It is done!

This commit is contained in:
Emotion 2023-10-05 00:44:51 +13:00
parent 761a519028
commit dcf649f9d2
No known key found for this signature in database
GPG key ID: D7D3E4C27A98C37B
143 changed files with 8234 additions and 2 deletions
.editorconfig.gitignoreEllieHub.sln
EllieHub
App.axamlApp.axaml.cs
Assets
Common
DesignData
EllieHub.csproj
Enums
Extensions
Models
Program.cs
Resources
Services

1744
.editorconfig Normal file

File diff suppressed because it is too large Load diff

83
.gitignore vendored
View file

@ -1,4 +1,3 @@
# ---> VisualStudio
## Ignore Visual Studio temporary files, build results, and
## files generated by popular Visual Studio add-ons.
##
@ -35,6 +34,8 @@ bld/
# Visual Studio 2015/2017 cache/options directory
.vs/
# Visual Studio Code
.vscode/
# Uncomment if you have tasks that create the project's static files in wwwroot
#wwwroot/
@ -58,11 +59,14 @@ dlldata.c
# Benchmark Results
BenchmarkDotNet.Artifacts/
# .NET Core
# .NET
project.lock.json
project.fragment.lock.json
artifacts/
# Tye
.tye/
# ASP.NET Scaffolding
ScaffoldingReadMe.txt
@ -398,3 +402,78 @@ FodyWeavers.xsd
# JetBrains Rider
*.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
View 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
View 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
View 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();
}
}

Binary file not shown.

After

(image error) Size: 18 KiB

Binary file not shown.

After

(image error) Size: 1 KiB

Binary file not shown.

After

(image error) Size: 35 KiB

Binary file not shown.

After

(image error) Size: 9.3 KiB

Binary file not shown.

After

(image error) Size: 4.2 KiB

Binary file not shown.

After

(image error) Size: 13 KiB

Binary file not shown.

After

(image error) Size: 175 B

Binary file not shown.

After

(image error) Size: 340 B

Binary file not shown.

After

(image error) Size: 398 B

Binary file not shown.

After

(image error) Size: 528 B

Binary file not shown.

After

(image error) Size: 587 B

Binary file not shown.

After

(image error) Size: 595 B

Binary file not shown.

After

(image error) Size: 11 KiB

Binary file not shown.

After

(image error) Size: 1.4 KiB

Binary file not shown.

After

(image error) Size: 41 KiB

Binary file not shown.

After

(image error) Size: 13 KiB

Binary file not shown.

After

(image error) Size: 4.2 KiB

Binary file not shown.

After

(image error) Size: 12 KiB

Binary file not shown.

After

(image error) Size: 552 B

Binary file not shown.

After

(image error) Size: 712 B

Binary file not shown.

After

(image error) Size: 827 B

Binary file not shown.

After

(image error) Size: 908 B

Binary file not shown.

After

(image error) Size: 1,016 B

Binary file not shown.

After

(image error) Size: 992 B

Binary file not shown.

After

(image error) Size: 9.8 KiB

Binary file not shown.

After

(image error) Size: 172 KiB

Binary file not shown.

After

(image error) Size: 158 KiB

Binary file not shown.

After

(image error) Size: 38 KiB

Binary file not shown.

After

(image error) Size: 471 B

Binary file not shown.

After

(image error) Size: 13 KiB

Binary file not shown.

After

(image error) Size: 592 KiB

Binary file not shown.

After

(image error) Size: 447 B

Binary file not shown.

After

(image error) Size: 726 B

Binary file not shown.

After

(image error) Size: 524 B

Binary file not shown.

After

(image error) Size: 5.4 KiB

Binary file not shown.

After

(image error) Size: 192 KiB

BIN
EllieHub/Assets/ellie.png Normal file

Binary file not shown.

After

(image error) Size: 273 KiB

BIN
EllieHub/Assets/ko-fi.webp Normal file

Binary file not shown.

After

(image error) Size: 5.9 KiB

BIN
EllieHub/Assets/patreon.png Normal file

Binary file not shown.

After

(image error) Size: 11 KiB

BIN
EllieHub/Assets/paypal.png Normal file

Binary file not shown.

After

(image error) Size: 14 KiB

View 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";
}

View 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
}

View 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();
}

View 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
}

View 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;
}
}

View 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.";
}

View 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!;
}

View 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>()
)
{
}
}

View 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>()
)
{
}
}

View file

@ -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>())
{
}
}

View 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.";
}

View 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
{
}

View 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>())
{
}
}

View 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>())
{
}
}

View 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
{
}

View 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>()
)
{
}
}

View 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
View 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>

View 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
}

View 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
}

View 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
}

View 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;
}
}

View 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;
}
}

View 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;
}
}

View 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;
}
}

View 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
);

View 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
);

View 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);

View 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);

View 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();
}

View 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;
}

View 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;
}
}

View 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;
}
}

View 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;
}
}

View 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;
}
}

View 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;
}
}

View 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
View 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();
}

View 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>

View 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>

View 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>

View 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);
}

View 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);
}

View 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();
}

View 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();
}

View 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();
}

View 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);
}

View 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
{
}

View 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);
}

View 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
{
}

View 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);
}
}

View 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.")
};
}
}

View 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);
}
}

View 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();
}

View 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();
}

Some files were not shown because too many files have changed in this diff Show more