It is done!
1744
.editorconfig
Normal file
83
.gitignore
vendored
|
@ -1,4 +1,3 @@
|
||||||
# ---> VisualStudio
|
|
||||||
## Ignore Visual Studio temporary files, build results, and
|
## Ignore Visual Studio temporary files, build results, and
|
||||||
## files generated by popular Visual Studio add-ons.
|
## files generated by popular Visual Studio add-ons.
|
||||||
##
|
##
|
||||||
|
@ -35,6 +34,8 @@ bld/
|
||||||
|
|
||||||
# Visual Studio 2015/2017 cache/options directory
|
# Visual Studio 2015/2017 cache/options directory
|
||||||
.vs/
|
.vs/
|
||||||
|
# Visual Studio Code
|
||||||
|
.vscode/
|
||||||
# Uncomment if you have tasks that create the project's static files in wwwroot
|
# Uncomment if you have tasks that create the project's static files in wwwroot
|
||||||
#wwwroot/
|
#wwwroot/
|
||||||
|
|
||||||
|
@ -58,11 +59,14 @@ dlldata.c
|
||||||
# Benchmark Results
|
# Benchmark Results
|
||||||
BenchmarkDotNet.Artifacts/
|
BenchmarkDotNet.Artifacts/
|
||||||
|
|
||||||
# .NET Core
|
# .NET
|
||||||
project.lock.json
|
project.lock.json
|
||||||
project.fragment.lock.json
|
project.fragment.lock.json
|
||||||
artifacts/
|
artifacts/
|
||||||
|
|
||||||
|
# Tye
|
||||||
|
.tye/
|
||||||
|
|
||||||
# ASP.NET Scaffolding
|
# ASP.NET Scaffolding
|
||||||
ScaffoldingReadMe.txt
|
ScaffoldingReadMe.txt
|
||||||
|
|
||||||
|
@ -398,3 +402,78 @@ FodyWeavers.xsd
|
||||||
# JetBrains Rider
|
# JetBrains Rider
|
||||||
*.sln.iml
|
*.sln.iml
|
||||||
|
|
||||||
|
##
|
||||||
|
## Visual studio for Mac
|
||||||
|
##
|
||||||
|
|
||||||
|
|
||||||
|
# globs
|
||||||
|
Makefile.in
|
||||||
|
*.userprefs
|
||||||
|
*.usertasks
|
||||||
|
config.make
|
||||||
|
config.status
|
||||||
|
aclocal.m4
|
||||||
|
install-sh
|
||||||
|
autom4te.cache/
|
||||||
|
*.tar.gz
|
||||||
|
tarballs/
|
||||||
|
test-results/
|
||||||
|
|
||||||
|
# Mac bundle stuff
|
||||||
|
*.dmg
|
||||||
|
*.app
|
||||||
|
|
||||||
|
# content below from: https://github.com/github/gitignore/blob/master/Global/macOS.gitignore
|
||||||
|
# General
|
||||||
|
.DS_Store
|
||||||
|
.AppleDouble
|
||||||
|
.LSOverride
|
||||||
|
|
||||||
|
# Icon must end with two \r
|
||||||
|
Icon
|
||||||
|
|
||||||
|
|
||||||
|
# Thumbnails
|
||||||
|
._*
|
||||||
|
|
||||||
|
# Files that might appear in the root of a volume
|
||||||
|
.DocumentRevisions-V100
|
||||||
|
.fseventsd
|
||||||
|
.Spotlight-V100
|
||||||
|
.TemporaryItems
|
||||||
|
.Trashes
|
||||||
|
.VolumeIcon.icns
|
||||||
|
.com.apple.timemachine.donotpresent
|
||||||
|
|
||||||
|
# Directories potentially created on remote AFP share
|
||||||
|
.AppleDB
|
||||||
|
.AppleDesktop
|
||||||
|
Network Trash Folder
|
||||||
|
Temporary Items
|
||||||
|
.apdisk
|
||||||
|
|
||||||
|
# content below from: https://github.com/github/gitignore/blob/master/Global/Windows.gitignore
|
||||||
|
# Windows thumbnail cache files
|
||||||
|
Thumbs.db
|
||||||
|
ehthumbs.db
|
||||||
|
ehthumbs_vista.db
|
||||||
|
|
||||||
|
# Dump file
|
||||||
|
*.stackdump
|
||||||
|
|
||||||
|
# Folder config file
|
||||||
|
[Dd]esktop.ini
|
||||||
|
|
||||||
|
# Recycle Bin used on file shares
|
||||||
|
$RECYCLE.BIN/
|
||||||
|
|
||||||
|
# Windows Installer files
|
||||||
|
*.cab
|
||||||
|
*.msi
|
||||||
|
*.msix
|
||||||
|
*.msm
|
||||||
|
*.msp
|
||||||
|
|
||||||
|
# Windows shortcuts
|
||||||
|
*.lnk
|
25
EllieHub.sln
Normal file
|
@ -0,0 +1,25 @@
|
||||||
|
|
||||||
|
Microsoft Visual Studio Solution File, Format Version 12.00
|
||||||
|
# Visual Studio Version 17
|
||||||
|
VisualStudioVersion = 17.6.33829.357
|
||||||
|
MinimumVisualStudioVersion = 10.0.40219.1
|
||||||
|
Project("{9A19103F-16F7-4668-BE54-9A1E7A4F7556}") = "EllieHub", "EllieHub\EllieHub.csproj", "{6E399AD5-2130-4F97-A08F-397EFCE5872A}"
|
||||||
|
EndProject
|
||||||
|
Global
|
||||||
|
GlobalSection(SolutionConfigurationPlatforms) = preSolution
|
||||||
|
Debug|Any CPU = Debug|Any CPU
|
||||||
|
Release|Any CPU = Release|Any CPU
|
||||||
|
EndGlobalSection
|
||||||
|
GlobalSection(ProjectConfigurationPlatforms) = postSolution
|
||||||
|
{6E399AD5-2130-4F97-A08F-397EFCE5872A}.Debug|Any CPU.ActiveCfg = Debug|Any CPU
|
||||||
|
{6E399AD5-2130-4F97-A08F-397EFCE5872A}.Debug|Any CPU.Build.0 = Debug|Any CPU
|
||||||
|
{6E399AD5-2130-4F97-A08F-397EFCE5872A}.Release|Any CPU.ActiveCfg = Release|Any CPU
|
||||||
|
{6E399AD5-2130-4F97-A08F-397EFCE5872A}.Release|Any CPU.Build.0 = Release|Any CPU
|
||||||
|
EndGlobalSection
|
||||||
|
GlobalSection(SolutionProperties) = preSolution
|
||||||
|
HideSolutionNode = FALSE
|
||||||
|
EndGlobalSection
|
||||||
|
GlobalSection(ExtensibilityGlobals) = postSolution
|
||||||
|
SolutionGuid = {CB52F80B-4BF5-4BF2-AC3A-939FF8588802}
|
||||||
|
EndGlobalSection
|
||||||
|
EndGlobal
|
50
EllieHub/App.axaml
Normal file
|
@ -0,0 +1,50 @@
|
||||||
|
<Application xmlns="https://github.com/avaloniaui"
|
||||||
|
xmlns:x="http://schemas.microsoft.com/winfx/2006/xaml"
|
||||||
|
xmlns:local="using:EllieHub"
|
||||||
|
RequestedThemeVariant="Default"
|
||||||
|
x:Class="EllieHub.App">
|
||||||
|
<!-- "Default" ThemeVariant follows system theme variant. "Dark" or "Light" are other available options. -->
|
||||||
|
|
||||||
|
<Application.DataTemplates>
|
||||||
|
<local:ViewLocator/>
|
||||||
|
</Application.DataTemplates>
|
||||||
|
|
||||||
|
<!--Resources-->
|
||||||
|
<!--Think of them like global variables-->
|
||||||
|
<Application.Resources>
|
||||||
|
<ResourceDictionary>
|
||||||
|
<ResourceDictionary.MergedDictionaries>
|
||||||
|
<ResourceInclude Source="/Resources/Colors.axaml" />
|
||||||
|
<ResourceInclude Source="/Resources/Fonts.axaml" />
|
||||||
|
<ResourceInclude Source="/Resources/Images.axaml" />
|
||||||
|
</ResourceDictionary.MergedDictionaries>
|
||||||
|
</ResourceDictionary>
|
||||||
|
</Application.Resources>
|
||||||
|
|
||||||
|
<!--Styles-->
|
||||||
|
<!--Think of them like CSS classes-->
|
||||||
|
<Application.Styles>
|
||||||
|
<StyleInclude Source="/Styles/EllieStyles.axaml" />
|
||||||
|
<!--Generated Here: https://theme.xaml.live/-->
|
||||||
|
<FluentTheme>
|
||||||
|
<FluentTheme.Palettes>
|
||||||
|
<ColorPaletteResources x:Key="Light" Accent="#ff0073cf" AltHigh="White" AltLow="White" AltMedium="White" AltMediumHigh="White" AltMediumLow="White" BaseHigh="Black" BaseLow="#6fcccccc" BaseMedium="#c5898989" BaseMediumHigh="#ff5d5d5d" BaseMediumLow="#e2737373" ChromeAltLow="#ff5d5d5d" ChromeBlackHigh="Black" ChromeBlackLow="#6fcccccc" ChromeBlackMedium="#ff5d5d5d" ChromeBlackMediumLow="#c5898989" ChromeDisabledHigh="#6fcccccc" ChromeDisabledLow="#c5898989" ChromeGray="#e2737373" ChromeHigh="#6fcccccc" ChromeLow="#ffececec" ChromeMedium="#e2e6e6e6" ChromeMediumLow="#ffececec" ChromeWhite="White" ListLow="#e2e6e6e6" ListMedium="#6fcccccc" RegionColor="White" />
|
||||||
|
<ColorPaletteResources x:Key="Dark" Accent="#ff0073cf" AltHigh="Black" AltLow="Black" AltMedium="Black" AltMediumHigh="Black" AltMediumLow="Black" BaseHigh="White" BaseLow="#ff333333" BaseMedium="#ff9a9a9a" BaseMediumHigh="#ffb4b4b4" BaseMediumLow="#ff676767" ChromeAltLow="#ffb4b4b4" ChromeBlackHigh="Black" ChromeBlackLow="#ffb4b4b4" ChromeBlackMedium="Black" ChromeBlackMediumLow="Black" ChromeDisabledHigh="#ff333333" ChromeDisabledLow="#ff9a9a9a" ChromeGray="Gray" ChromeHigh="Gray" ChromeLow="#ff151515" ChromeMedium="#ff1d1d1d" ChromeMediumLow="#ff2c2c2c" ChromeWhite="White" ListLow="#ff1d1d1d" ListMedium="#ff333333" RegionColor="#ff181818" />
|
||||||
|
</FluentTheme.Palettes>
|
||||||
|
</FluentTheme>
|
||||||
|
</Application.Styles>
|
||||||
|
|
||||||
|
<TrayIcon.Icons>
|
||||||
|
<TrayIcons>
|
||||||
|
<TrayIcon Clicked="TrayDoubleClick" ToolTipText="Ellie Updater" Icon="{DynamicResource EllieHubIcon}">
|
||||||
|
<TrayIcon.Menu>
|
||||||
|
<NativeMenu>
|
||||||
|
<NativeMenuItem Header="Open" Click="ShowApp" />
|
||||||
|
<NativeMenuItemSeparator />
|
||||||
|
<NativeMenuItem Header="Close" Click="CloseApp" />
|
||||||
|
</NativeMenu>
|
||||||
|
</TrayIcon.Menu>
|
||||||
|
</TrayIcon>
|
||||||
|
</TrayIcons>
|
||||||
|
</TrayIcon.Icons>
|
||||||
|
</Application>
|
80
EllieHub/App.axaml.cs
Normal file
|
@ -0,0 +1,80 @@
|
||||||
|
using Avalonia;
|
||||||
|
using Avalonia.Controls;
|
||||||
|
using Avalonia.Controls.ApplicationLifetimes;
|
||||||
|
using Avalonia.Markup.Xaml;
|
||||||
|
using Microsoft.Extensions.DependencyInjection;
|
||||||
|
using EllieHub.Views.Windows;
|
||||||
|
using System.Reflection;
|
||||||
|
|
||||||
|
namespace EllieHub;
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Defines the Avalonia application.
|
||||||
|
/// </summary>
|
||||||
|
public partial class App : Application
|
||||||
|
{
|
||||||
|
private DateTimeOffset _trayClickTime = DateTimeOffset.UnixEpoch;
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// IoC container with all services required by the application.
|
||||||
|
/// </summary>
|
||||||
|
public IServiceProvider Services { get; } = new ServiceCollection()
|
||||||
|
.RegisterViewsAndViewModels(Assembly.GetExecutingAssembly())
|
||||||
|
.RegisterServices()
|
||||||
|
.BuildServiceProvider(true);
|
||||||
|
|
||||||
|
/// <inheritdoc />
|
||||||
|
public override void Initialize()
|
||||||
|
=> AvaloniaXamlLoader.Load(this);
|
||||||
|
|
||||||
|
/// <inheritdoc />
|
||||||
|
public override void OnFrameworkInitializationCompleted()
|
||||||
|
{
|
||||||
|
if (ApplicationLifetime is IClassicDesktopStyleApplicationLifetime desktop)
|
||||||
|
desktop.MainWindow = Services.GetRequiredService<AppView>();
|
||||||
|
|
||||||
|
base.OnFrameworkInitializationCompleted();
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Executed when the "Open" menu in the tray icon is clicked.
|
||||||
|
/// </summary>
|
||||||
|
/// <param name="sender">A <see cref="NativeMenuItem"/>.</param>
|
||||||
|
/// <param name="eventArgs">A <see cref="EventArgs"/>.</param>
|
||||||
|
private void ShowApp(object sender, EventArgs eventArgs)
|
||||||
|
=> Services.GetRequiredService<AppView>().Show();
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Executed when the "Close" menu in the tray icon is clicked.
|
||||||
|
/// </summary>
|
||||||
|
/// <param name="sender">A <see cref="NativeMenuItem"/>.</param>
|
||||||
|
/// <param name="eventArgs">A <see cref="EventArgs"/>.</param>
|
||||||
|
private void CloseApp(object sender, EventArgs eventArgs)
|
||||||
|
=> Services.GetRequiredService<AppView>().Close();
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Executed when the tray icon is clicked.
|
||||||
|
/// </summary>
|
||||||
|
/// <param name="sender">A <see cref="TrayIcon"/>.</param>
|
||||||
|
/// <param name="eventArgs">A <see cref="EventArgs"/>.</param>
|
||||||
|
/// <remarks>Shows or hides the application when the tray icon is double-clicked.</remarks>
|
||||||
|
private void TrayDoubleClick(object sender, EventArgs eventArgs)
|
||||||
|
{
|
||||||
|
// If this is the first click or if the second click took longer than 0.3 seconds, exit.
|
||||||
|
if (_trayClickTime == DateTimeOffset.UnixEpoch || DateTimeOffset.Now.Subtract(_trayClickTime) > TimeSpan.FromSeconds(0.3))
|
||||||
|
{
|
||||||
|
_trayClickTime = DateTimeOffset.Now;
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
// User has double-clicked the tray icon. Reset the timer.
|
||||||
|
_trayClickTime = DateTimeOffset.UnixEpoch;
|
||||||
|
|
||||||
|
var mainWindow = Services.GetRequiredService<AppView>();
|
||||||
|
|
||||||
|
if (mainWindow.IsVisible)
|
||||||
|
mainWindow.Hide();
|
||||||
|
else
|
||||||
|
mainWindow.Show();
|
||||||
|
}
|
||||||
|
}
|
BIN
EllieHub/Assets/Dark/check_for_updates.png
Normal file
After Width: | Height: | Size: 18 KiB |
BIN
EllieHub/Assets/Dark/config.png
Normal file
After Width: | Height: | Size: 1 KiB |
BIN
EllieHub/Assets/Dark/deps.png
Normal file
After Width: | Height: | Size: 35 KiB |
BIN
EllieHub/Assets/Dark/docs.png
Normal file
After Width: | Height: | Size: 9.3 KiB |
BIN
EllieHub/Assets/Dark/ellieupdatericon.ico
Normal file
After Width: | Height: | Size: 4.2 KiB |
BIN
EllieHub/Assets/Dark/home.png
Normal file
After Width: | Height: | Size: 13 KiB |
BIN
EllieHub/Assets/Dark/icon-commands.png
Normal file
After Width: | Height: | Size: 175 B |
BIN
EllieHub/Assets/Dark/icon-embeds.png
Normal file
After Width: | Height: | Size: 340 B |
BIN
EllieHub/Assets/Dark/icon-link.png
Normal file
After Width: | Height: | Size: 398 B |
BIN
EllieHub/Assets/Dark/icon-suggest.png
Normal file
After Width: | Height: | Size: 528 B |
BIN
EllieHub/Assets/Dark/icon-support.png
Normal file
After Width: | Height: | Size: 587 B |
BIN
EllieHub/Assets/Dark/terminal.png
Normal file
After Width: | Height: | Size: 595 B |
BIN
EllieHub/Assets/Light/check_for_updates.png
Normal file
After Width: | Height: | Size: 11 KiB |
BIN
EllieHub/Assets/Light/config.png
Normal file
After Width: | Height: | Size: 1.4 KiB |
BIN
EllieHub/Assets/Light/deps.png
Normal file
After Width: | Height: | Size: 41 KiB |
BIN
EllieHub/Assets/Light/docs.png
Normal file
After Width: | Height: | Size: 13 KiB |
BIN
EllieHub/Assets/Light/ellieupdatericon.ico
Normal file
After Width: | Height: | Size: 4.2 KiB |
BIN
EllieHub/Assets/Light/home.png
Normal file
After Width: | Height: | Size: 12 KiB |
BIN
EllieHub/Assets/Light/icon-commands.png
Normal file
After Width: | Height: | Size: 552 B |
BIN
EllieHub/Assets/Light/icon-embeds.png
Normal file
After Width: | Height: | Size: 712 B |
BIN
EllieHub/Assets/Light/icon-link.png
Normal file
After Width: | Height: | Size: 827 B |
BIN
EllieHub/Assets/Light/icon-suggest.png
Normal file
After Width: | Height: | Size: 908 B |
BIN
EllieHub/Assets/Light/icon-support.png
Normal file
After Width: | Height: | Size: 1,016 B |
BIN
EllieHub/Assets/Light/terminal.png
Normal file
After Width: | Height: | Size: 992 B |
BIN
EllieHub/Assets/Unused/addbot.png
Normal file
After Width: | Height: | Size: 9.8 KiB |
BIN
EllieHub/Assets/Unused/avalonia-logo.ico
Normal file
After Width: | Height: | Size: 172 KiB |
BIN
EllieHub/Assets/Unused/black_home.png
Normal file
After Width: | Height: | Size: 158 KiB |
BIN
EllieHub/Assets/Unused/config2.png
Normal file
After Width: | Height: | Size: 38 KiB |
BIN
EllieHub/Assets/Unused/config_active.png
Normal file
After Width: | Height: | Size: 471 B |
BIN
EllieHub/Assets/Unused/delete.png
Normal file
After Width: | Height: | Size: 13 KiB |
BIN
EllieHub/Assets/Unused/install_bg.png
Normal file
After Width: | Height: | Size: 592 KiB |
BIN
EllieHub/Assets/Unused/setup.png
Normal file
After Width: | Height: | Size: 447 B |
BIN
EllieHub/Assets/Unused/setup_active.png
Normal file
After Width: | Height: | Size: 726 B |
BIN
EllieHub/Assets/Unused/terminal_active.png
Normal file
After Width: | Height: | Size: 524 B |
BIN
EllieHub/Assets/Unused/unknown.png
Normal file
After Width: | Height: | Size: 5.4 KiB |
BIN
EllieHub/Assets/Unused/white_home.png
Normal file
After Width: | Height: | Size: 192 KiB |
BIN
EllieHub/Assets/ellie.png
Normal file
After Width: | Height: | Size: 273 KiB |
BIN
EllieHub/Assets/ko-fi.webp
Normal file
After Width: | Height: | Size: 5.9 KiB |
BIN
EllieHub/Assets/patreon.png
Normal file
After Width: | Height: | Size: 11 KiB |
BIN
EllieHub/Assets/paypal.png
Normal file
After Width: | Height: | Size: 14 KiB |
17
EllieHub/Common/AppConstants.cs
Normal file
|
@ -0,0 +1,17 @@
|
||||||
|
namespace EllieHub.Common;
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Defines the constants used throughout the whole application.
|
||||||
|
/// </summary>
|
||||||
|
public static class AppConstants
|
||||||
|
{
|
||||||
|
/// <summary>
|
||||||
|
/// Defines the location of the default image for the bot avatar.
|
||||||
|
/// </summary>
|
||||||
|
public const string BotAvatarUri = "avares://EllieHub/Assets/ellie.png";
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// The name for an <see cref="HttpClient"/> that does not automatically follow redirect responses.
|
||||||
|
/// </summary>
|
||||||
|
public const string NoRedirectClient = "NoRedirect";
|
||||||
|
}
|
127
EllieHub/Common/AppResources.cs
Normal file
|
@ -0,0 +1,127 @@
|
||||||
|
using Avalonia.Controls;
|
||||||
|
using Avalonia.Media.Imaging;
|
||||||
|
using Avalonia.Media.Immutable;
|
||||||
|
|
||||||
|
namespace EllieHub.Common;
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Defines names of resources for the application.
|
||||||
|
/// </summary>
|
||||||
|
public static class AppResources
|
||||||
|
{
|
||||||
|
#region Images
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Resource type: <see cref="Bitmap"/>
|
||||||
|
/// </summary>
|
||||||
|
public const string EllieAvatar = "EllieAvatar";
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Resource type: <see cref="Bitmap"/>
|
||||||
|
/// </summary>
|
||||||
|
public const string PaypalIcon = "PaypalIcon";
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Resource type: <see cref="Bitmap"/>
|
||||||
|
/// </summary>
|
||||||
|
public const string PatreonIcon = "PatreonIcon";
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Resource type: <see cref="Bitmap"/>
|
||||||
|
/// </summary>
|
||||||
|
public const string CheckForUpdateIcon = "CheckForUpdateIcon";
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Resource type: <see cref="Bitmap"/>
|
||||||
|
/// </summary>
|
||||||
|
public const string ConfigIcon = "ConfigIcon";
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Resource type: <see cref="Bitmap"/>
|
||||||
|
/// </summary>
|
||||||
|
public const string DependenciesIcon = "DependenciesIcon";
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Resource type: <see cref="Bitmap"/>
|
||||||
|
/// </summary>
|
||||||
|
public const string DocumentationIcon = "DocumentationIcon";
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Resource type: <see cref="Bitmap"/>
|
||||||
|
/// </summary>
|
||||||
|
public const string HomeIcon = "HomeIcon";
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Resource type: <see cref="Bitmap"/>
|
||||||
|
/// </summary>
|
||||||
|
public const string EmbedsIcon = "EmbedsIcon";
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Resource type: <see cref="Bitmap"/>
|
||||||
|
/// </summary>
|
||||||
|
public const string UrlIcon = "UrlIcon";
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Resource type: <see cref="Bitmap"/>
|
||||||
|
/// </summary>
|
||||||
|
public const string SuggestionIcon = "SuggestionIcon";
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Resource type: <see cref="Bitmap"/>
|
||||||
|
/// </summary>
|
||||||
|
public const string DiscordIcon = "DiscordIcon";
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Resource type: <see cref="WindowIcon"/>
|
||||||
|
/// </summary>
|
||||||
|
public const string EllieHubIcon = "EllieHubIcon";
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Resource type: <see cref="Bitmap"/>
|
||||||
|
/// </summary>
|
||||||
|
public const string EllieHubImage = "EllieHubImage";
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Resource type: <see cref="Bitmap"/>
|
||||||
|
/// </summary>
|
||||||
|
public const string TerminalIcon = "TerminalIcon";
|
||||||
|
|
||||||
|
#endregion
|
||||||
|
|
||||||
|
#region Color Brushes
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Resource type: <see cref="ImmutableSolidColorBrush"/>
|
||||||
|
/// </summary>
|
||||||
|
public const string LightBackground = "LightBackground";
|
||||||
|
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Resource type: <see cref="ImmutableSolidColorBrush"/>
|
||||||
|
/// </summary>
|
||||||
|
public const string MediumBackground = "MediumBackground";
|
||||||
|
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Resource type: <see cref="ImmutableSolidColorBrush"/>
|
||||||
|
/// </summary>
|
||||||
|
public const string HeavyBackground = "HeavyBackground";
|
||||||
|
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Resource type: <see cref="ImmutableSolidColorBrush"/>
|
||||||
|
/// </summary>
|
||||||
|
public const string BotSelectionColor = "BotSelectionColor";
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Resource type: <see cref="ImmutableSolidColorBrush"/>
|
||||||
|
/// </summary>
|
||||||
|
public const string DependencyInstall = "DependencyInstall";
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Resource type: <see cref="ImmutableSolidColorBrush"/>
|
||||||
|
/// </summary>
|
||||||
|
public const string DependencyUpdate = "DependencyUpdate";
|
||||||
|
|
||||||
|
#endregion
|
||||||
|
}
|
73
EllieHub/Common/AppStatics.cs
Normal file
|
@ -0,0 +1,73 @@
|
||||||
|
using Avalonia.Media;
|
||||||
|
using Avalonia.Media.Immutable;
|
||||||
|
using Avalonia.Platform.Storage;
|
||||||
|
using System.Text.RegularExpressions;
|
||||||
|
|
||||||
|
namespace EllieHub.Common;
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Defines the application's environment data.
|
||||||
|
/// </summary>
|
||||||
|
public static partial class AppStatics
|
||||||
|
{
|
||||||
|
/// <summary>
|
||||||
|
/// Defines the default location where the updater configuration and bot instances are stored.
|
||||||
|
/// </summary>
|
||||||
|
#if DEBUG
|
||||||
|
public static string AppDefaultConfigDirectoryUri { get; } = Path.Combine(Environment.GetFolderPath(Environment.SpecialFolder.Personal), "EllieHubDebug");
|
||||||
|
#else
|
||||||
|
public static string AppDefaultConfigDirectoryUri { get; } = Path.Combine(Environment.GetFolderPath(Environment.SpecialFolder.ApplicationData), "EllieHub");
|
||||||
|
#endif
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Defines the default location where the bot instances are stored.
|
||||||
|
/// </summary>
|
||||||
|
public static string AppDefaultBotDirectoryUri { get; } = Path.Combine(AppDefaultConfigDirectoryUri, "Bots");
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Defines the default location where the backups of bot instances are stored.
|
||||||
|
/// </summary>
|
||||||
|
public static string AppDefaultBotBackupDirectoryUri { get; } = Path.Combine(AppDefaultConfigDirectoryUri, "Backups");
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Defines the default location where the logs of bot instances are stored.
|
||||||
|
/// </summary>
|
||||||
|
public static string AppDefaultLogDirectoryUri { get; } = Path.Combine(AppDefaultConfigDirectoryUri, "Logs");
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Defines the location of the application's configuration file.
|
||||||
|
/// </summary>
|
||||||
|
public static string AppConfigUri { get; } = Path.Combine(AppDefaultConfigDirectoryUri, "config.json");
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Defines the location of the application's dependencies.
|
||||||
|
/// </summary>
|
||||||
|
public static string AppDepsUri { get; } = Path.Combine(AppDefaultConfigDirectoryUri, "Dependencies");
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Defines a transparent color brush.
|
||||||
|
/// </summary>
|
||||||
|
public static ImmutableSolidColorBrush TransparentColorBrush { get; } = new(Colors.Transparent);
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Represents the image formats supported by the views of this application.
|
||||||
|
/// </summary>
|
||||||
|
public static FilePickerOpenOptions ImageFilePickerOptions { get; } = new()
|
||||||
|
{
|
||||||
|
AllowMultiple = false,
|
||||||
|
FileTypeFilter = new FilePickerFileType[]
|
||||||
|
{
|
||||||
|
new("Image") { Patterns = new[] { "*.png", "*.jpg", "*.jpeg", "*.gif", "*.webp" } },
|
||||||
|
new("All") { Patterns = new[] { "*.*" } }
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Matches the version of Ffmpeg from its CLI output.
|
||||||
|
/// </summary>
|
||||||
|
/// <remarks>Pattern: ^(?:\S+\s+\D*?){2}(git\S+|[\d\.]+)</remarks>
|
||||||
|
public static Regex FfmpegVersionRegex { get; } = FfmpegVersionRegexGenerator();
|
||||||
|
|
||||||
|
[GeneratedRegex(@"^(?:\S+\s+\D*?){2}(git\S+|[\d\.]+)", RegexOptions.Compiled)]
|
||||||
|
private static partial Regex FfmpegVersionRegexGenerator();
|
||||||
|
}
|
22
EllieHub/Common/DialogType.cs
Normal file
|
@ -0,0 +1,22 @@
|
||||||
|
namespace EllieHub.Common;
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Defines the available types of dialog windows.
|
||||||
|
/// </summary>
|
||||||
|
public enum DialogType
|
||||||
|
{
|
||||||
|
/// <summary>
|
||||||
|
/// The dialog box notifies the user about something.
|
||||||
|
/// </summary>
|
||||||
|
Notification,
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// The dialog box notifies the user of a non-fatal error.
|
||||||
|
/// </summary>
|
||||||
|
Warning,
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// The dialog box notifies the user of a fatal error.
|
||||||
|
/// </summary>
|
||||||
|
Error
|
||||||
|
}
|
215
EllieHub/Common/Utilities.cs
Normal file
|
@ -0,0 +1,215 @@
|
||||||
|
using Avalonia.Platform;
|
||||||
|
using SkiaSharp;
|
||||||
|
using System.ComponentModel;
|
||||||
|
using System.Diagnostics;
|
||||||
|
using System.Diagnostics.CodeAnalysis;
|
||||||
|
|
||||||
|
namespace EllieHub.Common;
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Miscellaneous utility methods.
|
||||||
|
/// </summary>
|
||||||
|
internal static class Utilities
|
||||||
|
{
|
||||||
|
private static readonly string _programVerifier = (Environment.OSVersion.Platform is PlatformID.Win32NT) ? "where" : "which";
|
||||||
|
private static readonly string _envPathSeparator = (Environment.OSVersion.Platform is PlatformID.Win32NT) ? ";" : ":";
|
||||||
|
private static readonly EnvironmentVariableTarget _envTarget = (Environment.OSVersion.Platform is PlatformID.Win32NT)
|
||||||
|
? EnvironmentVariableTarget.User
|
||||||
|
: EnvironmentVariableTarget.Process;
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Loads an image embeded with this application.
|
||||||
|
/// </summary>
|
||||||
|
/// <param name="uri">An uri that starts with "avares://"</param>
|
||||||
|
/// <remarks>Valid uris must start with "avares://".</remarks>
|
||||||
|
/// <returns>The embeded image or the default bot avatar placeholder.</returns>
|
||||||
|
/// <exception cref="FileNotFoundException">Occurs when the embeded resource does not exist.</exception>
|
||||||
|
public static SKBitmap LoadEmbededImage(string? uri = default)
|
||||||
|
{
|
||||||
|
return (string.IsNullOrWhiteSpace(uri) || !uri.StartsWith("avares://"))
|
||||||
|
? SKBitmap.Decode(AssetLoader.Open(new Uri(AppConstants.BotAvatarUri)))
|
||||||
|
: SKBitmap.Decode(AssetLoader.Open(new Uri(uri)));
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Loads the image at the specified location or the bot avatar placeholder if it was not found.
|
||||||
|
/// </summary>
|
||||||
|
/// <param name="uri">The absolute path to the image file or <see langword="null"/> to get the avatar placeholder.</param>
|
||||||
|
/// <remarks>This fallsback to <see cref="LoadEmbededImage(string?)"/> if <paramref name="uri"/> doesn't point to a valid image file.</remarks>
|
||||||
|
/// <returns>The requested image or the default bot avatar placeholder.</returns>
|
||||||
|
public static SKBitmap LoadLocalImage(string? uri = default)
|
||||||
|
{
|
||||||
|
return (File.Exists(uri))
|
||||||
|
? SKBitmap.Decode(uri)
|
||||||
|
: LoadEmbededImage(uri);
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Safely casts an <see cref="object"/> to a <typeparamref name="T"/>.
|
||||||
|
/// </summary>
|
||||||
|
/// <typeparam name="T">The type to cast to.</typeparam>
|
||||||
|
/// <param name="obj">The object to be cast.</param>
|
||||||
|
/// <param name="castObject">The cast object, or <see langword="null"/> is casting failed.</param>
|
||||||
|
/// <returns><see langword="true"/> if the object was successfully cast, <see langword="false"/> otherwise.</returns>
|
||||||
|
public static bool TryCastTo<T>(object? obj, [MaybeNullWhen(false)] out T castObject)
|
||||||
|
{
|
||||||
|
if (obj is T result)
|
||||||
|
{
|
||||||
|
castObject = result;
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
|
||||||
|
castObject = default;
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Starts the specified program in the background.
|
||||||
|
/// </summary>
|
||||||
|
/// <param name="program">
|
||||||
|
/// The name of the program in the PATH environment variable,
|
||||||
|
/// or the absolute path to its executable.
|
||||||
|
/// </param>
|
||||||
|
/// <param name="arguments">The arguments to the program.</param>
|
||||||
|
/// <returns>The process of the specified program.</returns>
|
||||||
|
/// <exception cref="ArgumentException" />
|
||||||
|
/// <exception cref="ArgumentNullException" />
|
||||||
|
/// <exception cref="Win32Exception">Occurs when <paramref name="program"/> does not exist.</exception>
|
||||||
|
/// <exception cref="InvalidOperationException">Occurs when the process fails to execute.</exception>
|
||||||
|
public static Process StartProcess(string program, string arguments = "")
|
||||||
|
{
|
||||||
|
ArgumentException.ThrowIfNullOrEmpty(program, nameof(program));
|
||||||
|
ArgumentNullException.ThrowIfNull(arguments, nameof(arguments));
|
||||||
|
|
||||||
|
return Process.Start(new ProcessStartInfo()
|
||||||
|
{
|
||||||
|
FileName = program,
|
||||||
|
Arguments = arguments,
|
||||||
|
UseShellExecute = false,
|
||||||
|
CreateNoWindow = true,
|
||||||
|
RedirectStandardOutput = true,
|
||||||
|
}) ?? throw new InvalidOperationException($"Failed spawing process for: {program} {arguments}");
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Checks if a program exists.
|
||||||
|
/// </summary>
|
||||||
|
/// <param name="programName">The name of the program.</param>
|
||||||
|
/// <param name="cToken">The cancellation token.</param>
|
||||||
|
/// <returns><see langword="true"/> if the program exists, <see langword="false"/> otherwise.</returns>
|
||||||
|
/// <exception cref="ArgumentException" />
|
||||||
|
/// <exception cref="ArgumentNullException" />
|
||||||
|
public static async ValueTask<bool> ProgramExistsAsync(string programName, CancellationToken cToken = default)
|
||||||
|
{
|
||||||
|
ArgumentException.ThrowIfNullOrEmpty(programName, nameof(programName));
|
||||||
|
|
||||||
|
using var process = StartProcess(_programVerifier, programName);
|
||||||
|
return !string.IsNullOrWhiteSpace(await process.StandardOutput.ReadToEndAsync(cToken));
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Safely deletes a file.
|
||||||
|
/// </summary>
|
||||||
|
/// <param name="fileUri">The absolute path to the file.</param>
|
||||||
|
/// <returns><see langword="true"/> if the file was deleted, <see langword="false"/> otherwise.</returns>
|
||||||
|
/// <exception cref="ArgumentException" />
|
||||||
|
/// <exception cref="ArgumentNullException" />
|
||||||
|
/// <exception cref="IOException" />
|
||||||
|
/// <exception cref="NotSupportedException" />
|
||||||
|
/// <exception cref="PathTooLongException" />
|
||||||
|
/// <exception cref="UnauthorizedAccessException" />
|
||||||
|
public static bool TryDeleteFile(string fileUri)
|
||||||
|
{
|
||||||
|
ArgumentException.ThrowIfNullOrEmpty(fileUri, nameof(fileUri));
|
||||||
|
|
||||||
|
if (!File.Exists(fileUri))
|
||||||
|
return false;
|
||||||
|
|
||||||
|
File.Delete(fileUri);
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Safely deletes a directory.
|
||||||
|
/// </summary>
|
||||||
|
/// <param name="directoryUri">The absolute path to the directory.</param>
|
||||||
|
/// <returns><see langword="true"/> if the directory was deleted, <see langword="false"/> otherwise.</returns>
|
||||||
|
/// <exception cref="ArgumentException" />
|
||||||
|
/// <exception cref="ArgumentNullException" />
|
||||||
|
/// <exception cref="IOException" />
|
||||||
|
/// <exception cref="DirectoryNotFoundException" />
|
||||||
|
/// <exception cref="PathTooLongException" />
|
||||||
|
/// <exception cref="UnauthorizedAccessException" />
|
||||||
|
public static bool TryDeleteDirectory(string directoryUri)
|
||||||
|
{
|
||||||
|
ArgumentException.ThrowIfNullOrEmpty(directoryUri, nameof(directoryUri));
|
||||||
|
|
||||||
|
if (!Directory.Exists(directoryUri))
|
||||||
|
return false;
|
||||||
|
|
||||||
|
Directory.Delete(directoryUri, true);
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Checks if this application can write to <paramref name="directoryUri"/>.
|
||||||
|
/// </summary>
|
||||||
|
/// <param name="directoryUri">The absolute path to a directory.</param>
|
||||||
|
/// <returns><see langword="true"/> if writing is allowed, <see langword="false"/> otherwise.</returns>
|
||||||
|
/// <exception cref="PathTooLongException" />
|
||||||
|
/// <exception cref="DirectoryNotFoundException" />
|
||||||
|
public static bool CanWriteTo(string directoryUri)
|
||||||
|
{
|
||||||
|
var tempFileUri = Path.Combine(directoryUri, $"{Guid.NewGuid()}.tmp");
|
||||||
|
|
||||||
|
try
|
||||||
|
{
|
||||||
|
using var fileStream = File.Create(tempFileUri);
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
catch (UnauthorizedAccessException)
|
||||||
|
{
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
finally
|
||||||
|
{
|
||||||
|
TryDeleteFile(tempFileUri);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Adds a directory path to the PATH environment variable.
|
||||||
|
/// </summary>
|
||||||
|
/// <param name="directoryUri">The absolute path to a directory.</param>
|
||||||
|
/// <remarks>
|
||||||
|
/// On Windows, this needs to be called once and the dependencies will be available for the user forever. <br />
|
||||||
|
/// On Unix systems, we can only add to the PATH on a process basis, so this needs to be called at least once everytime the application is opened.
|
||||||
|
/// </remarks>
|
||||||
|
/// <returns><see langword="true"/> if <paramref name="directoryUri"/> got successfully added to the PATH envar, <see langword="false"/> otherwise.</returns>
|
||||||
|
/// <exception cref="ArgumentException" />
|
||||||
|
/// <exception cref="ArgumentNullException" />
|
||||||
|
public static bool AddPathToPATHEnvar(string directoryUri)
|
||||||
|
{
|
||||||
|
ArgumentException.ThrowIfNullOrEmpty(directoryUri, nameof(directoryUri));
|
||||||
|
|
||||||
|
if (File.Exists(directoryUri))
|
||||||
|
throw new ArgumentException("Parameter must point to a directory, not a file.", nameof(directoryUri));
|
||||||
|
|
||||||
|
var envPathValue = Environment.GetEnvironmentVariable("PATH", _envTarget) ?? string.Empty;
|
||||||
|
|
||||||
|
// If directoryPath is already in the PATH envar, don't add it again.
|
||||||
|
if (envPathValue.Contains(directoryUri, StringComparison.Ordinal))
|
||||||
|
return false;
|
||||||
|
|
||||||
|
var newPathEnvValue = envPathValue + _envPathSeparator + directoryUri;
|
||||||
|
|
||||||
|
// Add path to Windows' user envar, so it persists across reboots.
|
||||||
|
if (Environment.OSVersion.Platform is PlatformID.Win32NT)
|
||||||
|
Environment.SetEnvironmentVariable("PATH", newPathEnvValue, EnvironmentVariableTarget.User);
|
||||||
|
|
||||||
|
// Add path to the current process' envar, so the updater can see the dependencies.
|
||||||
|
Environment.SetEnvironmentVariable("PATH", newPathEnvValue, EnvironmentVariableTarget.Process);
|
||||||
|
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
}
|
37
EllieHub/Common/WindowConstants.cs
Normal file
|
@ -0,0 +1,37 @@
|
||||||
|
namespace EllieHub.Common;
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Defines constants to be used inside views.
|
||||||
|
/// </summary>
|
||||||
|
public static class WindowConstants
|
||||||
|
{
|
||||||
|
/// <summary>
|
||||||
|
/// Defines the default width of the window.
|
||||||
|
/// </summary>
|
||||||
|
public const string DefaultWindowWidth = "885";
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Defines the default height of the window.
|
||||||
|
/// </summary>
|
||||||
|
public const string DefaultWindowHeight = "570";
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Defines the minimum height of the window.
|
||||||
|
/// </summary>
|
||||||
|
public const string MinWindowWidth = "550";
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Defines the minimum height of the window.
|
||||||
|
/// </summary>
|
||||||
|
public const string MinWindowHeight = "500";
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Defines the default window title.
|
||||||
|
/// </summary>
|
||||||
|
public const string WindowTitle = "Ellie Hub";
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Defines the message that should be shown when a view's parameterless constructor should not be used.
|
||||||
|
/// </summary>
|
||||||
|
public const string DesignerCtorWarning = "This constructor exists to satisfy Avalonia's designer. Please, use the parameterized constructor instead.";
|
||||||
|
}
|
18
EllieHub/DesignData/Common/DesignStatics.cs
Normal file
|
@ -0,0 +1,18 @@
|
||||||
|
using Avalonia;
|
||||||
|
using Avalonia.Controls;
|
||||||
|
|
||||||
|
namespace EllieHub.DesignData.Common;
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Defines objects useful at design-time.
|
||||||
|
/// </summary>
|
||||||
|
internal static class DesignStatics
|
||||||
|
{
|
||||||
|
/// <summary>
|
||||||
|
/// Provides the services necessary for design-time rendering of views.
|
||||||
|
/// </summary>
|
||||||
|
/// <remarks>This property is <see langword="null"/> when the application is not in design mode.</remarks>
|
||||||
|
internal static IServiceProvider Services { get; } = (Design.IsDesignMode)
|
||||||
|
? (Application.Current as App)!.Services
|
||||||
|
: null!;
|
||||||
|
}
|
30
EllieHub/DesignData/Controls/DesignBotConfigViewModel.cs
Normal file
|
@ -0,0 +1,30 @@
|
||||||
|
using Microsoft.Extensions.DependencyInjection;
|
||||||
|
using EllieHub.DesignData.Common;
|
||||||
|
using EllieHub.Services.Abstractions;
|
||||||
|
using EllieHub.Services.Mocks;
|
||||||
|
using EllieHub.ViewModels.Controls;
|
||||||
|
using EllieHub.Views.Windows;
|
||||||
|
|
||||||
|
namespace EllieHub.DesignData.Controls;
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Mock view-model for <see cref="BotConfigViewModel"/>.
|
||||||
|
/// </summary>
|
||||||
|
public sealed class DesignBotConfigViewModel : BotConfigViewModel
|
||||||
|
{
|
||||||
|
/// <summary>
|
||||||
|
/// Creates a mock <see cref="BotConfigViewModel"/> to be used at design-time.
|
||||||
|
/// </summary>
|
||||||
|
public DesignBotConfigViewModel() : base(
|
||||||
|
DesignStatics.Services.GetRequiredService<MockAppConfigManager>(),
|
||||||
|
DesignStatics.Services.GetRequiredService<AppView>(),
|
||||||
|
DesignStatics.Services.GetRequiredService<UriInputBarViewModel>(),
|
||||||
|
DesignStatics.Services.GetRequiredService<DependencyButtonViewModel>(),
|
||||||
|
DesignStatics.Services.GetRequiredService<FakeConsoleViewModel>(),
|
||||||
|
DesignStatics.Services.GetRequiredService<MockEllieResolver>(),
|
||||||
|
DesignStatics.Services.GetRequiredService<IBotOrchestrator>(),
|
||||||
|
DesignStatics.Services.GetRequiredService<ILogWriter>()
|
||||||
|
)
|
||||||
|
{
|
||||||
|
}
|
||||||
|
}
|
31
EllieHub/DesignData/Controls/DesignConfigViewModel.cs
Normal file
|
@ -0,0 +1,31 @@
|
||||||
|
using Microsoft.Extensions.DependencyInjection;
|
||||||
|
using EllieHub.DesignData.Common;
|
||||||
|
using EllieHub.Services.Abstractions;
|
||||||
|
using EllieHub.Services.Mocks;
|
||||||
|
using EllieHub.ViewModels.Controls;
|
||||||
|
using EllieHub.ViewModels.Windows;
|
||||||
|
using EllieHub.Views.Windows;
|
||||||
|
|
||||||
|
namespace EllieHub.DesignData.Controls;
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Mock view-model for <see cref="ConfigViewModel"/>.
|
||||||
|
/// </summary>
|
||||||
|
public sealed class DesignConfigViewModel : ConfigViewModel
|
||||||
|
{
|
||||||
|
/// <summary>
|
||||||
|
/// Creates a mock <see cref="ConfigViewModel"/> to be used at design-time.
|
||||||
|
/// </summary>
|
||||||
|
public DesignConfigViewModel() : base(
|
||||||
|
DesignStatics.Services.GetRequiredService<MockAppConfigManager>(),
|
||||||
|
DesignStatics.Services.GetRequiredService<AppView>(),
|
||||||
|
DesignStatics.Services.GetRequiredService<UriInputBarViewModel>(),
|
||||||
|
DesignStatics.Services.GetRequiredService<UriInputBarViewModel>(),
|
||||||
|
DesignStatics.Services.GetRequiredService<UriInputBarViewModel>(),
|
||||||
|
DesignStatics.Services.GetRequiredService<AboutMeViewModel>(),
|
||||||
|
DesignStatics.Services.GetRequiredService<IFfmpegResolver>(),
|
||||||
|
DesignStatics.Services.GetRequiredService<IYtdlpResolver>()
|
||||||
|
)
|
||||||
|
{
|
||||||
|
}
|
||||||
|
}
|
|
@ -0,0 +1,19 @@
|
||||||
|
using Microsoft.Extensions.DependencyInjection;
|
||||||
|
using EllieHub.DesignData.Common;
|
||||||
|
using EllieHub.ViewModels.Controls;
|
||||||
|
using EllieHub.Views.Windows;
|
||||||
|
|
||||||
|
namespace EllieHub.DesignData.Controls;
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Mock view-model for <see cref="DependencyButtonViewModel"/>.
|
||||||
|
/// </summary>
|
||||||
|
public sealed class DesignDependencyButtonViewModel : DependencyButtonViewModel
|
||||||
|
{
|
||||||
|
/// <summary>
|
||||||
|
/// Creates a mock <see cref="DependencyButtonViewModel"/> to be used at design-time.
|
||||||
|
/// </summary>
|
||||||
|
public DesignDependencyButtonViewModel() : base(DesignStatics.Services.GetRequiredService<AppView>())
|
||||||
|
{
|
||||||
|
}
|
||||||
|
}
|
15
EllieHub/DesignData/Controls/DesignFakeConsoleViewModel.cs
Normal file
|
@ -0,0 +1,15 @@
|
||||||
|
using EllieHub.ViewModels.Controls;
|
||||||
|
|
||||||
|
namespace EllieHub.DesignData.Controls;
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Mock view-model for <see cref="FakeConsoleViewModel"/>.
|
||||||
|
/// </summary>
|
||||||
|
public sealed class DesignFakeConsoleViewModel : FakeConsoleViewModel
|
||||||
|
{
|
||||||
|
/// <summary>
|
||||||
|
/// Creates a mock <see cref="FakeConsoleViewModel"/> to be used at design-time.
|
||||||
|
/// </summary>
|
||||||
|
public DesignFakeConsoleViewModel() : base()
|
||||||
|
=> Watermark = "Sample watermark.";
|
||||||
|
}
|
10
EllieHub/DesignData/Controls/DesignHomeViewModel.cs
Normal file
|
@ -0,0 +1,10 @@
|
||||||
|
using EllieHub.ViewModels.Controls;
|
||||||
|
|
||||||
|
namespace EllieHub.DesignData.Controls;
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Mock view-model for <see cref="HomeViewModel"/>.
|
||||||
|
/// </summary>
|
||||||
|
public sealed class DesignHomeViewModel : HomeViewModel
|
||||||
|
{
|
||||||
|
}
|
19
EllieHub/DesignData/Controls/DesignLateralBarViewModel.cs
Normal file
|
@ -0,0 +1,19 @@
|
||||||
|
using Microsoft.Extensions.DependencyInjection;
|
||||||
|
using EllieHub.DesignData.Common;
|
||||||
|
using EllieHub.Services.Mocks;
|
||||||
|
using EllieHub.ViewModels.Controls;
|
||||||
|
|
||||||
|
namespace EllieHub.DesignData.Controls;
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Mock view-model for <see cref="LateralBarViewModel"/>.
|
||||||
|
/// </summary>
|
||||||
|
public sealed class DesignLateralBarViewModel : LateralBarViewModel
|
||||||
|
{
|
||||||
|
/// <summary>
|
||||||
|
/// Creates a mock <see cref="LateralBarViewModel"/> to be used at design-time.
|
||||||
|
/// </summary>
|
||||||
|
public DesignLateralBarViewModel() : base(DesignStatics.Services.GetRequiredService<MockAppConfigManager>())
|
||||||
|
{
|
||||||
|
}
|
||||||
|
}
|
19
EllieHub/DesignData/Controls/DesignUriInputBarViewModel.cs
Normal file
|
@ -0,0 +1,19 @@
|
||||||
|
using Avalonia.Platform.Storage;
|
||||||
|
using Microsoft.Extensions.DependencyInjection;
|
||||||
|
using EllieHub.DesignData.Common;
|
||||||
|
using EllieHub.ViewModels.Controls;
|
||||||
|
|
||||||
|
namespace EllieHub.DesignData.Controls;
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Mock view-model for <see cref="UriInputBarViewModel"/>.
|
||||||
|
/// </summary>
|
||||||
|
public sealed class DesignUriInputBarViewModel : UriInputBarViewModel
|
||||||
|
{
|
||||||
|
/// <summary>
|
||||||
|
/// Creates a mock <see cref="UriInputBarViewModel"/> to be used at design-time.
|
||||||
|
/// </summary>
|
||||||
|
public DesignUriInputBarViewModel() : base(DesignStatics.Services.GetRequiredService<IStorageProvider>())
|
||||||
|
{
|
||||||
|
}
|
||||||
|
}
|
10
EllieHub/DesignData/Windows/DesignAboutMeViewModel.cs
Normal file
|
@ -0,0 +1,10 @@
|
||||||
|
using EllieHub.ViewModels.Windows;
|
||||||
|
|
||||||
|
namespace EllieHub.DesignData.Windows;
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Mock view-model for <see cref="AboutMeViewModel"/>.
|
||||||
|
/// </summary>
|
||||||
|
public sealed class DesignAboutMeViewModel : AboutMeViewModel
|
||||||
|
{
|
||||||
|
}
|
22
EllieHub/DesignData/Windows/DesignAppViewModel.cs
Normal file
|
@ -0,0 +1,22 @@
|
||||||
|
using Microsoft.Extensions.DependencyInjection;
|
||||||
|
using EllieHub.DesignData.Common;
|
||||||
|
using EllieHub.ViewModels.Controls;
|
||||||
|
using EllieHub.ViewModels.Windows;
|
||||||
|
|
||||||
|
namespace EllieHub.DesignData.Windows;
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Mock view-model for <see cref="AppViewModel"/>.
|
||||||
|
/// </summary>
|
||||||
|
public sealed class DesignAppViewModel : AppViewModel
|
||||||
|
{
|
||||||
|
/// <summary>
|
||||||
|
/// Creates a mock <see cref="AppViewModel"/> to be used at design-time.
|
||||||
|
/// </summary>
|
||||||
|
public DesignAppViewModel() : base(
|
||||||
|
DesignStatics.Services.GetRequiredService<LateralBarViewModel>(),
|
||||||
|
DesignStatics.Services.GetRequiredService<HomeViewModel>()
|
||||||
|
)
|
||||||
|
{
|
||||||
|
}
|
||||||
|
}
|
10
EllieHub/DesignData/Windows/DesignUpdateViewModel.cs
Normal file
|
@ -0,0 +1,10 @@
|
||||||
|
using EllieHub.ViewModels.Windows;
|
||||||
|
|
||||||
|
namespace EllieHub.DesignData.Windows;
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Mock view-model for <see cref="UpdateViewModel"/>.
|
||||||
|
/// </summary>
|
||||||
|
public sealed class DesignUpdateViewModel : UpdateViewModel
|
||||||
|
{
|
||||||
|
}
|
62
EllieHub/EllieHub.csproj
Normal file
|
@ -0,0 +1,62 @@
|
||||||
|
<Project Sdk="Microsoft.NET.Sdk">
|
||||||
|
<PropertyGroup>
|
||||||
|
<!--Project Settings-->
|
||||||
|
<OutputType>WinExe</OutputType>
|
||||||
|
<TargetFramework>net7.0</TargetFramework>
|
||||||
|
<AnalysisLevel>latest</AnalysisLevel>
|
||||||
|
<LangVersion>latest</LangVersion>
|
||||||
|
<ImplicitUsings>enable</ImplicitUsings>
|
||||||
|
<Nullable>enable</Nullable>
|
||||||
|
<WarningsAsErrors>Nullable</WarningsAsErrors>
|
||||||
|
<TreatWarningsAsErrors>True</TreatWarningsAsErrors>
|
||||||
|
<EnforceCodeStyleInBuild>True</EnforceCodeStyleInBuild>
|
||||||
|
<GenerateDocumentationFile>True</GenerateDocumentationFile>
|
||||||
|
<BuiltInComInteropSupport>True</BuiltInComInteropSupport>
|
||||||
|
<ApplicationIcon>Assets/Light/ellieupdatericon.ico</ApplicationIcon>
|
||||||
|
|
||||||
|
<!--Publishing-->
|
||||||
|
<PublishSingleFile>true</PublishSingleFile>
|
||||||
|
<DebugType>embedded</DebugType>
|
||||||
|
|
||||||
|
<!--Version-->
|
||||||
|
<VersionPrefix>1.0.0.0</VersionPrefix>
|
||||||
|
|
||||||
|
<!--Avalonia Settings-->
|
||||||
|
<ApplicationManifest>app.manifest</ApplicationManifest>
|
||||||
|
<AvaloniaUseCompiledBindingsByDefault>true</AvaloniaUseCompiledBindingsByDefault>
|
||||||
|
|
||||||
|
<!--Generate app host, so the final MacOS .app binary works-->
|
||||||
|
<UseAppHost>true</UseAppHost>
|
||||||
|
</PropertyGroup>
|
||||||
|
|
||||||
|
<ItemGroup>
|
||||||
|
<AvaloniaResource Include="Assets\**" />
|
||||||
|
|
||||||
|
<!--Watch XAML files for hot reload-->
|
||||||
|
<Watch Include="**\*.xaml" />
|
||||||
|
<Watch Include="**\*.axaml" />
|
||||||
|
</ItemGroup>
|
||||||
|
|
||||||
|
<ItemGroup>
|
||||||
|
<PackageReference Include="Avalonia" Version="11.0.4" />
|
||||||
|
<PackageReference Include="Avalonia.Desktop" Version="11.0.4" />
|
||||||
|
<PackageReference Include="Avalonia.ReactiveUI" Version="11.0.4" />
|
||||||
|
<PackageReference Include="Avalonia.Controls.ItemsRepeater" Version="11.0.4" />
|
||||||
|
<PackageReference Include="Avalonia.Fonts.Inter" Version="11.0.4" />
|
||||||
|
<PackageReference Include="Avalonia.Themes.Fluent" Version="11.0.4" />
|
||||||
|
<PackageReference Include="Toastie.Events" Version="2.1.1" />
|
||||||
|
<PackageReference Include="MessageBox.Avalonia" Version="3.1.4" />
|
||||||
|
<PackageReference Include="Microsoft.Extensions.Caching.Memory" Version="7.0.0" />
|
||||||
|
<PackageReference Include="Microsoft.Extensions.DependencyInjection" Version="7.0.0" />
|
||||||
|
<!--Condition below is needed to remove Avalonia.Diagnostics package from build output in Release configuration.-->
|
||||||
|
<PackageReference Condition="'$(Configuration)' == 'Debug'" Include="Avalonia.Diagnostics" Version="11.0.4" />
|
||||||
|
<PackageReference Include="Microsoft.Extensions.Http" Version="7.0.0" />
|
||||||
|
<PackageReference Include="SkiaImageView.Avalonia11" Version="1.5.0" />
|
||||||
|
</ItemGroup>
|
||||||
|
|
||||||
|
<ItemGroup>
|
||||||
|
<Compile Update="Views\Windows\AppView.axaml.cs">
|
||||||
|
<DependentUpon>AppView.axaml</DependentUpon>
|
||||||
|
</Compile>
|
||||||
|
</ItemGroup>
|
||||||
|
</Project>
|
32
EllieHub/Enums/DependencyStatus.cs
Normal file
|
@ -0,0 +1,32 @@
|
||||||
|
namespace EllieHub.Enums;
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Defines the possible status for a dependency.
|
||||||
|
/// </summary>
|
||||||
|
public enum DependencyStatus
|
||||||
|
{
|
||||||
|
/// <summary>
|
||||||
|
/// The dependency is available for installation.
|
||||||
|
/// </summary>
|
||||||
|
Install,
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// The dependency is installed and up-to-date.
|
||||||
|
/// </summary>
|
||||||
|
Installed,
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// The dependency is in the process of being updated.
|
||||||
|
/// </summary>
|
||||||
|
Updating,
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// The dependency has an update available.
|
||||||
|
/// </summary>
|
||||||
|
Update,
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// The dependency is currently being checked for updates.
|
||||||
|
/// </summary>
|
||||||
|
Checking
|
||||||
|
}
|
17
EllieHub/Enums/DependencyType.cs
Normal file
|
@ -0,0 +1,17 @@
|
||||||
|
namespace EllieHub.Enums;
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Defines the types of dependencies available to install.
|
||||||
|
/// </summary>
|
||||||
|
public enum DependencyType
|
||||||
|
{
|
||||||
|
/// <summary>
|
||||||
|
/// Ffmpeg.
|
||||||
|
/// </summary>
|
||||||
|
Ffmpeg,
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Yt-dlp.
|
||||||
|
/// </summary>
|
||||||
|
Ytdlp
|
||||||
|
}
|
22
EllieHub/Enums/ThemeType.cs
Normal file
|
@ -0,0 +1,22 @@
|
||||||
|
namespace EllieHub.Enums;
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// The types of themes available.
|
||||||
|
/// </summary>
|
||||||
|
public enum ThemeType
|
||||||
|
{
|
||||||
|
/// <summary>
|
||||||
|
/// Either Light or Dark, according to the OS preferences.
|
||||||
|
/// </summary>
|
||||||
|
Auto,
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Light theme.
|
||||||
|
/// </summary>
|
||||||
|
Light,
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Dark theme.
|
||||||
|
/// </summary>
|
||||||
|
Dark
|
||||||
|
}
|
41
EllieHub/Extensions/HttpClientExt.cs
Normal file
|
@ -0,0 +1,41 @@
|
||||||
|
using System.Text.Json;
|
||||||
|
|
||||||
|
namespace EllieHub.Extensions;
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Defines extension methods for <see cref="HttpClient"/>.
|
||||||
|
/// </summary>
|
||||||
|
public static class HttpClientExt
|
||||||
|
{
|
||||||
|
/// <summary>
|
||||||
|
/// Sends a GET request to an API at the specified <paramref name="endpoint"/> and returns a Json deserialized response.
|
||||||
|
/// </summary>
|
||||||
|
/// <typeparam name="T">The type to be returned.</typeparam>
|
||||||
|
/// <param name="http">This http client.</param>
|
||||||
|
/// <param name="endpoint">The API endpoint to be called.</param>
|
||||||
|
/// <param name="cToken">The cancellation token.</param>
|
||||||
|
/// <returns>A <typeparamref name="T"/> response.</returns>
|
||||||
|
/// <exception cref="InvalidOperationException">Occurs when the deserialization fails.</exception>
|
||||||
|
public static async Task<T> CallApiAsync<T>(this HttpClient http, string endpoint, CancellationToken cToken = default)
|
||||||
|
{
|
||||||
|
var responseString = await http.GetStringAsync(endpoint, cToken);
|
||||||
|
|
||||||
|
return JsonSerializer.Deserialize<T>(responseString)
|
||||||
|
?? throw new InvalidOperationException($"Could not deserialize response to {nameof(T)}.");
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Checks if a request to the specified <paramref name="url"/> returns a successful HTTP response.
|
||||||
|
/// </summary>
|
||||||
|
/// <param name="http">This http client.</param>
|
||||||
|
/// <param name="url">The url to check.</param>
|
||||||
|
/// <param name="cToken">The cancellation token.</param>
|
||||||
|
/// <returns><see langword="true"/> if the <paramref name="url"/> is valid, <see langword="false"/> otherwise.</returns>
|
||||||
|
public static async Task<bool> IsUrlValidAsync(this HttpClient http, string url, CancellationToken cToken = default)
|
||||||
|
{
|
||||||
|
var request = new HttpRequestMessage(HttpMethod.Head, url);
|
||||||
|
var response = await http.SendAsync(request, cToken);
|
||||||
|
|
||||||
|
return response.IsSuccessStatusCode;
|
||||||
|
}
|
||||||
|
}
|
91
EllieHub/Extensions/IServiceCollectionExt.cs
Normal file
|
@ -0,0 +1,91 @@
|
||||||
|
using Avalonia.Controls;
|
||||||
|
using Microsoft.Extensions.DependencyInjection;
|
||||||
|
using EllieHub.Models.Config;
|
||||||
|
using EllieHub.Services;
|
||||||
|
using EllieHub.Services.Abstractions;
|
||||||
|
using EllieHub.Services.Mocks;
|
||||||
|
using EllieHub.Views.Windows;
|
||||||
|
using ReactiveUI;
|
||||||
|
using System.Reflection;
|
||||||
|
using System.Text.Json;
|
||||||
|
|
||||||
|
namespace EllieHub.Extensions;
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Defines extension methods for <see cref="IServiceCollection"/>.
|
||||||
|
/// </summary>
|
||||||
|
public static class IServiceCollectionExt
|
||||||
|
{
|
||||||
|
/// <summary>
|
||||||
|
/// Registers all views and view-models in the provided <paramref name="assembly"/> to this service collection.
|
||||||
|
/// </summary>
|
||||||
|
/// <param name="serviceCollection">This service collection.</param>
|
||||||
|
/// <param name="assembly">The assembly to get the views and view-models from.</param>
|
||||||
|
/// <returns>This service collection with the views and view-models added.</returns>
|
||||||
|
public static IServiceCollection RegisterViewsAndViewModels(this IServiceCollection serviceCollection, Assembly assembly)
|
||||||
|
{
|
||||||
|
var viewModelPairs = assembly.GetTypes()
|
||||||
|
.Where(x => !x.IsAbstract && x.IsAssignableTo(typeof(IViewFor)))
|
||||||
|
.Select(x => (ViewType: x, ViewModelType: x.GetInterface(typeof(IViewFor<>).Name)!.GenericTypeArguments[0]));
|
||||||
|
|
||||||
|
foreach (var (viewType, viewModelType) in viewModelPairs)
|
||||||
|
{
|
||||||
|
serviceCollection.AddSingleton(viewType);
|
||||||
|
serviceCollection.AddTransient(viewModelType);
|
||||||
|
}
|
||||||
|
|
||||||
|
return serviceCollection;
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Registers the application's services.
|
||||||
|
/// </summary>
|
||||||
|
/// <param name="serviceCollection">This service collection.</param>
|
||||||
|
/// <returns>This service collection with the services added.</returns>
|
||||||
|
public static IServiceCollection RegisterServices(this IServiceCollection serviceCollection)
|
||||||
|
{
|
||||||
|
// Design-time
|
||||||
|
if (Design.IsDesignMode)
|
||||||
|
{
|
||||||
|
serviceCollection.AddTransient<MockEllieResolver>();
|
||||||
|
serviceCollection.AddTransient<MockAppConfigManager>();
|
||||||
|
}
|
||||||
|
|
||||||
|
// Internal
|
||||||
|
serviceCollection.AddMemoryCache();
|
||||||
|
serviceCollection.AddSingleton<ILogWriter, LogWriter>();
|
||||||
|
serviceCollection.AddSingleton<IAppResolver, AppResolver>();
|
||||||
|
serviceCollection.AddSingleton<IBotOrchestrator, EllieOrchestrator>();
|
||||||
|
serviceCollection.AddSingleton(x => x.GetRequiredService<AppView>().StorageProvider);
|
||||||
|
|
||||||
|
// Web requests
|
||||||
|
serviceCollection.AddHttpClient();
|
||||||
|
serviceCollection.AddHttpClient(AppConstants.NoRedirectClient) // Client that doesn't allow automatic reditections
|
||||||
|
.ConfigureHttpMessageHandlerBuilder(builder => builder.PrimaryHandler = new HttpClientHandler() { AllowAutoRedirect = false });
|
||||||
|
|
||||||
|
// App settings
|
||||||
|
serviceCollection.AddSingleton<IAppConfigManager, AppConfigManager>();
|
||||||
|
serviceCollection.AddSingleton<ReadOnlyAppConfig>();
|
||||||
|
serviceCollection.AddSingleton(_ =>
|
||||||
|
(File.Exists(AppStatics.AppConfigUri))
|
||||||
|
? JsonSerializer.Deserialize<AppConfig>(File.ReadAllText(AppStatics.AppConfigUri)) ?? new()
|
||||||
|
: new()
|
||||||
|
);
|
||||||
|
|
||||||
|
// Dependency resolvers
|
||||||
|
serviceCollection.AddSingleton<IYtdlpResolver, YtdlpResolver>();
|
||||||
|
serviceCollection.AddTransient<IBotResolver, EllieResolver>();
|
||||||
|
|
||||||
|
// Platform-dependent services
|
||||||
|
if (OperatingSystem.IsWindows())
|
||||||
|
serviceCollection.AddSingleton<IFfmpegResolver, FfmpegWindowsResolver>();
|
||||||
|
else if (OperatingSystem.IsLinux())
|
||||||
|
serviceCollection.AddSingleton<IFfmpegResolver, FfmpegLinuxResolver>();
|
||||||
|
else if (OperatingSystem.IsMacOS())
|
||||||
|
serviceCollection.AddSingleton<IFfmpegResolver, FfmpegMacResolver>();
|
||||||
|
else
|
||||||
|
serviceCollection.AddSingleton<IFfmpegResolver, FfmpegMockResolver>();
|
||||||
|
|
||||||
|
return serviceCollection;
|
||||||
|
}
|
||||||
|
}
|
31
EllieHub/Extensions/IServiceProviderExt.cs
Normal file
|
@ -0,0 +1,31 @@
|
||||||
|
using Microsoft.Extensions.DependencyInjection;
|
||||||
|
|
||||||
|
namespace EllieHub.Extensions;
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Defines extension methods for <see cref="IServiceProvider"/>.
|
||||||
|
/// </summary>
|
||||||
|
public static class IServiceProviderExt
|
||||||
|
{
|
||||||
|
/// <summary>
|
||||||
|
/// Gets service of type <typeparamref name="T"/> from the <see cref="IServiceProvider"/>.
|
||||||
|
/// </summary>
|
||||||
|
/// <typeparam name="T">The type of service object to get.</typeparam>
|
||||||
|
/// <param name="serviceProvider">The <see cref="IServiceProvider"/> to retrieve the service object from.</param>
|
||||||
|
/// <param name="arguments"></param>
|
||||||
|
/// <remarks>Do not use abstract types in the type argument!</remarks>
|
||||||
|
/// <returns>A service object of type <typeparamref name="T"/>.</returns>
|
||||||
|
/// <exception cref="ArgumentNullException">Occurs when <paramref name="serviceProvider"/> or <paramref name="arguments"/> are <see langword="null"/>.</exception>
|
||||||
|
/// <exception cref="InvalidOperationException">Occurs when there is no concrete service of type <typeparamref name="T"/> or when the arguments are wrong.</exception>
|
||||||
|
public static T GetParameterizedService<T>(this IServiceProvider serviceProvider, params object[] arguments)
|
||||||
|
{
|
||||||
|
ArgumentNullException.ThrowIfNull(serviceProvider, nameof(serviceProvider));
|
||||||
|
ArgumentNullException.ThrowIfNull(arguments, nameof(arguments));
|
||||||
|
|
||||||
|
var result = ActivatorUtilities.CreateInstance<T>(serviceProvider, arguments);
|
||||||
|
|
||||||
|
return (result is null)
|
||||||
|
? throw new InvalidOperationException($"There is no service of type {nameof(T)} or the arguments were incorrect.")
|
||||||
|
: result;
|
||||||
|
}
|
||||||
|
}
|
90
EllieHub/Extensions/WindowExt.cs
Normal file
|
@ -0,0 +1,90 @@
|
||||||
|
using Avalonia.Controls;
|
||||||
|
using Avalonia.Styling;
|
||||||
|
using MsBox.Avalonia;
|
||||||
|
using MsBox.Avalonia.Dto;
|
||||||
|
using MsBox.Avalonia.Enums;
|
||||||
|
|
||||||
|
namespace EllieHub.Extensions;
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Provides extension methods for <see cref="Window"/>.
|
||||||
|
/// </summary>
|
||||||
|
public static class WindowExt
|
||||||
|
{
|
||||||
|
/// <summary>
|
||||||
|
/// Shows a dialog window that blocks the main window.
|
||||||
|
/// </summary>
|
||||||
|
/// <param name="activeView">The active window.</param>
|
||||||
|
/// <param name="message">The message to be displayed.</param>
|
||||||
|
/// <param name="dialogType">The type of dialog window to display.</param>
|
||||||
|
/// <param name="iconType">The icon to be displayed.</param>
|
||||||
|
/// <returns>The button type that was pressed.</returns>
|
||||||
|
public static Task<ButtonResult> ShowDialogWindowAsync(this Window activeView, string message, DialogType dialogType = DialogType.Notification, Icon iconType = Icon.None)
|
||||||
|
=> ShowDialogWindowAsync(activeView, message, dialogType.ToString(), iconType);
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Shows a dialog window that blocks the main window.
|
||||||
|
/// </summary>
|
||||||
|
/// <param name="activeView">The active window.</param>
|
||||||
|
/// <param name="message">The message to be displayed.</param>
|
||||||
|
/// <param name="title">The title of the dialog box.</param>
|
||||||
|
/// <param name="iconType">The icon to be displayed.</param>
|
||||||
|
/// <returns>The button type that was pressed.</returns>
|
||||||
|
public static Task<ButtonResult> ShowDialogWindowAsync(this Window activeView, string message, string title, Icon iconType = Icon.None)
|
||||||
|
{
|
||||||
|
var messageparameters = new MessageBoxStandardParams()
|
||||||
|
{
|
||||||
|
ButtonDefinitions = ButtonEnum.Ok,
|
||||||
|
ContentMessage = message,
|
||||||
|
ContentTitle = title,
|
||||||
|
Icon = iconType,
|
||||||
|
WindowIcon = activeView.GetResource<WindowIcon>(AppResources.EllieHubIcon),
|
||||||
|
MaxWidth = int.Parse(WindowConstants.DefaultWindowWidth) / 1.7,
|
||||||
|
SizeToContent = SizeToContent.WidthAndHeight,
|
||||||
|
ShowInCenter = true,
|
||||||
|
WindowStartupLocation = WindowStartupLocation.CenterOwner
|
||||||
|
};
|
||||||
|
|
||||||
|
return ShowDialogWindowAsync(activeView, messageparameters);
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Shows a dialog window that blocks the main window.
|
||||||
|
/// </summary>
|
||||||
|
/// <param name="activeView">The active window.</param>
|
||||||
|
/// <param name="messageParameters">The parameters of the message dialog box.</param>
|
||||||
|
/// <returns>The button type that was pressed.</returns>
|
||||||
|
public static Task<ButtonResult> ShowDialogWindowAsync(this Window activeView, MessageBoxStandardParams messageParameters)
|
||||||
|
=> MessageBoxManager.GetMessageBoxStandard(messageParameters).ShowWindowDialogAsync(activeView);
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Finds the specified resource by searching up the logical tree and then global styles.
|
||||||
|
/// </summary>
|
||||||
|
/// <typeparam name="T">The type of the resource.</typeparam>
|
||||||
|
/// <param name="activeView">The active window.</param>
|
||||||
|
/// <param name="resourceName">The name of the resource.</param>
|
||||||
|
/// <returns>The requested <typeparamref name="T"/> resource.</returns>
|
||||||
|
/// <exception cref="InvalidOperationException">Occurs when the resource is not found.</exception>
|
||||||
|
/// <exception cref="InvalidCastException">Occurs when the resource is not of type <typeparamref name="T"/>.</exception>
|
||||||
|
public static T GetResource<T>(this Window activeView, string resourceName)
|
||||||
|
=> GetResource<T>(activeView, resourceName, activeView.ActualThemeVariant);
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Finds the specified resource by searching up the logical tree and then global styles.
|
||||||
|
/// </summary>
|
||||||
|
/// <typeparam name="T">The type of the resource.</typeparam>
|
||||||
|
/// <param name="activeView">The active window.</param>
|
||||||
|
/// <param name="resourceName">The name of the resource.</param>
|
||||||
|
/// <param name="theme">The UI theme variant the resource belongs to.</param>
|
||||||
|
/// <returns>The requested <typeparamref name="T"/> resource.</returns>
|
||||||
|
/// <exception cref="InvalidOperationException">Occurs when the resource is not found.</exception>
|
||||||
|
/// <exception cref="InvalidCastException">Occurs when the resource is not of type <typeparamref name="T"/>.</exception>
|
||||||
|
public static T GetResource<T>(this Window activeView, string resourceName, ThemeVariant theme)
|
||||||
|
{
|
||||||
|
return (!activeView.TryFindResource(resourceName, theme, out var resource))
|
||||||
|
? throw new InvalidOperationException($"Resource '{resourceName}' was not found.")
|
||||||
|
: (!Utilities.TryCastTo<T>(resource, out var result))
|
||||||
|
? throw new InvalidCastException($"Could not convert resource of type '{resource?.GetType()?.FullName}' to '{nameof(T)}'.")
|
||||||
|
: result;
|
||||||
|
}
|
||||||
|
}
|
15
EllieHub/Models/Api/EvermeetDownloadInfo.cs
Normal file
|
@ -0,0 +1,15 @@
|
||||||
|
using System.Text.Json.Serialization;
|
||||||
|
|
||||||
|
namespace EllieHub.Models.Api;
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Represents download information for a <see cref="EvermeetInfo"/> component.
|
||||||
|
/// </summary>
|
||||||
|
/// <param name="Url">The url to download the component.</param>
|
||||||
|
/// <param name="Size">The size of the package to download, in bytes.</param>
|
||||||
|
/// <param name="SignatureUrl">The url to download the cryptographic signature of the component.</param>
|
||||||
|
public sealed record EvermeetDownloadInfo(
|
||||||
|
[property: JsonPropertyName("url")] string Url,
|
||||||
|
[property: JsonPropertyName("size")] uint Size,
|
||||||
|
[property: JsonPropertyName("sig")] string SignatureUrl
|
||||||
|
);
|
19
EllieHub/Models/Api/EvermeetInfo.cs
Normal file
|
@ -0,0 +1,19 @@
|
||||||
|
using System.Text.Json.Serialization;
|
||||||
|
|
||||||
|
namespace EllieHub.Models.Api;
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Represents a response from the "https://evermeet.cx/ffmpeg/info" endpoint.
|
||||||
|
/// </summary>
|
||||||
|
/// <param name="Name">The name of the component.</param>
|
||||||
|
/// <param name="Type">The type of the component (snapshot or release).</param>
|
||||||
|
/// <param name="Version">The version of the component.</param>
|
||||||
|
/// <param name="Size">The size of the component, in bytes.</param>
|
||||||
|
/// <param name="Download">The download links to the component, where the key is the desired file format.</param>
|
||||||
|
public sealed record EvermeetInfo(
|
||||||
|
[property: JsonPropertyName("name")] string Name,
|
||||||
|
[property: JsonPropertyName("type")] string Type,
|
||||||
|
[property: JsonPropertyName("version")] string Version,
|
||||||
|
[property: JsonPropertyName("size")] uint Size,
|
||||||
|
[property: JsonPropertyName("download")] IReadOnlyDictionary<string, EvermeetDownloadInfo> Download
|
||||||
|
);
|
10
EllieHub/Models/BotEntry.cs
Normal file
|
@ -0,0 +1,10 @@
|
||||||
|
using EllieHub.ViewModels.Controls;
|
||||||
|
|
||||||
|
namespace EllieHub.Models;
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Represents a bot entry in the <see cref="LateralBarViewModel"/>.
|
||||||
|
/// </summary>
|
||||||
|
/// <param name="Id">The Id of the bot.</param>
|
||||||
|
/// <param name="BotInfo">The information about the bot instance.</param>
|
||||||
|
public sealed record BotEntry(Guid Id, BotInstanceInfo BotInfo);
|
11
EllieHub/Models/BotInstanceInfo.cs
Normal file
|
@ -0,0 +1,11 @@
|
||||||
|
namespace EllieHub.Models;
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Represents the information of a bot instance.
|
||||||
|
/// </summary>
|
||||||
|
/// <param name="Name">The name of the bot.</param>
|
||||||
|
/// <param name="InstanceDirectoryUri">The path to the directory where the bot instance is located at.</param>
|
||||||
|
/// <param name="Position">The position of the bot in the lateral bar.</param>
|
||||||
|
/// <param name="Version">The version of the bot or <see langword="null"/> if the bot hasn't been downloaded yet.</param>
|
||||||
|
/// <param name="AvatarUri">The path to the bot's avatar image file or <see langword="null"/> is there is none.</param>
|
||||||
|
public sealed record BotInstanceInfo(string Name, string InstanceDirectoryUri, uint Position, string? Version = default, string? AvatarUri = default);
|
56
EllieHub/Models/Config/AppConfig.cs
Normal file
|
@ -0,0 +1,56 @@
|
||||||
|
using EllieHub.Enums;
|
||||||
|
using System.Collections.Concurrent;
|
||||||
|
|
||||||
|
namespace EllieHub.Models.Config;
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Represents the settings of the application.
|
||||||
|
/// </summary>
|
||||||
|
/// <remarks>Prefer using <see cref="ReadOnlyAppConfig"/> in dependency injection, if possible.</remarks>
|
||||||
|
public sealed class AppConfig
|
||||||
|
{
|
||||||
|
/// <summary>
|
||||||
|
/// The absolute path to the directory where the bot instances are stored.
|
||||||
|
/// </summary>
|
||||||
|
public string BotsDirectoryUri { get; set; } = AppStatics.AppDefaultBotDirectoryUri;
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// The absolute path to the directory where the bot instances are backed up.
|
||||||
|
/// </summary>
|
||||||
|
public string BotsBackupDirectoryUri { get; set; } = AppStatics.AppDefaultBotBackupDirectoryUri;
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// The absolute path to the directory where the bot logs are stored.
|
||||||
|
/// </summary>
|
||||||
|
public string LogsDirectoryUri { get; set; } = AppStatics.AppDefaultLogDirectoryUri;
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// The theme to be used.
|
||||||
|
/// </summary>
|
||||||
|
public ThemeType Theme { get; set; } = ThemeType.Auto;
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Determines whether the application should update itself.
|
||||||
|
/// </summary>
|
||||||
|
public bool AutomaticUpdates { get; set; } = true;
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Determines whether the application should be minimized to the system tray when closed.
|
||||||
|
/// </summary>
|
||||||
|
public bool MinimizeToTray { get; set; } = true;
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Determines the maximum size a log file can have, in Mb.
|
||||||
|
/// </summary>
|
||||||
|
public double LogMaxSizeMb { get; set; } = 0.5;
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Determines the size the application window should be set on startup.
|
||||||
|
/// </summary>
|
||||||
|
public WindowSize WindowSize { get; set; } = new(double.Parse(WindowConstants.DefaultWindowWidth), double.Parse(WindowConstants.DefaultWindowHeight));
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// A collection of metadata about the bot instances.
|
||||||
|
/// </summary>
|
||||||
|
public ConcurrentDictionary<Guid, BotInstanceInfo> BotEntries { get; init; } = new();
|
||||||
|
}
|
72
EllieHub/Models/Config/ReadOnlyAppConfig.cs
Normal file
|
@ -0,0 +1,72 @@
|
||||||
|
using EllieHub.Enums;
|
||||||
|
|
||||||
|
namespace EllieHub.Models.Config;
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Represents a read-only version of <see cref="AppConfig"/>.
|
||||||
|
/// </summary>
|
||||||
|
public sealed class ReadOnlyAppConfig
|
||||||
|
{
|
||||||
|
private readonly AppConfig _appConfig;
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// The absolute path to the directory where the bot instances are stored.
|
||||||
|
/// </summary>
|
||||||
|
public string BotsDirectoryUri
|
||||||
|
=> _appConfig.BotsDirectoryUri;
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// The absolute path to the directory where the bot instances are backed up.
|
||||||
|
/// </summary>
|
||||||
|
public string BotsBackupDirectoryUri
|
||||||
|
=> _appConfig.BotsBackupDirectoryUri;
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// The absolute path to the directory where the bot logs are stored.
|
||||||
|
/// </summary>
|
||||||
|
public string LogsDirectoryUri
|
||||||
|
=> _appConfig.LogsDirectoryUri;
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// The theme to be used.
|
||||||
|
/// </summary>
|
||||||
|
public ThemeType Theme
|
||||||
|
=> _appConfig.Theme;
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Determines whether the application should update itself.
|
||||||
|
/// </summary>
|
||||||
|
public bool AutomaticUpdates
|
||||||
|
=> _appConfig.AutomaticUpdates;
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Determines whether the application should be minimized to the system tray when closed.
|
||||||
|
/// </summary>
|
||||||
|
public bool MinimizeToTray
|
||||||
|
=> _appConfig.MinimizeToTray;
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Determines the maximum size a log file can have, in Mb.
|
||||||
|
/// </summary>
|
||||||
|
public double LogMaxSizeMb
|
||||||
|
=> _appConfig.LogMaxSizeMb;
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Determines the size the application window should be set on startup.
|
||||||
|
/// </summary>
|
||||||
|
public WindowSize WindowSize
|
||||||
|
=> _appConfig.WindowSize;
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// A collection of metadata about the bot instances.
|
||||||
|
/// </summary>
|
||||||
|
public IReadOnlyDictionary<Guid, BotInstanceInfo> BotEntries
|
||||||
|
=> _appConfig.BotEntries;
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Initializes a read-only version of <see cref="AppConfig"/>.
|
||||||
|
/// </summary>
|
||||||
|
/// <param name="appConfig">The application settings to read from.</param>
|
||||||
|
public ReadOnlyAppConfig(AppConfig appConfig)
|
||||||
|
=> _appConfig = appConfig;
|
||||||
|
}
|
37
EllieHub/Models/EventArguments/AvatarChangedEventArgs.cs
Normal file
|
@ -0,0 +1,37 @@
|
||||||
|
using SkiaSharp;
|
||||||
|
|
||||||
|
namespace EllieHub.Models.EventArguments;
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Defines the event arguments for when the user sets a new avatar for a bot instance.
|
||||||
|
/// </summary>
|
||||||
|
public sealed class AvatarChangedEventArgs : EventArgs
|
||||||
|
{
|
||||||
|
/// <summary>
|
||||||
|
/// The Id of the bot.
|
||||||
|
/// </summary>
|
||||||
|
public Guid BotId { get; }
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// The new avatar.
|
||||||
|
/// </summary>
|
||||||
|
public SKBitmap Avatar { get; }
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// The absolute path to the avatar's file.
|
||||||
|
/// </summary>
|
||||||
|
public string AvatarUri { get; }
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Creates the event arguments for when the user sets a new avatar for a bot instance.
|
||||||
|
/// </summary>
|
||||||
|
/// <param name="botId">The Id of the bot.</param>
|
||||||
|
/// <param name="avatar">The new avatar.</param>
|
||||||
|
/// <param name="avatarUri">The absolute path to the avatar's file.</param>
|
||||||
|
public AvatarChangedEventArgs(Guid botId, SKBitmap avatar, string avatarUri)
|
||||||
|
{
|
||||||
|
BotId = botId;
|
||||||
|
Avatar = avatar;
|
||||||
|
AvatarUri = avatarUri;
|
||||||
|
}
|
||||||
|
}
|
28
EllieHub/Models/EventArguments/BotExitEventArgs.cs
Normal file
|
@ -0,0 +1,28 @@
|
||||||
|
namespace EllieHub.Models.EventArguments;
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Defines the event arguments when a bot process exits.
|
||||||
|
/// </summary>
|
||||||
|
public sealed class BotExitEventArgs : EventArgs
|
||||||
|
{
|
||||||
|
/// <summary>
|
||||||
|
/// The bot's Id.
|
||||||
|
/// </summary>
|
||||||
|
public Guid Id { get; }
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// The exit code.
|
||||||
|
/// </summary>
|
||||||
|
public int ExitCode { get; }
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Creates the event arguments when a bot process exits.
|
||||||
|
/// </summary>
|
||||||
|
/// <param name="botId">The bot's Id.</param>
|
||||||
|
/// <param name="exitCode">The exit code.</param>
|
||||||
|
public BotExitEventArgs(Guid botId, int exitCode)
|
||||||
|
{
|
||||||
|
Id = botId;
|
||||||
|
ExitCode = exitCode;
|
||||||
|
}
|
||||||
|
}
|
35
EllieHub/Models/EventArguments/LogFlushEventArgs.cs
Normal file
|
@ -0,0 +1,35 @@
|
||||||
|
namespace EllieHub.Models.EventArguments;
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Defines the event arguments when a log is written to disk.
|
||||||
|
/// </summary>
|
||||||
|
public sealed class LogFlushEventArgs : EventArgs
|
||||||
|
{
|
||||||
|
/// <summary>
|
||||||
|
/// The absolute path to the recently created log file.
|
||||||
|
/// </summary>
|
||||||
|
public string FileUri { get; }
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// The size of the log file, in bytes.
|
||||||
|
/// </summary>
|
||||||
|
public int Size { get; }
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// The date the log file was created.
|
||||||
|
/// </summary>
|
||||||
|
public DateTimeOffset CreatedAt { get; }
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Creates the event arguments when a log is written to disk.
|
||||||
|
/// </summary>
|
||||||
|
/// <param name="fileUri">The absolute path to the recently created log file.</param>
|
||||||
|
/// <param name="size">The size of the log file, in bytes.</param>
|
||||||
|
/// <param name="createdAt">The date the log file was created.</param>
|
||||||
|
public LogFlushEventArgs(string fileUri, int size, DateTimeOffset createdAt)
|
||||||
|
{
|
||||||
|
FileUri = fileUri;
|
||||||
|
Size = size;
|
||||||
|
CreatedAt = createdAt;
|
||||||
|
}
|
||||||
|
}
|
28
EllieHub/Models/EventArguments/ProcessStdWriteEventArgs.cs
Normal file
|
@ -0,0 +1,28 @@
|
||||||
|
namespace EllieHub.Models.EventArguments;
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Defines the event arguments when a bot process writes to stdout or stderr.
|
||||||
|
/// </summary>
|
||||||
|
public sealed class ProcessStdWriteEventArgs : EventArgs
|
||||||
|
{
|
||||||
|
/// <summary>
|
||||||
|
/// The Id of the bot.
|
||||||
|
/// </summary>
|
||||||
|
public Guid Id { get; }
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// The value that was just written to std.
|
||||||
|
/// </summary>
|
||||||
|
public string Output { get; }
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Creates the event arguments when a bot process writes to stdout or stderr.
|
||||||
|
/// </summary>
|
||||||
|
/// <param name="botId">The Id of the bot.</param>
|
||||||
|
/// <param name="output">The value that was just written to std.</param>
|
||||||
|
public ProcessStdWriteEventArgs(Guid botId, string output)
|
||||||
|
{
|
||||||
|
Id = botId;
|
||||||
|
Output = output;
|
||||||
|
}
|
||||||
|
}
|
30
EllieHub/Models/EventArguments/UriInputBarEventArgs.cs
Normal file
|
@ -0,0 +1,30 @@
|
||||||
|
using EllieHub.ViewModels.Controls;
|
||||||
|
|
||||||
|
namespace EllieHub.Models.EventArguments;
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Defines the event arguments for when a valid uri is set to a <see cref="UriInputBarViewModel"/>.
|
||||||
|
/// </summary>
|
||||||
|
public sealed class UriInputBarEventArgs : EventArgs
|
||||||
|
{
|
||||||
|
/// <summary>
|
||||||
|
/// The old valid uri.
|
||||||
|
/// </summary>
|
||||||
|
public string OldUri { get; }
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// The new valid uri.
|
||||||
|
/// </summary>
|
||||||
|
public string NewUri { get; }
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Creates the event arguments for when a valid uri is set to a <see cref="UriInputBarViewModel"/>.
|
||||||
|
/// </summary>
|
||||||
|
/// <param name="oldUri">The old valid uri.</param>
|
||||||
|
/// <param name="newUri">The new valid uri.</param>
|
||||||
|
public UriInputBarEventArgs(string oldUri, string newUri)
|
||||||
|
{
|
||||||
|
OldUri = oldUri;
|
||||||
|
NewUri = newUri;
|
||||||
|
}
|
||||||
|
}
|
8
EllieHub/Models/WindowSize.cs
Normal file
|
@ -0,0 +1,8 @@
|
||||||
|
namespace EllieHub.Models;
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Represents the dimensions of the application's window.
|
||||||
|
/// </summary>
|
||||||
|
/// <param name="Width">The width of the application window.</param>
|
||||||
|
/// <param name="Height">The height of the application window.</param>
|
||||||
|
public sealed record WindowSize(double Width, double Height);
|
23
EllieHub/Program.cs
Normal file
|
@ -0,0 +1,23 @@
|
||||||
|
using Avalonia;
|
||||||
|
using Avalonia.ReactiveUI;
|
||||||
|
|
||||||
|
namespace EllieHub;
|
||||||
|
|
||||||
|
internal sealed class Program
|
||||||
|
{
|
||||||
|
// Initialization code. Don't use any Avalonia, third-party APIs or any
|
||||||
|
// SynchronizationContext-reliant code before AppMain is called: things aren't initialized
|
||||||
|
// yet and stuff might break.
|
||||||
|
[STAThread]
|
||||||
|
public static void Main(string[] args) => BuildAvaloniaApp()
|
||||||
|
.StartWithClassicDesktopLifetime(args);
|
||||||
|
|
||||||
|
|
||||||
|
// Avalonia configuration, don't remove; also used by visual designer.
|
||||||
|
public static AppBuilder BuildAvaloniaApp()
|
||||||
|
=> AppBuilder.Configure<App>()
|
||||||
|
.UsePlatformDetect()
|
||||||
|
.WithInterFont()
|
||||||
|
.LogToTrace()
|
||||||
|
.UseReactiveUI();
|
||||||
|
}
|
35
EllieHub/Resources/Colors.axaml
Normal file
|
@ -0,0 +1,35 @@
|
||||||
|
<ResourceDictionary xmlns="https://github.com/avaloniaui"
|
||||||
|
xmlns:x="http://schemas.microsoft.com/winfx/2006/xaml">
|
||||||
|
|
||||||
|
<!--Preview-->
|
||||||
|
<Design.PreviewWith>
|
||||||
|
<Border Padding="20">
|
||||||
|
<StackPanel Spacing="5">
|
||||||
|
<TextBlock Text="LightBackground" Background="{DynamicResource LightBackground}" Padding="10" />
|
||||||
|
<TextBlock Text="MediumBackground" Background="{DynamicResource MediumBackground}" Padding="10" />
|
||||||
|
<TextBlock Text="HeavyBackground" Background="{DynamicResource HeavyBackground}" Padding="10" />
|
||||||
|
</StackPanel>
|
||||||
|
</Border>
|
||||||
|
</Design.PreviewWith>
|
||||||
|
|
||||||
|
|
||||||
|
<SolidColorBrush x:Key='DependencyInstall'>#24A148</SolidColorBrush>
|
||||||
|
<SolidColorBrush x:Key='DependencyUpdate'>#D6D600</SolidColorBrush>
|
||||||
|
|
||||||
|
<ResourceDictionary.ThemeDictionaries>
|
||||||
|
<ResourceDictionary x:Key='Light'>
|
||||||
|
<SolidColorBrush x:Key='LightBackground'>#FAF5F8</SolidColorBrush>
|
||||||
|
<SolidColorBrush x:Key='MediumBackground'>#FCFAFC</SolidColorBrush>
|
||||||
|
<SolidColorBrush x:Key='HeavyBackground'>#FFFAFD</SolidColorBrush>
|
||||||
|
<SolidColorBrush x:Key='BotSelectionColor'>#FF0067</SolidColorBrush>
|
||||||
|
<SolidColorBrush x:Key='HyperlinkColor'>Blue</SolidColorBrush>
|
||||||
|
</ResourceDictionary>
|
||||||
|
<ResourceDictionary x:Key='Dark'>
|
||||||
|
<SolidColorBrush x:Key='LightBackground'>#252525</SolidColorBrush>
|
||||||
|
<SolidColorBrush x:Key='MediumBackground'>#202020</SolidColorBrush>
|
||||||
|
<SolidColorBrush x:Key='HeavyBackground'>#181818</SolidColorBrush>
|
||||||
|
<SolidColorBrush x:Key='BotSelectionColor'>#D90058</SolidColorBrush>
|
||||||
|
<SolidColorBrush x:Key='HyperlinkColor'>#5090BB</SolidColorBrush>
|
||||||
|
</ResourceDictionary>
|
||||||
|
</ResourceDictionary.ThemeDictionaries>
|
||||||
|
</ResourceDictionary>
|
19
EllieHub/Resources/Fonts.axaml
Normal file
|
@ -0,0 +1,19 @@
|
||||||
|
<ResourceDictionary xmlns="https://github.com/avaloniaui"
|
||||||
|
xmlns:x="http://schemas.microsoft.com/winfx/2006/xaml">
|
||||||
|
<!--Preview-->
|
||||||
|
<Design.PreviewWith>
|
||||||
|
<Border Padding="20">
|
||||||
|
<StackPanel Spacing="5">
|
||||||
|
<TextBlock Text="Regular Text" />
|
||||||
|
<TextBlock Text="Regular Text" FontWeight="Bold" />
|
||||||
|
<TextBlock Text="NotoSansFont Text" FontFamily="{StaticResource NotoSansFont}" />
|
||||||
|
<TextBlock Text="NotoSansBoldFont Text" FontFamily="{StaticResource NotoSansBoldFont}" FontWeight="Bold" />
|
||||||
|
<TextBlock Text="NotoSansFont Size 15" FontFamily="{StaticResource NotoSansFont}" FontSize="15" />
|
||||||
|
<TextBlock Text="NotoSansBoldFont Size 15" FontFamily="{StaticResource NotoSansBoldFont}" FontWeight="Bold" FontSize="15" />
|
||||||
|
</StackPanel>
|
||||||
|
</Border>
|
||||||
|
</Design.PreviewWith>
|
||||||
|
|
||||||
|
<FontFamily x:Key="NotoSansFont">avares://EllieHub/Assets/Fonts/NotoSans-Regular.ttf</FontFamily>
|
||||||
|
<FontFamily x:Key="NotoSansBoldFont">avares://EllieHub/Assets/Fonts/NotoSans-Bold.ttf</FontFamily>
|
||||||
|
</ResourceDictionary>
|
73
EllieHub/Resources/Images.axaml
Normal file
|
@ -0,0 +1,73 @@
|
||||||
|
<ResourceDictionary xmlns="https://github.com/avaloniaui"
|
||||||
|
xmlns:x="http://schemas.microsoft.com/winfx/2006/xaml">
|
||||||
|
|
||||||
|
<!--Preview-->
|
||||||
|
<Design.PreviewWith>
|
||||||
|
<Border Padding="20">
|
||||||
|
<StackPanel>
|
||||||
|
<StackPanel Spacing="5" Orientation="Horizontal">
|
||||||
|
<Image Classes="icon" Source="{DynamicResource EllieAvatar}" />
|
||||||
|
<Image Classes="icon" Source="{DynamicResource PaypalIcon}" />
|
||||||
|
<Image Classes="icon" Source="{DynamicResource PatreonIcon}" />
|
||||||
|
<Image Classes="icon" Source="{DynamicResource KofiIcon}" />
|
||||||
|
</StackPanel>
|
||||||
|
<StackPanel Spacing="5" Orientation="Horizontal">
|
||||||
|
<Image Classes="icon" Source="{DynamicResource CheckForUpdateIcon}" />
|
||||||
|
<Image Classes="icon" Source="{DynamicResource ConfigIcon}" />
|
||||||
|
<Image Classes="icon" Source="{DynamicResource DependenciesIcon}" />
|
||||||
|
<Image Classes="icon" Source="{DynamicResource DocumentationIcon}" />
|
||||||
|
<Image Classes="icon" Source="{DynamicResource HomeIcon}" />
|
||||||
|
<Image Classes="icon" Source="{DynamicResource CommandsIcon}" />
|
||||||
|
</StackPanel>
|
||||||
|
<StackPanel Spacing="5" Orientation="Horizontal">
|
||||||
|
<Image Classes="icon" Source="{DynamicResource EmbedsIcon}" />
|
||||||
|
<Image Classes="icon" Source="{DynamicResource UrlIcon}" />
|
||||||
|
<Image Classes="icon" Source="{DynamicResource SuggestionIcon}" />
|
||||||
|
<Image Classes="icon" Source="{DynamicResource DiscordIcon}" />
|
||||||
|
<Image Classes="icon" Source="{DynamicResource EllieHubImage}" />
|
||||||
|
<Image Classes="icon" Source="{DynamicResource TerminalIcon}" />
|
||||||
|
</StackPanel>
|
||||||
|
</StackPanel>
|
||||||
|
</Border>
|
||||||
|
</Design.PreviewWith>
|
||||||
|
|
||||||
|
<!--Generic Images-->
|
||||||
|
<Bitmap x:Key="EllieAvatar">avares://EllieHub/Assets/ellie.png</Bitmap>
|
||||||
|
<Bitmap x:Key="PaypalIcon">avares://EllieHub/Assets/paypal.png</Bitmap>
|
||||||
|
<Bitmap x:Key="PatreonIcon">avares://EllieHub/Assets/patreon.png</Bitmap>
|
||||||
|
<Bitmap x:Key="KofiIcon">avares://EllieHub/Assets/ko-fi.webp</Bitmap>
|
||||||
|
|
||||||
|
<!--Themed Images-->
|
||||||
|
<ResourceDictionary.ThemeDictionaries>
|
||||||
|
<ResourceDictionary x:Key='Light'>
|
||||||
|
<Bitmap x:Key="CheckForUpdateIcon">avares://EllieHub/Assets/Light/check_for_updates.png</Bitmap>
|
||||||
|
<Bitmap x:Key="ConfigIcon">avares://EllieHub/Assets/Light/config.png</Bitmap>
|
||||||
|
<Bitmap x:Key="DependenciesIcon">avares://EllieHub/Assets/Light/deps.png</Bitmap>
|
||||||
|
<Bitmap x:Key="DocumentationIcon">avares://EllieHub/Assets/Light/docs.png</Bitmap>
|
||||||
|
<Bitmap x:Key="HomeIcon">avares://EllieHub/Assets/Light/home.png</Bitmap>
|
||||||
|
<Bitmap x:Key="CommandsIcon">avares://EllieHub/Assets/Light/icon-commands.png</Bitmap>
|
||||||
|
<Bitmap x:Key="EmbedsIcon">avares://EllieHub/Assets/Light/icon-embeds.png</Bitmap>
|
||||||
|
<Bitmap x:Key="UrlIcon">avares://EllieHub/Assets/Light/icon-link.png</Bitmap>
|
||||||
|
<Bitmap x:Key="SuggestionIcon">avares://EllieHub/Assets/Light/icon-suggest.png</Bitmap>
|
||||||
|
<Bitmap x:Key="DiscordIcon">avares://EllieHub/Assets/Light/icon-support.png</Bitmap>
|
||||||
|
<WindowIcon x:Key="EllieHubIcon">avares://EllieHub/Assets/Light/ellieupdatericon.ico</WindowIcon>
|
||||||
|
<Bitmap x:Key="EllieHubImage">avares://EllieHub/Assets/Light/ellieupdatericon.ico</Bitmap>
|
||||||
|
<Bitmap x:Key="TerminalIcon">avares://EllieHub/Assets/Light/terminal.png</Bitmap>
|
||||||
|
</ResourceDictionary>
|
||||||
|
<ResourceDictionary x:Key='Dark'>
|
||||||
|
<Bitmap x:Key="CheckForUpdateIcon">avares://EllieHub/Assets/Dark/check_for_updates.png</Bitmap>
|
||||||
|
<Bitmap x:Key="ConfigIcon">avares://EllieHub/Assets/Dark/config.png</Bitmap>
|
||||||
|
<Bitmap x:Key="DependenciesIcon">avares://EllieHub/Assets/Dark/deps.png</Bitmap>
|
||||||
|
<Bitmap x:Key="DocumentationIcon">avares://EllieHub/Assets/Dark/docs.png</Bitmap>
|
||||||
|
<Bitmap x:Key="HomeIcon">avares://EllieHub/Assets/Dark/home.png</Bitmap>
|
||||||
|
<Bitmap x:Key="CommandsIcon">avares://EllieHub/Assets/Dark/icon-commands.png</Bitmap>
|
||||||
|
<Bitmap x:Key="EmbedsIcon">avares://EllieHub/Assets/Dark/icon-embeds.png</Bitmap>
|
||||||
|
<Bitmap x:Key="UrlIcon">avares://EllieHub/Assets/Dark/icon-link.png</Bitmap>
|
||||||
|
<Bitmap x:Key="SuggestionIcon">avares://EllieHub/Assets/Dark/icon-suggest.png</Bitmap>
|
||||||
|
<Bitmap x:Key="DiscordIcon">avares://EllieHub/Assets/Dark/icon-support.png</Bitmap>
|
||||||
|
<WindowIcon x:Key="EllieHubIcon">avares://EllieHub/Assets/Dark/ellieupdatericon.ico</WindowIcon>
|
||||||
|
<Bitmap x:Key="EllieHubImage">avares://EllieHub/Assets/Dark/ellieupdatericon.ico</Bitmap>
|
||||||
|
<Bitmap x:Key="TerminalIcon">avares://EllieHub/Assets/Dark/terminal.png</Bitmap>
|
||||||
|
</ResourceDictionary>
|
||||||
|
</ResourceDictionary.ThemeDictionaries>
|
||||||
|
</ResourceDictionary>
|
71
EllieHub/Services/Abstractions/FfmpregResolver.cs
Normal file
|
@ -0,0 +1,71 @@
|
||||||
|
namespace EllieHub.Services.Abstractions;
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Base class for a service that checks, downloads, installs, and updates ffmpeg.
|
||||||
|
/// </summary>
|
||||||
|
public abstract class FfmpegResolver : IFfmpegResolver
|
||||||
|
{
|
||||||
|
private readonly string _programVerifier = (Environment.OSVersion.Platform is PlatformID.Win32NT) ? "where" : "which";
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// The name of the Ffmpeg process.
|
||||||
|
/// </summary>
|
||||||
|
protected const string FfmpegProcessName = "ffmpeg";
|
||||||
|
|
||||||
|
/// <inheritdoc/>
|
||||||
|
public string DependencyName { get; } = "FFMPEG";
|
||||||
|
|
||||||
|
/// <inheritdoc/>
|
||||||
|
public abstract string FileName { get; }
|
||||||
|
|
||||||
|
/// <inheritdoc/>
|
||||||
|
public virtual async ValueTask<bool?> CanUpdateAsync(CancellationToken cToken = default)
|
||||||
|
{
|
||||||
|
// Check where ffmpeg is referenced.
|
||||||
|
using var whereProcess = Utilities.StartProcess(_programVerifier, FfmpegProcessName);
|
||||||
|
var installationPath = await whereProcess.StandardOutput.ReadToEndAsync(cToken);
|
||||||
|
|
||||||
|
// If ffmpeg is present but not managed by us, just report it is installed.
|
||||||
|
if (!string.IsNullOrWhiteSpace(installationPath) && !installationPath.Contains(AppStatics.AppDepsUri, StringComparison.Ordinal))
|
||||||
|
return false;
|
||||||
|
|
||||||
|
var currentVer = await GetCurrentVersionAsync(cToken);
|
||||||
|
|
||||||
|
// If ffmpeg or ffprobe are absent, a reinstall needs to be performed.
|
||||||
|
if (currentVer is null || !await Utilities.ProgramExistsAsync("ffprobe", cToken))
|
||||||
|
return null;
|
||||||
|
|
||||||
|
var latestVer = await GetLatestVersionAsync(cToken);
|
||||||
|
|
||||||
|
return !latestVer.Equals(currentVer, StringComparison.Ordinal);
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <inheritdoc/>
|
||||||
|
public virtual async ValueTask<string?> GetCurrentVersionAsync(CancellationToken cToken = default)
|
||||||
|
{
|
||||||
|
// If ffmpeg is not accessible from the shell...
|
||||||
|
if (!await Utilities.ProgramExistsAsync(FfmpegProcessName, cToken))
|
||||||
|
{
|
||||||
|
// And doesn't exist in the dependencies folder,
|
||||||
|
// report that ffmpeg is not installed.
|
||||||
|
if (!File.Exists(Path.Combine(AppStatics.AppDepsUri, FileName)))
|
||||||
|
return null;
|
||||||
|
|
||||||
|
// Else, add the dependencies directory to the PATH envar,
|
||||||
|
// then try again.
|
||||||
|
Utilities.AddPathToPATHEnvar(AppStatics.AppDepsUri);
|
||||||
|
return await GetCurrentVersionAsync(cToken);
|
||||||
|
}
|
||||||
|
|
||||||
|
using var ffmpeg = Utilities.StartProcess(FfmpegProcessName, "-version");
|
||||||
|
var match = AppStatics.FfmpegVersionRegex.Match(await ffmpeg.StandardOutput.ReadLineAsync(cToken) ?? string.Empty);
|
||||||
|
|
||||||
|
return match.Groups[1].Value;
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <inheritdoc/>
|
||||||
|
public abstract ValueTask<string> GetLatestVersionAsync(CancellationToken cToken = default);
|
||||||
|
|
||||||
|
/// <inheritdoc/>
|
||||||
|
public abstract ValueTask<(string? OldVersion, string? NewVersion)> InstallOrUpdateAsync(string dependenciesUri, CancellationToken cToken = default);
|
||||||
|
}
|
56
EllieHub/Services/Abstractions/IAppConfigManager.cs
Normal file
|
@ -0,0 +1,56 @@
|
||||||
|
using EllieHub.Models;
|
||||||
|
using EllieHub.Models.Config;
|
||||||
|
|
||||||
|
namespace EllieHub.Services.Abstractions;
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Represents a service that manages the application's settings.
|
||||||
|
/// </summary>
|
||||||
|
public interface IAppConfigManager
|
||||||
|
{
|
||||||
|
/// <summary>
|
||||||
|
/// The application settings.
|
||||||
|
/// </summary>
|
||||||
|
ReadOnlyAppConfig AppConfig { get; }
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Creates a bot entry.
|
||||||
|
/// </summary>
|
||||||
|
/// <param name="cToken">The cancellation token.</param>
|
||||||
|
/// <returns>The bot entry that got created.</returns>
|
||||||
|
/// <exception cref="InvalidOperationException">Occurs when the bot entry is not successfully created.</exception>
|
||||||
|
ValueTask<BotEntry> CreateBotEntryAsync(CancellationToken cToken = default);
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Deletes a bot entry at the specified <paramref name="id"/>.
|
||||||
|
/// </summary>
|
||||||
|
/// <param name="id">The Id of the bot.</param>
|
||||||
|
/// <param name="cToken">The cancellation token.</param>
|
||||||
|
/// <returns>The bot entry that got deleted, <see langword="null"/> otherwise.</returns>
|
||||||
|
ValueTask<BotEntry?> DeleteBotEntryAsync(Guid id, CancellationToken cToken = default);
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Moves a bot entry in the list.
|
||||||
|
/// </summary>
|
||||||
|
/// <param name="firstBotId">The bot being swapped.</param>
|
||||||
|
/// <param name="secondBotId">The bot to swap with.</param>
|
||||||
|
/// <param name="cToken">The cancellation token.</param>
|
||||||
|
/// <returns><see langword="true"/> if the entry got moved, <see langword="false"/> otherwise.</returns>
|
||||||
|
ValueTask<bool> SwapBotEntryAsync(Guid firstBotId, Guid secondBotId, CancellationToken cToken = default);
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Changes the bot entry with the specified <paramref name="id"/>.
|
||||||
|
/// </summary>
|
||||||
|
/// <param name="id">The Id of the bot.</param>
|
||||||
|
/// <param name="selector">The changes that should be performed on the entry.</param>
|
||||||
|
/// <param name="cToken">The cancellation token.</param>
|
||||||
|
/// <returns><see langword="true"/> if changes were made on the entry, <see langword="false"/> otherwise.</returns>
|
||||||
|
ValueTask<bool> UpdateBotEntryAsync(Guid id, Func<BotInstanceInfo, BotInstanceInfo> selector, CancellationToken cToken = default);
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Changes the application's settings file according to the <paramref name="action"/>.
|
||||||
|
/// </summary>
|
||||||
|
/// <param name="action">The action to be performed on the configuration file.</param>
|
||||||
|
/// <param name="cToken">The cancellation token.</param>
|
||||||
|
ValueTask UpdateConfigAsync(Action<AppConfig> action, CancellationToken cToken = default);
|
||||||
|
}
|
28
EllieHub/Services/Abstractions/IAppResolver.cs
Normal file
|
@ -0,0 +1,28 @@
|
||||||
|
namespace EllieHub.Services.Abstractions;
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Represents a service that updates this application.
|
||||||
|
/// </summary>
|
||||||
|
public interface IAppResolver : IDependencyResolver
|
||||||
|
{
|
||||||
|
/// <summary>
|
||||||
|
/// The absolute path to the binary file of this application.
|
||||||
|
/// </summary>
|
||||||
|
string BinaryUri { get; }
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// The suffix appended to the name of old files.
|
||||||
|
/// </summary>
|
||||||
|
string OldFileSuffix { get; }
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Removes the files from the old installation.
|
||||||
|
/// </summary>
|
||||||
|
/// <returns><see langword="true"/> if old files were removed, <see langword="false"/> otherwise.</returns>
|
||||||
|
bool RemoveOldFiles();
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Starts the recently updated version of this application.
|
||||||
|
/// </summary>
|
||||||
|
void LaunchNewVersion();
|
||||||
|
}
|
51
EllieHub/Services/Abstractions/IBotOrchestrator.cs
Normal file
|
@ -0,0 +1,51 @@
|
||||||
|
using EllieHub.Models.EventArguments;
|
||||||
|
|
||||||
|
namespace EllieHub.Services.Abstractions;
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Represents an object that coordinates multiple running processes of Ellie.
|
||||||
|
/// </summary>
|
||||||
|
public interface IBotOrchestrator
|
||||||
|
{
|
||||||
|
/// <summary>
|
||||||
|
/// Raised when a bot process exits.
|
||||||
|
/// </summary>
|
||||||
|
event EventHandler<IBotOrchestrator, BotExitEventArgs>? OnBotExit;
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Raised when a bot process prints data to stderr.
|
||||||
|
/// </summary>
|
||||||
|
event EventHandler<IBotOrchestrator, ProcessStdWriteEventArgs>? OnStderr;
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Raised when a bot process prints data to stdout.
|
||||||
|
/// </summary>
|
||||||
|
event EventHandler<IBotOrchestrator, ProcessStdWriteEventArgs>? OnStdout;
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Determines whether the bot with the specified <paramref name="botId"/>.
|
||||||
|
/// </summary>
|
||||||
|
/// <param name="botId">The bot's Id.</param>
|
||||||
|
/// <returns><see langword="true"/> if the bot is running, <see langword="false"/> otherwise.</returns>
|
||||||
|
bool IsBotRunning(Guid botId);
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Starts the bot with the specified <paramref name="botId"/>.
|
||||||
|
/// </summary>
|
||||||
|
/// <param name="botId">The bot's Id.</param>
|
||||||
|
/// <returns><see langword="true"/> if the bot successfully started, <see langword="false"/> otherwise.</returns>
|
||||||
|
bool Start(Guid botId);
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Stops the bot with the specified <paramref name="botId"/>.
|
||||||
|
/// </summary>
|
||||||
|
/// <param name="botId">The bot's Id.</param>
|
||||||
|
/// <returns><see langword="true"/> if the bot successfully stopped, <see langword="false"/> otherwise.</returns>
|
||||||
|
bool Stop(Guid botId);
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Stops all bot instances.
|
||||||
|
/// </summary>
|
||||||
|
/// <returns><see langword="true"/> if at least one bot instance was stopped, <see langword="false"/> otherwise.</returns>
|
||||||
|
bool StopAll();
|
||||||
|
}
|
23
EllieHub/Services/Abstractions/IBotResolver.cs
Normal file
|
@ -0,0 +1,23 @@
|
||||||
|
namespace EllieHub.Services.Abstractions;
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Represents a service that checks, downloads, installs, and updates a bot instance.
|
||||||
|
/// </summary>
|
||||||
|
public interface IBotResolver : IDependencyResolver
|
||||||
|
{
|
||||||
|
/// <summary>
|
||||||
|
/// The name of the bot instance.
|
||||||
|
/// </summary>
|
||||||
|
string BotName { get; }
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// The Id of the bot.
|
||||||
|
/// </summary>
|
||||||
|
Guid Id { get; }
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Creates a backup of the bot instance associated with this resolver.
|
||||||
|
/// </summary>
|
||||||
|
/// <returns>The absolute path to the backup file or <see langword="null"/> if the backup failed.</returns>
|
||||||
|
ValueTask<string?> CreateBackupAsync();
|
||||||
|
}
|
59
EllieHub/Services/Abstractions/IDependencyResolver.cs
Normal file
|
@ -0,0 +1,59 @@
|
||||||
|
namespace EllieHub.Services.Abstractions;
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Represents a service that checks, downloads, installs, and updates a dependency.
|
||||||
|
/// </summary>
|
||||||
|
public interface IDependencyResolver
|
||||||
|
{
|
||||||
|
/// <summary>
|
||||||
|
/// The name of this dependency.
|
||||||
|
/// </summary>
|
||||||
|
string DependencyName { get; }
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// The name of the dependency binary file.
|
||||||
|
/// </summary>
|
||||||
|
string FileName { get; }
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Checks if the dependency can be updated.
|
||||||
|
/// </summary>
|
||||||
|
/// <param name="cToken">The cancellation token.</param>
|
||||||
|
/// <returns>
|
||||||
|
/// <see langword="true"/> if the dependency can be updated,
|
||||||
|
/// <see langword="false"/> if the dependency is up-to-date,
|
||||||
|
/// <see langword="null"/> if the dependency is not installed.
|
||||||
|
/// </returns>
|
||||||
|
ValueTask<bool?> CanUpdateAsync(CancellationToken cToken = default);
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Gets the version of the dependency currently installed on this system.
|
||||||
|
/// </summary>
|
||||||
|
/// <param name="cToken">The cancellation token.</param>
|
||||||
|
/// <returns>The version of the dependency on this system or <see langword="null"/> if the dependency is not installed.</returns>
|
||||||
|
ValueTask<string?> GetCurrentVersionAsync(CancellationToken cToken = default);
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Gets the latest version of the dependency.
|
||||||
|
/// </summary>
|
||||||
|
/// <param name="cToken">The cancellation token.</param>
|
||||||
|
/// <returns>The latest version of the dependency.</returns>
|
||||||
|
/// <exception cref="InvalidOperationException">
|
||||||
|
/// Occurs when there is an issue with the redirection of GitHub's latest release link.
|
||||||
|
/// </exception>
|
||||||
|
ValueTask<string> GetLatestVersionAsync(CancellationToken cToken = default);
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Installs or updates the dependency on this system.
|
||||||
|
/// </summary>
|
||||||
|
/// <param name="installationUri">The absolute path to the directory where the dependency should be installed to.</param>
|
||||||
|
/// <param name="cToken">The cancellation token.</param>
|
||||||
|
/// <returns>
|
||||||
|
/// A tuple that may or may not contain the old and new versions of the dependency. <br />
|
||||||
|
/// (<see langword="null"/>, <see langword="null"/>): the dependency is being updated by another thread, so no operation was performed. <br />
|
||||||
|
/// (<see langword="string"/>, <see langword="null"/>): the dependency is already up-to-date, so no operation was performed. <br />
|
||||||
|
/// (<see langword="null"/>, <see langword="string"/>): the dependency got installed. <br />
|
||||||
|
/// (<see langword="string"/>, <see langword="string"/>): the dependency got updated.
|
||||||
|
/// </returns>
|
||||||
|
ValueTask<(string? OldVersion, string? NewVersion)> InstallOrUpdateAsync(string installationUri, CancellationToken cToken = default);
|
||||||
|
}
|
9
EllieHub/Services/Abstractions/IFfmpegResolver.cs
Normal file
|
@ -0,0 +1,9 @@
|
||||||
|
namespace EllieHub.Services.Abstractions;
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Represents a service that checks, downloads, installs, and updates ffmpeg.
|
||||||
|
/// </summary>
|
||||||
|
/// <remarks>This interface exists mainly for DI registration.</remarks>
|
||||||
|
public interface IFfmpegResolver : IDependencyResolver
|
||||||
|
{
|
||||||
|
}
|
52
EllieHub/Services/Abstractions/ILogWriter.cs
Normal file
|
@ -0,0 +1,52 @@
|
||||||
|
using EllieHub.Models.EventArguments;
|
||||||
|
using System.Diagnostics.CodeAnalysis;
|
||||||
|
|
||||||
|
namespace EllieHub.Services.Abstractions;
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Represents a service that writes logs of bot instances to the disk.
|
||||||
|
/// </summary>
|
||||||
|
public interface ILogWriter
|
||||||
|
{
|
||||||
|
/// <summary>
|
||||||
|
/// Raised when a log file is created.
|
||||||
|
/// </summary>
|
||||||
|
event EventHandler<ILogWriter, LogFlushEventArgs>? OnLogCreated;
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Writes the logs of all bots to a log file.
|
||||||
|
/// </summary>
|
||||||
|
/// <param name="removeFromMemory">
|
||||||
|
/// <see langword="true"/> if the backing storage for the bot's logs should be removed from memory, <see langword="false"/> otherwise.
|
||||||
|
/// </param>
|
||||||
|
/// <param name="cToken">The cancellation token.</param>
|
||||||
|
/// <returns><see langword="true"/> if at least one log file was created, <see langword="false"/> otherwise.</returns>
|
||||||
|
Task<bool> FlushAllAsync(bool removeFromMemory = false, CancellationToken cToken = default);
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Writes the logs of the specified bot to a log file.
|
||||||
|
/// </summary>
|
||||||
|
/// <param name="botId">The Id of the bot.</param>
|
||||||
|
/// <param name="removeFromMemory">
|
||||||
|
/// <see langword="true"/> if the backing storage for the bot's logs should be removed from memory, <see langword="false"/> otherwise.
|
||||||
|
/// </param>
|
||||||
|
/// <param name="cToken">The cancellation token.</param>
|
||||||
|
/// <returns><see langword="true"/> if the log file was successfully created, <see langword="false"/> otherwise.</returns>
|
||||||
|
Task<bool> FlushAsync(Guid botId, bool removeFromMemory = false, CancellationToken cToken = default);
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Safely adds a <paramref name="message"/> to the log of the bot with the specified <paramref name="botId"/>.
|
||||||
|
/// </summary>
|
||||||
|
/// <param name="botId">The Id of the bot.</param>
|
||||||
|
/// <param name="message">The message to be appended to the log.</param>
|
||||||
|
/// <returns><see langword="true"/> if the <paramref name="message"/> was successfully added to the log, <see langword="false"/> otherwise.</returns>
|
||||||
|
bool TryAdd(Guid botId, string message);
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Safely gets the logs of the bot with the specified <paramref name="botId"/>.
|
||||||
|
/// </summary>
|
||||||
|
/// <param name="botId">The Id of the bot.</param>
|
||||||
|
/// <param name="log">The log of the bot.</param>
|
||||||
|
/// <returns><see langword="true"/> if the <paramref name="log"/> was successfully retrieved, <see langword="false"/> otherwise.</returns>
|
||||||
|
bool TryRead(Guid botId, [MaybeNullWhen(false)] out string log);
|
||||||
|
}
|
9
EllieHub/Services/Abstractions/IYtdlpResolver.cs
Normal file
|
@ -0,0 +1,9 @@
|
||||||
|
namespace EllieHub.Services.Abstractions;
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Represents a service that checks, downloads, installs, and updates yt-dlp.
|
||||||
|
/// </summary>
|
||||||
|
/// <remarks>This interface exists mainly for DI registration.</remarks>
|
||||||
|
public interface IYtdlpResolver : IDependencyResolver
|
||||||
|
{
|
||||||
|
}
|
126
EllieHub/Services/AppConfigManager.cs
Normal file
|
@ -0,0 +1,126 @@
|
||||||
|
using EllieHub.Models;
|
||||||
|
using EllieHub.Models.Config;
|
||||||
|
using EllieHub.Services.Abstractions;
|
||||||
|
using System.Text.Json;
|
||||||
|
|
||||||
|
namespace EllieHub.Services;
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Defines a service that manages the application's settings.
|
||||||
|
/// </summary>
|
||||||
|
public sealed class AppConfigManager : IAppConfigManager
|
||||||
|
{
|
||||||
|
private readonly JsonSerializerOptions _jsonSerializerOptions = new() { WriteIndented = true };
|
||||||
|
private readonly AppConfig _appConfig;
|
||||||
|
|
||||||
|
/// <inheritdoc/>
|
||||||
|
public ReadOnlyAppConfig AppConfig { get; }
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Creates a service that manages the application's settings.
|
||||||
|
/// </summary>
|
||||||
|
public AppConfigManager(AppConfig appConfig, ReadOnlyAppConfig readOnlyAppConfig)
|
||||||
|
{
|
||||||
|
_appConfig = appConfig;
|
||||||
|
AppConfig = readOnlyAppConfig;
|
||||||
|
|
||||||
|
Directory.CreateDirectory(AppStatics.AppDefaultConfigDirectoryUri); // Create the directory where the app settings will be stored.
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <inheritdoc/>
|
||||||
|
public async ValueTask<BotEntry> CreateBotEntryAsync(CancellationToken cToken = default)
|
||||||
|
{
|
||||||
|
var newId = CreateNewId();
|
||||||
|
var newPosition = (_appConfig.BotEntries.Count is 0) ? 0 : _appConfig.BotEntries.Values.Max(x => x.Position) + 1;
|
||||||
|
var newBotName = "NewBot_" + newPosition;
|
||||||
|
var newEntry = new BotInstanceInfo(newBotName, Path.Combine(_appConfig.BotsDirectoryUri, newBotName), newPosition);
|
||||||
|
|
||||||
|
if (!_appConfig.BotEntries.TryAdd(newId, newEntry))
|
||||||
|
throw new InvalidOperationException($"Could not create a new bot entry with Id {newId}.");
|
||||||
|
|
||||||
|
await SaveAsync(cToken);
|
||||||
|
|
||||||
|
return new(newId, newEntry);
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <inheritdoc/>
|
||||||
|
public async ValueTask<BotEntry?> DeleteBotEntryAsync(Guid id, CancellationToken cToken = default)
|
||||||
|
{
|
||||||
|
if (!_appConfig.BotEntries.TryRemove(id, out var removedEntry))
|
||||||
|
return null;
|
||||||
|
|
||||||
|
Utilities.TryDeleteDirectory(removedEntry.InstanceDirectoryUri);
|
||||||
|
|
||||||
|
await SaveAsync(cToken);
|
||||||
|
|
||||||
|
return new(id, removedEntry);
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <inheritdoc/>
|
||||||
|
public async ValueTask<bool> SwapBotEntryAsync(Guid firstBotId, Guid secondBotId, CancellationToken cToken = default)
|
||||||
|
{
|
||||||
|
if (firstBotId == secondBotId
|
||||||
|
|| !_appConfig.BotEntries.TryGetValue(firstBotId, out var firstBotEntry)
|
||||||
|
|| !_appConfig.BotEntries.TryGetValue(secondBotId, out var secondBotEntry))
|
||||||
|
return false;
|
||||||
|
|
||||||
|
var tempFirstPosition = firstBotEntry.Position;
|
||||||
|
|
||||||
|
_appConfig.BotEntries[firstBotId] = _appConfig.BotEntries[firstBotId] with { Position = secondBotEntry.Position };
|
||||||
|
_appConfig.BotEntries[secondBotId] = _appConfig.BotEntries[secondBotId] with { Position = tempFirstPosition };
|
||||||
|
|
||||||
|
await SaveAsync(cToken);
|
||||||
|
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <inheritdoc/>
|
||||||
|
public async ValueTask<bool> UpdateBotEntryAsync(Guid id, Func<BotInstanceInfo, BotInstanceInfo> selector, CancellationToken cToken = default)
|
||||||
|
{
|
||||||
|
if (!_appConfig.BotEntries.TryRemove(id, out var entry))
|
||||||
|
return false;
|
||||||
|
|
||||||
|
var updatedEntry = selector(entry);
|
||||||
|
|
||||||
|
_appConfig.BotEntries.TryAdd(id, updatedEntry);
|
||||||
|
|
||||||
|
await SaveAsync(cToken);
|
||||||
|
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <inheritdoc/>
|
||||||
|
public ValueTask UpdateConfigAsync(Action<AppConfig> action, CancellationToken cToken = default)
|
||||||
|
{
|
||||||
|
action(_appConfig);
|
||||||
|
return SaveAsync(cToken);
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Creates a new Id that is known to be unique in the configuration file.
|
||||||
|
/// </summary>
|
||||||
|
/// <returns>An unique Id.</returns>
|
||||||
|
private Guid CreateNewId()
|
||||||
|
{
|
||||||
|
var id = Guid.NewGuid();
|
||||||
|
|
||||||
|
while (_appConfig.BotEntries.ContainsKey(id))
|
||||||
|
id = Guid.NewGuid();
|
||||||
|
|
||||||
|
return id;
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Saves the bot entries to a configuration file.
|
||||||
|
/// </summary>
|
||||||
|
/// <param name="cToken">The cancellation token.</param>
|
||||||
|
private async ValueTask SaveAsync(CancellationToken cToken = default)
|
||||||
|
{
|
||||||
|
// Create the directory where the config file will be stored, if it doesn't exist.
|
||||||
|
Directory.CreateDirectory(AppStatics.AppDefaultConfigDirectoryUri);
|
||||||
|
|
||||||
|
// Create the configuration file.
|
||||||
|
var json = JsonSerializer.Serialize(_appConfig, _jsonSerializerOptions);
|
||||||
|
await File.WriteAllTextAsync(AppStatics.AppConfigUri, json, cToken);
|
||||||
|
}
|
||||||
|
}
|
185
EllieHub/Services/AppResolver.cs
Normal file
|
@ -0,0 +1,185 @@
|
||||||
|
using EllieHub.Services.Abstractions;
|
||||||
|
using System.IO.Compression;
|
||||||
|
using System.Reflection;
|
||||||
|
using System.Runtime.InteropServices;
|
||||||
|
|
||||||
|
namespace EllieHub.Services;
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Defines a service that updates this application.
|
||||||
|
/// </summary>
|
||||||
|
public sealed class AppResolver : IAppResolver
|
||||||
|
{
|
||||||
|
private static readonly string _tempDirectory = Path.GetTempPath();
|
||||||
|
private static readonly string _downloadedFileName = GetDownloadFileName();
|
||||||
|
private static readonly string? _currentUpdaterVersion = Assembly.GetEntryAssembly()?.GetName().Version?.ToString();
|
||||||
|
private readonly IHttpClientFactory _httpClientFactory;
|
||||||
|
|
||||||
|
/// <inheritdoc/>
|
||||||
|
public string DependencyName { get; } = "EllieHub";
|
||||||
|
|
||||||
|
/// <inheritdoc/>
|
||||||
|
public string OldFileSuffix { get; } = "_old";
|
||||||
|
|
||||||
|
/// <inheritdoc/>
|
||||||
|
public string FileName { get; }
|
||||||
|
|
||||||
|
/// <inheritdoc/>
|
||||||
|
public string BinaryUri { get; }
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Creates a service that updates this application.
|
||||||
|
/// </summary>
|
||||||
|
/// <param name="httpClientFactory">The Http client factory.</param>
|
||||||
|
public AppResolver(IHttpClientFactory httpClientFactory)
|
||||||
|
{
|
||||||
|
_httpClientFactory = httpClientFactory;
|
||||||
|
FileName = (OperatingSystem.IsWindows()) ? "EllieHub.exe" : "EllieHub";
|
||||||
|
BinaryUri = Path.Combine(AppContext.BaseDirectory, FileName);
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <inheritdoc/>
|
||||||
|
public ValueTask<string?> GetCurrentVersionAsync(CancellationToken cToken = default)
|
||||||
|
=> ValueTask.FromResult(_currentUpdaterVersion);
|
||||||
|
|
||||||
|
/// <inheritdoc/>
|
||||||
|
public void LaunchNewVersion()
|
||||||
|
=> Utilities.StartProcess(BinaryUri);
|
||||||
|
|
||||||
|
/// <returns>
|
||||||
|
/// <see langword="true"/> if the updater can be updated,
|
||||||
|
/// <see langword="false"/> if the updater is up-to-date,
|
||||||
|
/// <see langword="null"/> if the updater cannot update itself.
|
||||||
|
/// </returns>
|
||||||
|
/// <inheritdoc/>
|
||||||
|
public async ValueTask<bool?> CanUpdateAsync(CancellationToken cToken = default)
|
||||||
|
{
|
||||||
|
if (!Utilities.CanWriteTo(AppContext.BaseDirectory))
|
||||||
|
return null;
|
||||||
|
|
||||||
|
var currentVersion = await GetCurrentVersionAsync(cToken);
|
||||||
|
|
||||||
|
if (currentVersion is null)
|
||||||
|
return null;
|
||||||
|
|
||||||
|
var latestVersion = await GetLatestVersionAsync(cToken);
|
||||||
|
|
||||||
|
if (latestVersion.Equals(currentVersion, StringComparison.Ordinal))
|
||||||
|
return false;
|
||||||
|
|
||||||
|
var http = _httpClientFactory.CreateClient();
|
||||||
|
|
||||||
|
return await http.IsUrlValidAsync($"https://toastielab.dev/ToastieSharp/EllieHub/releases/download/{latestVersion}/{_downloadedFileName}", cToken);
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <inheritdoc/>
|
||||||
|
public bool RemoveOldFiles()
|
||||||
|
{
|
||||||
|
var result = false;
|
||||||
|
|
||||||
|
foreach (var file in Directory.GetFiles(AppContext.BaseDirectory).Where(x => x.EndsWith(OldFileSuffix)))
|
||||||
|
result |= Utilities.TryDeleteFile(file);
|
||||||
|
|
||||||
|
return result;
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <inheritdoc/>
|
||||||
|
public async ValueTask<string> GetLatestVersionAsync(CancellationToken cToken = default)
|
||||||
|
{
|
||||||
|
var http = _httpClientFactory.CreateClient(AppConstants.NoRedirectClient);
|
||||||
|
|
||||||
|
var response = await http.GetAsync("https://toastielab.dev/ToastieSharp/EllieHub/releases/latest", cToken);
|
||||||
|
|
||||||
|
var lastSlashIndex = response.Headers.Location?.OriginalString.LastIndexOf('/')
|
||||||
|
?? throw new InvalidOperationException("Failed to get the latest EllieBotUpdater version.");
|
||||||
|
|
||||||
|
return response.Headers.Location.OriginalString[(lastSlashIndex + 1)..];
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <inheritdoc/>
|
||||||
|
public async ValueTask<(string? OldVersion, string? NewVersion)> InstallOrUpdateAsync(string installationUri, CancellationToken cToken = default)
|
||||||
|
{
|
||||||
|
var currentVersion = await GetCurrentVersionAsync(cToken);
|
||||||
|
var latestVersion = await GetLatestVersionAsync(cToken);
|
||||||
|
|
||||||
|
if (latestVersion.Equals(currentVersion, StringComparison.Ordinal))
|
||||||
|
return (currentVersion, null);
|
||||||
|
|
||||||
|
var http = _httpClientFactory.CreateClient();
|
||||||
|
var appTempLocation = Path.Combine(_tempDirectory, _downloadedFileName[..(_downloadedFileName.LastIndexOf('.'))]);
|
||||||
|
var zipTempLocation = Path.Combine(_tempDirectory, _downloadedFileName);
|
||||||
|
|
||||||
|
try
|
||||||
|
{
|
||||||
|
using var downloadStream = await http.GetStreamAsync($"https://toastielab.dev/ToastieSharp/EllieHub/releases/download/{latestVersion}/{_downloadedFileName}", cToken);
|
||||||
|
|
||||||
|
// Save the zip file
|
||||||
|
using (var fileStream = new FileStream(zipTempLocation, FileMode.Create))
|
||||||
|
await downloadStream.CopyToAsync(fileStream, cToken);
|
||||||
|
|
||||||
|
// Extract the zip file
|
||||||
|
await Task.Run(() => ZipFile.ExtractToDirectory(zipTempLocation, _tempDirectory), cToken);
|
||||||
|
|
||||||
|
// Move the new binary and its dependencies
|
||||||
|
var newFilesUris = Directory.EnumerateFiles(appTempLocation);
|
||||||
|
|
||||||
|
foreach (var newFileUri in newFilesUris)
|
||||||
|
{
|
||||||
|
var destinationUri = Path.Combine(AppContext.BaseDirectory, newFileUri[(newFileUri.LastIndexOf(Path.DirectorySeparatorChar) + 1)..]);
|
||||||
|
|
||||||
|
// Rename the original file from "file" to "file_old".
|
||||||
|
if (File.Exists(destinationUri))
|
||||||
|
File.Move(destinationUri, destinationUri + OldFileSuffix);
|
||||||
|
|
||||||
|
// Move the new file to the application's directory.
|
||||||
|
if (Environment.OSVersion.Platform is not PlatformID.Unix)
|
||||||
|
File.Move(newFileUri, destinationUri, true);
|
||||||
|
else
|
||||||
|
{
|
||||||
|
// Circumvent this issue on Unix systems: https://github.com/dotnet/runtime/issues/31149
|
||||||
|
using var moveProcess = Utilities.StartProcess("mv", $"\"{newFileUri}\" \"{destinationUri}\"");
|
||||||
|
await moveProcess.WaitForExitAsync(cToken);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Mark the new binary file as executable.
|
||||||
|
if (Environment.OSVersion.Platform is PlatformID.Unix)
|
||||||
|
{
|
||||||
|
using var chmod = Utilities.StartProcess("chmod", $"+x \"{BinaryUri}\"");
|
||||||
|
await chmod.WaitForExitAsync(cToken);
|
||||||
|
}
|
||||||
|
|
||||||
|
return (currentVersion, latestVersion);
|
||||||
|
}
|
||||||
|
finally
|
||||||
|
{
|
||||||
|
// Cleanup
|
||||||
|
Utilities.TryDeleteFile(zipTempLocation);
|
||||||
|
Utilities.TryDeleteDirectory(appTempLocation);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Gets the name of the file to be downloaded.
|
||||||
|
/// </summary>
|
||||||
|
/// <returns>The name of the file to be downloaded.</returns>
|
||||||
|
/// <exception cref="NotSupportedException">Occurs when this method is used in an unsupported system.</exception>
|
||||||
|
private static string GetDownloadFileName()
|
||||||
|
{
|
||||||
|
return RuntimeInformation.OSArchitecture switch
|
||||||
|
{
|
||||||
|
// Windows
|
||||||
|
Architecture.X64 when OperatingSystem.IsWindows() => "EllieHub_win-x64.zip",
|
||||||
|
Architecture.Arm64 when OperatingSystem.IsWindows() => "EllieHub_win-arm64.zip",
|
||||||
|
|
||||||
|
// Linux
|
||||||
|
Architecture.X64 when OperatingSystem.IsLinux() => "EllieHub_linux-x64.zip",
|
||||||
|
Architecture.Arm64 when OperatingSystem.IsLinux() => "EllieHub_linux-arm64.zip",
|
||||||
|
|
||||||
|
// MacOS
|
||||||
|
Architecture.X64 when OperatingSystem.IsMacOS() => "EllieHub_osx-x64.zip",
|
||||||
|
Architecture.Arm64 when OperatingSystem.IsMacOS() => "EllieHub_osx-arm64.zip",
|
||||||
|
_ => throw new NotSupportedException($"Architecture of type {RuntimeInformation.OSArchitecture} is not supported by EllieHub on this OS.")
|
||||||
|
};
|
||||||
|
}
|
||||||
|
}
|
139
EllieHub/Services/EllieOrchestrator.cs
Normal file
|
@ -0,0 +1,139 @@
|
||||||
|
using EllieHub.Models.Config;
|
||||||
|
using EllieHub.Models.EventArguments;
|
||||||
|
using EllieHub.Services.Abstractions;
|
||||||
|
using System.Diagnostics;
|
||||||
|
|
||||||
|
namespace EllieHub.Services;
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Defines an object that coordinates multiple running processes of EllieBot.
|
||||||
|
/// </summary>
|
||||||
|
public sealed class EllieOrchestrator : IBotOrchestrator
|
||||||
|
{
|
||||||
|
private readonly Dictionary<Guid, Process> _runningBots = new();
|
||||||
|
private readonly ReadOnlyAppConfig _appConfig;
|
||||||
|
private readonly string _fileName = (OperatingSystem.IsWindows()) ? "NadekoBot.exe" : "NadekoBot";
|
||||||
|
|
||||||
|
/// <inheritdoc/>
|
||||||
|
public event EventHandler<IBotOrchestrator, BotExitEventArgs>? OnBotExit;
|
||||||
|
|
||||||
|
/// <inheritdoc/>
|
||||||
|
public event EventHandler<IBotOrchestrator, ProcessStdWriteEventArgs>? OnStderr;
|
||||||
|
|
||||||
|
/// <inheritdoc/>
|
||||||
|
public event EventHandler<IBotOrchestrator, ProcessStdWriteEventArgs>? OnStdout;
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Creates an object that coordinates multiple running processes of EllieBot.
|
||||||
|
/// </summary>
|
||||||
|
/// <param name="appConfig">The application settings.</param>
|
||||||
|
public EllieOrchestrator(ReadOnlyAppConfig appConfig)
|
||||||
|
=> _appConfig = appConfig;
|
||||||
|
|
||||||
|
|
||||||
|
/// <inheritdoc/>
|
||||||
|
public bool IsBotRunning(Guid botId)
|
||||||
|
=> _runningBots.ContainsKey(botId);
|
||||||
|
|
||||||
|
|
||||||
|
/// <inheritdoc/>
|
||||||
|
public bool Start(Guid botId)
|
||||||
|
{
|
||||||
|
if (_runningBots.ContainsKey(botId)
|
||||||
|
|| !_appConfig.BotEntries.TryGetValue(botId, out var botEntry)
|
||||||
|
|| !File.Exists(Path.Combine(botEntry.InstanceDirectoryUri, _fileName)))
|
||||||
|
return false;
|
||||||
|
|
||||||
|
var botProcess = Process.Start(new ProcessStartInfo()
|
||||||
|
{
|
||||||
|
FileName = Path.Combine(botEntry.InstanceDirectoryUri, _fileName),
|
||||||
|
WorkingDirectory = botEntry.InstanceDirectoryUri,
|
||||||
|
UseShellExecute = false,
|
||||||
|
CreateNoWindow = true,
|
||||||
|
RedirectStandardOutput = true,
|
||||||
|
RedirectStandardError = true,
|
||||||
|
});
|
||||||
|
|
||||||
|
if (botProcess is null)
|
||||||
|
return false;
|
||||||
|
|
||||||
|
botProcess.EnableRaisingEvents = true;
|
||||||
|
botProcess.OutputDataReceived += EmitStdout;
|
||||||
|
botProcess.ErrorDataReceived += EmitStderr;
|
||||||
|
botProcess.Exited += OnExit;
|
||||||
|
botProcess.BeginOutputReadLine();
|
||||||
|
botProcess.BeginErrorReadLine();
|
||||||
|
|
||||||
|
return _runningBots.TryAdd(botId, botProcess);
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <inheritdoc/>
|
||||||
|
public bool Stop(Guid botId)
|
||||||
|
{
|
||||||
|
if (!_runningBots.TryGetValue(botId, out var botProcess))
|
||||||
|
return false;
|
||||||
|
|
||||||
|
botProcess.Kill(true);
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <inheritdoc/>
|
||||||
|
public bool StopAll()
|
||||||
|
{
|
||||||
|
var amount = _runningBots.Count;
|
||||||
|
|
||||||
|
foreach (var process in _runningBots.Values)
|
||||||
|
try { process.Kill(true); } catch { }
|
||||||
|
|
||||||
|
return amount is not 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Finalizes a process when it stops running.
|
||||||
|
/// </summary>
|
||||||
|
/// <param name="sender">The <see cref="Process"/>.</param>
|
||||||
|
/// <param name="eventArgs">The event arguments.</param>
|
||||||
|
/// <exception cref="InvalidOperationException">Occurs when <paramref name="sender"/> is not of type <see cref="Process"/>.</exception>
|
||||||
|
private void OnExit(object? sender, EventArgs eventArgs)
|
||||||
|
{
|
||||||
|
var (id, process) = _runningBots.First(x => x.Value.Equals(sender));
|
||||||
|
OnBotExit?.Invoke(this, new(id, process.ExitCode));
|
||||||
|
|
||||||
|
_runningBots.Remove(id);
|
||||||
|
process.CancelOutputRead();
|
||||||
|
process.CancelErrorRead();
|
||||||
|
process.Dispose();
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Raises <see cref="OnStdout"/> with its appropriate event arguments.
|
||||||
|
/// </summary>
|
||||||
|
/// <param name="sender">The <see cref="Process"/>.</param>
|
||||||
|
/// <param name="eventArgs">The event arguments.</param>
|
||||||
|
private void EmitStdout(object sender, DataReceivedEventArgs eventArgs)
|
||||||
|
{
|
||||||
|
if (string.IsNullOrWhiteSpace(eventArgs.Data))
|
||||||
|
return;
|
||||||
|
|
||||||
|
var (id, _) = _runningBots.First(x => x.Value.Equals(sender));
|
||||||
|
var newEventArgs = new ProcessStdWriteEventArgs(id, eventArgs.Data);
|
||||||
|
|
||||||
|
OnStdout?.Invoke(this, newEventArgs);
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Raises <see cref="OnStderr"/> with its appropriate event arguments.
|
||||||
|
/// </summary>
|
||||||
|
/// <param name="sender">The <see cref="Process"/>.</param>
|
||||||
|
/// <param name="eventArgs">The event arguments.</param>
|
||||||
|
private void EmitStderr(object sender, DataReceivedEventArgs eventArgs)
|
||||||
|
{
|
||||||
|
if (string.IsNullOrWhiteSpace(eventArgs.Data))
|
||||||
|
return;
|
||||||
|
|
||||||
|
var (id, _) = _runningBots.First(x => x.Value.Equals(sender));
|
||||||
|
var newEventArgs = new ProcessStdWriteEventArgs(id, eventArgs.Data);
|
||||||
|
|
||||||
|
OnStderr?.Invoke(this, newEventArgs);
|
||||||
|
}
|
||||||
|
}
|
274
EllieHub/Services/EllieResolver.cs
Normal file
|
@ -0,0 +1,274 @@
|
||||||
|
using EllieHub.Services.Abstractions;
|
||||||
|
using System.Formats.Tar;
|
||||||
|
using System.IO.Compression;
|
||||||
|
using System.Reflection;
|
||||||
|
using System.Runtime.InteropServices;
|
||||||
|
using System.Text.RegularExpressions;
|
||||||
|
|
||||||
|
namespace EllieHub.Services;
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Service that checks, downloads, installs, and updates a NadekoBot instance.
|
||||||
|
/// </summary>
|
||||||
|
/// <remarks>Source: https://gitlab.com/Kwoth/nadekobot/-/releases/permalink/latest</remarks>
|
||||||
|
public sealed partial class EllieResolver : IBotResolver
|
||||||
|
{
|
||||||
|
private static readonly HashSet<Guid> _updateIdOngoing = new();
|
||||||
|
private static readonly string _tempDirectory = Path.GetTempPath();
|
||||||
|
private static readonly Regex _unzipedDirRegex = GenerateUnzipedDirRegex();
|
||||||
|
private readonly IHttpClientFactory _httpClientFactory;
|
||||||
|
private readonly IAppConfigManager _appConfigManager;
|
||||||
|
|
||||||
|
/// <inheritdoc/>
|
||||||
|
public string DependencyName { get; } = "NadekoBot";
|
||||||
|
|
||||||
|
/// <inheritdoc/>
|
||||||
|
public string FileName { get; } = (OperatingSystem.IsWindows()) ? "NadekoBot.exe" : "NadekoBot";
|
||||||
|
|
||||||
|
/// <inheritdoc/>
|
||||||
|
public Guid Id { get; }
|
||||||
|
|
||||||
|
/// <inheritdoc/>
|
||||||
|
public string BotName { get; }
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Creates a service that checks, downloads, installs, and updates a NadekoBot instance.
|
||||||
|
/// </summary>
|
||||||
|
/// <param name="httpClientFactory">The HTTP client factory.</param>
|
||||||
|
/// <param name="appConfigManager">The application's settings.</param>
|
||||||
|
/// <param name="botId">The Id of the bot.</param>
|
||||||
|
public EllieResolver(IHttpClientFactory httpClientFactory, IAppConfigManager appConfigManager, Guid botId)
|
||||||
|
{
|
||||||
|
_httpClientFactory = httpClientFactory;
|
||||||
|
_appConfigManager = appConfigManager;
|
||||||
|
Id = botId;
|
||||||
|
BotName = _appConfigManager.AppConfig.BotEntries[Id].Name;
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <inheritdoc/>
|
||||||
|
public async ValueTask<bool?> CanUpdateAsync(CancellationToken cToken = default)
|
||||||
|
{
|
||||||
|
var currentVersion = await GetCurrentVersionAsync(cToken);
|
||||||
|
|
||||||
|
if (currentVersion is null)
|
||||||
|
return null;
|
||||||
|
|
||||||
|
var latestVersion = await GetLatestVersionAsync(cToken);
|
||||||
|
|
||||||
|
if (latestVersion.Equals(currentVersion, StringComparison.Ordinal))
|
||||||
|
return false;
|
||||||
|
|
||||||
|
var http = _httpClientFactory.CreateClient();
|
||||||
|
|
||||||
|
return await http.IsUrlValidAsync(
|
||||||
|
$"https://gitlab.com/api/v4/projects/9321079/packages/generic/NadekoBot-build/{latestVersion}/{GetDownloadFileName(latestVersion)}",
|
||||||
|
cToken
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <inheritdoc/>
|
||||||
|
public async ValueTask<string?> CreateBackupAsync()
|
||||||
|
{
|
||||||
|
var botInstance = _appConfigManager.AppConfig.BotEntries[Id];
|
||||||
|
|
||||||
|
if (!Directory.Exists(botInstance.InstanceDirectoryUri))
|
||||||
|
return null;
|
||||||
|
|
||||||
|
Directory.CreateDirectory(_appConfigManager.AppConfig.BotsBackupDirectoryUri);
|
||||||
|
|
||||||
|
var now = DateTimeOffset.Now;
|
||||||
|
var date = new DateOnly(now.Year, now.Month, now.Day).ToShortDateString().Replace('/', '-');
|
||||||
|
var backupZipName = $"{botInstance.Name}_{date}-{now.ToUnixTimeMilliseconds()}.zip";
|
||||||
|
var destinationUri = Path.Combine(_appConfigManager.AppConfig.BotsBackupDirectoryUri, backupZipName);
|
||||||
|
|
||||||
|
// ZipFile does not provide asynchronous implementations, so we have to schedule its
|
||||||
|
// execution to be run in the thread-pool due to how long it takes to finish execution.
|
||||||
|
await Task.Run(() => ZipFile.CreateFromDirectory(botInstance.InstanceDirectoryUri, destinationUri, CompressionLevel.SmallestSize, true));
|
||||||
|
|
||||||
|
return destinationUri;
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <inheritdoc/>
|
||||||
|
public async ValueTask<string?> GetCurrentVersionAsync(CancellationToken cToken = default)
|
||||||
|
{
|
||||||
|
var botEntry = _appConfigManager.AppConfig.BotEntries[Id];
|
||||||
|
|
||||||
|
if (!string.IsNullOrWhiteSpace(botEntry.Version))
|
||||||
|
return botEntry.Version;
|
||||||
|
|
||||||
|
var assemblyUri = Path.Combine(botEntry.InstanceDirectoryUri, "NadekoBot.dll");
|
||||||
|
|
||||||
|
if (!File.Exists(assemblyUri))
|
||||||
|
return null;
|
||||||
|
var nadekoAssembly = Assembly.LoadFile(assemblyUri);
|
||||||
|
var version = nadekoAssembly.GetName().Version
|
||||||
|
?? throw new InvalidOperationException($"Could not find version of the assembly at {assemblyUri}.");
|
||||||
|
|
||||||
|
var currentVersion = $"{version.Major}.{version.Minor}.{version.Build}";
|
||||||
|
|
||||||
|
await _appConfigManager.UpdateBotEntryAsync(Id, x => x with { Version = currentVersion }, cToken);
|
||||||
|
|
||||||
|
return currentVersion;
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <inheritdoc/>
|
||||||
|
public async ValueTask<string> GetLatestVersionAsync(CancellationToken cToken = default)
|
||||||
|
{
|
||||||
|
var http = _httpClientFactory.CreateClient(AppConstants.NoRedirectClient);
|
||||||
|
|
||||||
|
var response = await http.GetAsync("https://gitlab.com/Kwoth/nadekobot/-/releases/permalink/latest", cToken);
|
||||||
|
|
||||||
|
var lastSlashIndex = response.Headers.Location?.OriginalString.LastIndexOf('/')
|
||||||
|
?? throw new InvalidOperationException("Failed to get the latest NadekoBot version.");
|
||||||
|
|
||||||
|
return response.Headers.Location.OriginalString[(lastSlashIndex + 1)..];
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <inheritdoc/>
|
||||||
|
public async ValueTask<(string? OldVersion, string? NewVersion)> InstallOrUpdateAsync(string installationUri, CancellationToken cToken = default)
|
||||||
|
{
|
||||||
|
if (_updateIdOngoing.Contains(Id))
|
||||||
|
return (null, null);
|
||||||
|
|
||||||
|
_updateIdOngoing.Add(Id);
|
||||||
|
|
||||||
|
var currentVersion = await GetCurrentVersionAsync(cToken);
|
||||||
|
var latestVersion = await GetLatestVersionAsync(cToken);
|
||||||
|
|
||||||
|
// Update
|
||||||
|
if (latestVersion == currentVersion)
|
||||||
|
{
|
||||||
|
_updateIdOngoing.Remove(Id);
|
||||||
|
return (currentVersion, null);
|
||||||
|
}
|
||||||
|
|
||||||
|
var backupFileUri = await CreateBackupAsync();
|
||||||
|
|
||||||
|
if (currentVersion is not null)
|
||||||
|
Directory.Delete(installationUri, true);
|
||||||
|
|
||||||
|
// Install
|
||||||
|
Directory.CreateDirectory(_appConfigManager.AppConfig.BotsDirectoryUri);
|
||||||
|
|
||||||
|
var http = _httpClientFactory.CreateClient();
|
||||||
|
var downloadFileName = GetDownloadFileName(latestVersion);
|
||||||
|
var botTempLocation = Path.Combine(_tempDirectory, "nadekobot-" + _unzipedDirRegex.Match(downloadFileName).Groups[1].Value);
|
||||||
|
var zipTempLocation = Path.Combine(_tempDirectory, downloadFileName);
|
||||||
|
|
||||||
|
try
|
||||||
|
{
|
||||||
|
using var downloadStream = await http.GetStreamAsync(
|
||||||
|
$"https://gitlab.com/api/v4/projects/9321079/packages/generic/NadekoBot-build/{latestVersion}/{downloadFileName}",
|
||||||
|
cToken
|
||||||
|
);
|
||||||
|
|
||||||
|
// Move the bot root directory while renaming it
|
||||||
|
if (Environment.OSVersion.Platform is not PlatformID.Unix)
|
||||||
|
{
|
||||||
|
// Save the zip file
|
||||||
|
using (var fileStream = new FileStream(zipTempLocation, FileMode.Create))
|
||||||
|
await downloadStream.CopyToAsync(fileStream, cToken);
|
||||||
|
|
||||||
|
// Extract the zip file
|
||||||
|
await Task.Run(() => ZipFile.ExtractToDirectory(zipTempLocation, _tempDirectory), cToken);
|
||||||
|
|
||||||
|
// Move the bot root directory while renaming it
|
||||||
|
Directory.Move(botTempLocation, installationUri);
|
||||||
|
}
|
||||||
|
else
|
||||||
|
{
|
||||||
|
// Extract the tar ball
|
||||||
|
await TarFile.ExtractToDirectoryAsync(downloadStream, _tempDirectory, true, cToken);
|
||||||
|
|
||||||
|
// Move the bot root directory with "mv" to circumvent this issue on Unix systems: https://github.com/dotnet/runtime/issues/31149
|
||||||
|
using var moveProcess = Utilities.StartProcess("mv", $"\"{botTempLocation}\" \"{installationUri}\"");
|
||||||
|
await moveProcess.WaitForExitAsync(cToken);
|
||||||
|
|
||||||
|
// Set executable permission
|
||||||
|
using var chmod = Utilities.StartProcess("chmod", $"+x \"{Path.Combine(installationUri, FileName)}\"");
|
||||||
|
await chmod.WaitForExitAsync(cToken);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Reapply bot settings
|
||||||
|
if (File.Exists(backupFileUri))
|
||||||
|
{
|
||||||
|
using var zipFile = ZipFile.OpenRead(backupFileUri);
|
||||||
|
var zippedFiles = zipFile.Entries
|
||||||
|
.Where(x =>
|
||||||
|
x.Name is "creds.yml" or "creds_example.yml"
|
||||||
|
|| (!string.IsNullOrWhiteSpace(x.Name) && x.FullName.Contains("data/"))
|
||||||
|
);
|
||||||
|
|
||||||
|
foreach (var zippedFile in zippedFiles)
|
||||||
|
{
|
||||||
|
var fileDestinationPath = zippedFile.FullName.Split('/')
|
||||||
|
.Prepend(Directory.GetParent(installationUri)?.FullName ?? string.Empty)
|
||||||
|
.ToArray();
|
||||||
|
|
||||||
|
await RestoreFileAsync(zippedFile, Path.Combine(fileDestinationPath), cToken);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Update settings
|
||||||
|
await _appConfigManager.UpdateBotEntryAsync(Id, x => x with { Version = latestVersion }, cToken);
|
||||||
|
|
||||||
|
// Create creds.yml
|
||||||
|
var credsUri = Path.Combine(installationUri, "creds.yml");
|
||||||
|
|
||||||
|
if (!File.Exists(credsUri))
|
||||||
|
File.Copy(Path.Combine(installationUri, "creds_example.yml"), credsUri);
|
||||||
|
|
||||||
|
return (currentVersion, latestVersion);
|
||||||
|
}
|
||||||
|
finally
|
||||||
|
{
|
||||||
|
_updateIdOngoing.Remove(Id);
|
||||||
|
|
||||||
|
// Cleanup
|
||||||
|
Utilities.TryDeleteFile(zipTempLocation);
|
||||||
|
Utilities.TryDeleteDirectory(botTempLocation);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Extracts the specified <paramref name="zippedFile"/> to the <paramref name="destinationPath"/>.
|
||||||
|
/// </summary>
|
||||||
|
/// <param name="zippedFile">The file to be extracted.</param>
|
||||||
|
/// <param name="destinationPath">The final location of the extracted file.</param>
|
||||||
|
/// <param name="cToken">The cancellation token.</param>
|
||||||
|
private async static ValueTask RestoreFileAsync(ZipArchiveEntry zippedFile, string destinationPath, CancellationToken cToken = default)
|
||||||
|
{
|
||||||
|
using var zipStream = zippedFile.Open();
|
||||||
|
using var fileStream = new FileStream(destinationPath, FileMode.Create);
|
||||||
|
|
||||||
|
await zipStream.CopyToAsync(fileStream, cToken);
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Gets the name of the file to be downloaded.
|
||||||
|
/// </summary>
|
||||||
|
/// <param name="version">The version of NadekoBot.</param>
|
||||||
|
/// <returns>The name of the file to download.</returns>
|
||||||
|
/// <exception cref="NotSupportedException">Occurs when this method is executed in an unsupported platform.</exception>
|
||||||
|
private static string GetDownloadFileName(string version)
|
||||||
|
{
|
||||||
|
return version + RuntimeInformation.OSArchitecture switch
|
||||||
|
{
|
||||||
|
// Windows
|
||||||
|
Architecture.X64 when OperatingSystem.IsWindows() => "-windows-x64-build.zip",
|
||||||
|
Architecture.Arm64 when OperatingSystem.IsWindows() => "-windows-arm64-build.zip",
|
||||||
|
|
||||||
|
// Linux
|
||||||
|
Architecture.X64 when OperatingSystem.IsLinux() => "-linux-x64-build.tar",
|
||||||
|
Architecture.Arm64 when OperatingSystem.IsLinux() => "-linux-arm64-build.tar",
|
||||||
|
|
||||||
|
// MacOS
|
||||||
|
Architecture.X64 when OperatingSystem.IsMacOS() => "-osx-x64-build.tar",
|
||||||
|
Architecture.Arm64 when OperatingSystem.IsMacOS() => "-osx-arm64-build.tar",
|
||||||
|
_ => throw new NotSupportedException($"Architecture of type {RuntimeInformation.OSArchitecture} is not supported by NadekoBot on this OS.")
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
[GeneratedRegex(@"^(?:\S+\-)(\S+\-\S+)\-", RegexOptions.Compiled)]
|
||||||
|
private static partial Regex GenerateUnzipedDirRegex();
|
||||||
|
}
|
110
EllieHub/Services/FfmpegLinuxResolver.cs
Normal file
|
@ -0,0 +1,110 @@
|
||||||
|
using EllieHub.Services.Abstractions;
|
||||||
|
using System.Runtime.InteropServices;
|
||||||
|
using System.Runtime.Versioning;
|
||||||
|
using System.Text.RegularExpressions;
|
||||||
|
|
||||||
|
namespace EllieHub.Services;
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Service that checks, downloads, installs, and updates ffmpeg on Linux.
|
||||||
|
/// </summary>
|
||||||
|
/// <remarks>Source: https://johnvansickle.com/ffmpeg</remarks>
|
||||||
|
[SupportedOSPlatform("linux")]
|
||||||
|
public sealed partial class FfmpegLinuxResolver : FfmpegResolver
|
||||||
|
{
|
||||||
|
private readonly Regex _ffmpegLatestVersionRegex = FfmpegLatestVersionRegexGenerator();
|
||||||
|
private readonly string _tempDirectory = Path.GetTempPath();
|
||||||
|
private bool _isUpdating = false;
|
||||||
|
private readonly IHttpClientFactory _httpClientFactory;
|
||||||
|
|
||||||
|
/// <inheritdoc/>
|
||||||
|
public override string FileName { get; } = "ffmpeg";
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Creates a service that checks, downloads, installs, and updates ffmpeg on Linux.
|
||||||
|
/// </summary>
|
||||||
|
/// <param name="httpClientFactory">The HTTP client factory.</param>
|
||||||
|
public FfmpegLinuxResolver(IHttpClientFactory httpClientFactory)
|
||||||
|
=> _httpClientFactory = httpClientFactory;
|
||||||
|
|
||||||
|
/// <inheritdoc/>
|
||||||
|
public override ValueTask<bool?> CanUpdateAsync(CancellationToken cToken = default)
|
||||||
|
{
|
||||||
|
return (RuntimeInformation.OSArchitecture is Architecture.X64 or Architecture.Arm64)
|
||||||
|
? base.CanUpdateAsync(cToken)
|
||||||
|
: ValueTask.FromResult<bool?>(false);
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <inheritdoc/>
|
||||||
|
public override async ValueTask<string> GetLatestVersionAsync(CancellationToken cToken = default)
|
||||||
|
{
|
||||||
|
var http = _httpClientFactory.CreateClient();
|
||||||
|
var pageContent = await http.GetStringAsync("https://johnvansickle.com/ffmpeg", cToken);
|
||||||
|
var match = _ffmpegLatestVersionRegex.Match(pageContent);
|
||||||
|
|
||||||
|
return (match.Success)
|
||||||
|
? match.Groups[1].Value
|
||||||
|
: throw new InvalidOperationException("Regex did not match the web page content.");
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <inheritdoc/>
|
||||||
|
public override async ValueTask<(string? OldVersion, string? NewVersion)> InstallOrUpdateAsync(string dependenciesUri, CancellationToken cToken = default)
|
||||||
|
{
|
||||||
|
if (_isUpdating)
|
||||||
|
return (null, null);
|
||||||
|
|
||||||
|
_isUpdating = true;
|
||||||
|
var currentVersion = await GetCurrentVersionAsync(cToken);
|
||||||
|
var newVersion = await GetLatestVersionAsync(cToken);
|
||||||
|
|
||||||
|
// Update
|
||||||
|
if (currentVersion is not null)
|
||||||
|
{
|
||||||
|
// If the versions are the same, exit.
|
||||||
|
if (currentVersion == newVersion)
|
||||||
|
return (currentVersion, null);
|
||||||
|
|
||||||
|
Utilities.TryDeleteFile(Path.Combine(dependenciesUri, FileName));
|
||||||
|
Utilities.TryDeleteFile(Path.Combine(dependenciesUri, "ffprobe"));
|
||||||
|
}
|
||||||
|
|
||||||
|
// Install
|
||||||
|
Directory.CreateDirectory(dependenciesUri);
|
||||||
|
|
||||||
|
var architecture = (RuntimeInformation.OSArchitecture is Architecture.X64) ? "amd" : "arm";
|
||||||
|
var tarFileName = $"ffmpeg-release-{architecture}64-static.tar.xz";
|
||||||
|
var http = _httpClientFactory.CreateClient();
|
||||||
|
using var downloadStream = await http.GetStreamAsync($"https://johnvansickle.com/ffmpeg/releases/{tarFileName}", cToken);
|
||||||
|
|
||||||
|
// Save tar file to the temporary directory.
|
||||||
|
var tarFilePath = Path.Combine(_tempDirectory, tarFileName);
|
||||||
|
var tarExtractDir = Path.Combine(_tempDirectory, $"ffmpeg-{newVersion}-{architecture}64-static");
|
||||||
|
using (var fileStream = new FileStream(tarFilePath, FileMode.Create))
|
||||||
|
await downloadStream.CopyToAsync(fileStream, cToken);
|
||||||
|
|
||||||
|
// Extract the tar file.
|
||||||
|
using var extractProcess = Utilities.StartProcess("tar", $"xf \"{tarFilePath}\" --directory=\"{_tempDirectory}\"");
|
||||||
|
await extractProcess.WaitForExitAsync(cToken);
|
||||||
|
|
||||||
|
// Move ffmpeg to the dependencies directory.
|
||||||
|
File.Move(Path.Combine(tarExtractDir, FileName), Path.Combine(dependenciesUri, FileName), true);
|
||||||
|
File.Move(Path.Combine(tarExtractDir, "ffprobe"), Path.Combine(dependenciesUri, "ffprobe"), true);
|
||||||
|
|
||||||
|
// Mark the files as executable.
|
||||||
|
using var chmod = Utilities.StartProcess("chmod", $"+x \"{Path.Combine(dependenciesUri, FileName)}\" \"{Path.Combine(dependenciesUri, "ffprobe")}\"");
|
||||||
|
await chmod.WaitForExitAsync(cToken);
|
||||||
|
|
||||||
|
// Cleanup
|
||||||
|
File.Delete(tarFilePath);
|
||||||
|
Directory.Delete(tarExtractDir, true);
|
||||||
|
|
||||||
|
// Update environment variable
|
||||||
|
Utilities.AddPathToPATHEnvar(dependenciesUri);
|
||||||
|
|
||||||
|
_isUpdating = false;
|
||||||
|
return (currentVersion, newVersion);
|
||||||
|
}
|
||||||
|
|
||||||
|
[GeneratedRegex(@"release:\s?([\d\.]+)", RegexOptions.Compiled)]
|
||||||
|
private static partial Regex FfmpegLatestVersionRegexGenerator();
|
||||||
|
}
|