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

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 ## Ignore Visual Studio temporary files, build results, and
## files generated by popular Visual Studio add-ons. ## files generated by popular Visual Studio add-ons.
## ##
@ -35,6 +34,8 @@ bld/
# Visual Studio 2015/2017 cache/options directory # Visual Studio 2015/2017 cache/options directory
.vs/ .vs/
# Visual Studio Code
.vscode/
# Uncomment if you have tasks that create the project's static files in wwwroot # Uncomment if you have tasks that create the project's static files in wwwroot
#wwwroot/ #wwwroot/
@ -58,11 +59,14 @@ dlldata.c
# Benchmark Results # Benchmark Results
BenchmarkDotNet.Artifacts/ BenchmarkDotNet.Artifacts/
# .NET Core # .NET
project.lock.json project.lock.json
project.fragment.lock.json project.fragment.lock.json
artifacts/ artifacts/
# Tye
.tye/
# ASP.NET Scaffolding # ASP.NET Scaffolding
ScaffoldingReadMe.txt ScaffoldingReadMe.txt
@ -398,3 +402,78 @@ FodyWeavers.xsd
# JetBrains Rider # JetBrains Rider
*.sln.iml *.sln.iml
##
## Visual studio for Mac
##
# globs
Makefile.in
*.userprefs
*.usertasks
config.make
config.status
aclocal.m4
install-sh
autom4te.cache/
*.tar.gz
tarballs/
test-results/
# Mac bundle stuff
*.dmg
*.app
# content below from: https://github.com/github/gitignore/blob/master/Global/macOS.gitignore
# General
.DS_Store
.AppleDouble
.LSOverride
# Icon must end with two \r
Icon
# Thumbnails
._*
# Files that might appear in the root of a volume
.DocumentRevisions-V100
.fseventsd
.Spotlight-V100
.TemporaryItems
.Trashes
.VolumeIcon.icns
.com.apple.timemachine.donotpresent
# Directories potentially created on remote AFP share
.AppleDB
.AppleDesktop
Network Trash Folder
Temporary Items
.apdisk
# content below from: https://github.com/github/gitignore/blob/master/Global/Windows.gitignore
# Windows thumbnail cache files
Thumbs.db
ehthumbs.db
ehthumbs_vista.db
# Dump file
*.stackdump
# Folder config file
[Dd]esktop.ini
# Recycle Bin used on file shares
$RECYCLE.BIN/
# Windows Installer files
*.cab
*.msi
*.msix
*.msm
*.msp
# Windows shortcuts
*.lnk

25
EllieHub.sln Normal file
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

Width:  |  Height:  |  Size: 18 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 1 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 35 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 9.3 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 4.2 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 13 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 175 B

Binary file not shown.

After

Width:  |  Height:  |  Size: 340 B

Binary file not shown.

After

Width:  |  Height:  |  Size: 398 B

Binary file not shown.

After

Width:  |  Height:  |  Size: 528 B

Binary file not shown.

After

Width:  |  Height:  |  Size: 587 B

Binary file not shown.

After

Width:  |  Height:  |  Size: 595 B

Binary file not shown.

After

Width:  |  Height:  |  Size: 11 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 1.4 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 41 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 13 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 4.2 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 12 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 552 B

Binary file not shown.

After

Width:  |  Height:  |  Size: 712 B

Binary file not shown.

After

Width:  |  Height:  |  Size: 827 B

Binary file not shown.

After

Width:  |  Height:  |  Size: 908 B

Binary file not shown.

After

Width:  |  Height:  |  Size: 1,016 B

Binary file not shown.

After

Width:  |  Height:  |  Size: 992 B

Binary file not shown.

After

Width:  |  Height:  |  Size: 9.8 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 172 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 158 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 38 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 471 B

Binary file not shown.

After

Width:  |  Height:  |  Size: 13 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 592 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 447 B

Binary file not shown.

After

Width:  |  Height:  |  Size: 726 B

Binary file not shown.

After

Width:  |  Height:  |  Size: 524 B

Binary file not shown.

After

Width:  |  Height:  |  Size: 5.4 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 192 KiB

BIN
EllieHub/Assets/ellie.png Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 273 KiB

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

Binary file not shown.

After

Width:  |  Height:  |  Size: 5.9 KiB

BIN
EllieHub/Assets/patreon.png Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 11 KiB

BIN
EllieHub/Assets/paypal.png Normal file

Binary file not shown.

After

Width:  |  Height:  |  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