1
0
mirror of https://github.com/chylex/Discord-History-Tracker.git synced 2024-11-25 05:42:45 +01:00

Compare commits

...

2 Commits

Author SHA1 Message Date
031d521402
Convert attachment download events to reactive 2023-12-28 17:33:30 +01:00
0131f8cb50
Refactor integrated server management 2023-12-28 17:33:30 +01:00
18 changed files with 271 additions and 328 deletions

View File

@ -21,6 +21,7 @@
<PackageReference Include="Avalonia.Desktop" Version="11.0.6" />
<PackageReference Include="Avalonia.Diagnostics" Version="11.0.6" Condition=" '$(Configuration)' == 'Debug' " />
<PackageReference Include="Avalonia.Fonts.Inter" Version="11.0.6" />
<PackageReference Include="Avalonia.ReactiveUI" Version="11.0.6" />
<PackageReference Include="Avalonia.Themes.Fluent" Version="11.0.6" />
</ItemGroup>

View File

@ -1,14 +1,19 @@
using System;
using System.Threading.Tasks;
using Avalonia.Controls;
using Avalonia.Threading;
using DHT.Desktop.Dialogs.Message;
using DHT.Desktop.Server;
using DHT.Server;
using DHT.Server.Service;
using DHT.Utils.Logging;
using DHT.Utils.Models;
namespace DHT.Desktop.Main.Controls;
sealed class ServerConfigurationPanelModel : BaseModel, IDisposable {
private static readonly Log Log = Log.ForType<ServerConfigurationPanelModel>();
private string inputPort;
public string InputPort {
@ -29,7 +34,7 @@ sealed class ServerConfigurationPanelModel : BaseModel, IDisposable {
}
}
public bool HasMadeChanges => ServerManager.Port.ToString() != InputPort || ServerManager.Token != InputToken;
public bool HasMadeChanges => ServerConfiguration.Port.ToString() != InputPort || ServerConfiguration.Token != InputToken;
private bool isToggleServerButtonEnabled = true;
@ -38,59 +43,69 @@ sealed class ServerConfigurationPanelModel : BaseModel, IDisposable {
set => Change(ref isToggleServerButtonEnabled, value);
}
public string ToggleServerButtonText => serverManager.IsRunning ? "Stop Server" : "Start Server";
public event EventHandler<StatusBarModel.Status>? ServerStatusChanged;
public string ToggleServerButtonText => server.IsRunning ? "Stop Server" : "Start Server";
private readonly Window window;
private readonly ServerManager serverManager;
private readonly ServerManager server;
[Obsolete("Designer")]
public ServerConfigurationPanelModel() : this(null!, new ServerManager(State.Dummy)) {}
public ServerConfigurationPanelModel() : this(null!, State.Dummy) {}
public ServerConfigurationPanelModel(Window window, ServerManager serverManager) {
public ServerConfigurationPanelModel(Window window, State state) {
this.window = window;
this.serverManager = serverManager;
this.inputPort = ServerManager.Port.ToString();
this.inputToken = ServerManager.Token;
}
this.server = state.Server;
this.inputPort = ServerConfiguration.Port.ToString();
this.inputToken = ServerConfiguration.Token;
public void Initialize() {
ServerLauncher.ServerStatusChanged += ServerLauncherOnServerStatusChanged;
server.StatusChanged += OnServerStatusChanged;
}
public void Dispose() {
ServerLauncher.ServerStatusChanged -= ServerLauncherOnServerStatusChanged;
server.StatusChanged -= OnServerStatusChanged;
}
private void ServerLauncherOnServerStatusChanged(object? sender, EventArgs e) {
ServerStatusChanged?.Invoke(this, serverManager.IsRunning ? StatusBarModel.Status.Ready : StatusBarModel.Status.Stopped);
private void OnServerStatusChanged(object? sender, ServerManager.Status e) {
Dispatcher.UIThread.InvokeAsync(UpdateServerStatus);
}
private void UpdateServerStatus() {
OnPropertyChanged(nameof(ToggleServerButtonText));
}
private async Task StartServer() {
IsToggleServerButtonEnabled = false;
try {
await server.Start(ServerConfiguration.Port, ServerConfiguration.Token);
} catch (Exception e) {
Log.Error(e);
await Dialog.ShowOk(window, "Internal Server Error", e.Message);
}
UpdateServerStatus();
IsToggleServerButtonEnabled = true;
}
private void BeforeServerStart() {
private async Task StopServer() {
IsToggleServerButtonEnabled = false;
ServerStatusChanged?.Invoke(this, StatusBarModel.Status.Starting);
try {
await server.Stop();
} catch (Exception e) {
Log.Error(e);
await Dialog.ShowOk(window, "Internal Server Error", e.Message);
}
private void StartServer() {
BeforeServerStart();
serverManager.Launch();
UpdateServerStatus();
IsToggleServerButtonEnabled = true;
}
private void StopServer() {
IsToggleServerButtonEnabled = false;
ServerStatusChanged?.Invoke(this, StatusBarModel.Status.Stopping);
serverManager.Stop();
}
public void OnClickToggleServerButton() {
if (serverManager.IsRunning) {
StopServer();
public async Task OnClickToggleServerButton() {
if (server.IsRunning) {
await StopServer();
}
else {
StartServer();
await StartServer();
}
}
@ -98,19 +113,21 @@ sealed class ServerConfigurationPanelModel : BaseModel, IDisposable {
InputToken = ServerUtils.GenerateRandomToken(20);
}
public async void OnClickApplyChanges() {
public async Task OnClickApplyChanges() {
if (!ushort.TryParse(InputPort, out ushort port)) {
await Dialog.ShowOk(window, "Invalid Port", "Port must be a number between 0 and 65535.");
return;
}
BeforeServerStart();
serverManager.Relaunch(port, InputToken);
ServerConfiguration.Port = port;
ServerConfiguration.Token = inputToken;
await StartServer();
OnPropertyChanged(nameof(HasMadeChanges));
}
public void OnClickCancelChanges() {
InputPort = ServerManager.Port.ToString();
InputToken = ServerManager.Token;
InputPort = ServerConfiguration.Port.ToString();
InputToken = ServerConfiguration.Token;
}
}

View File

@ -40,7 +40,7 @@
<StackPanel Orientation="Horizontal" Margin="6 3">
<StackPanel Orientation="Vertical" Width="65">
<TextBlock Classes="label">Status</TextBlock>
<TextBlock FontSize="12" Margin="0 3 0 0" Text="{Binding StatusText}" />
<TextBlock FontSize="12" Margin="0 3 0 0" Text="{Binding ServerStatusText}" />
</StackPanel>
<Rectangle />
<StackPanel Orientation="Vertical">

View File

@ -1,45 +1,46 @@
using System;
using Avalonia.Threading;
using DHT.Server;
using DHT.Server.Database;
using DHT.Server.Service;
using DHT.Utils.Models;
namespace DHT.Desktop.Main.Controls;
sealed class StatusBarModel : BaseModel {
sealed class StatusBarModel : BaseModel, IDisposable {
public DatabaseStatistics DatabaseStatistics { get; }
private Status status = Status.Stopped;
private ServerManager.Status serverStatus;
public Status CurrentStatus {
get => status;
set {
status = value;
OnPropertyChanged(nameof(StatusText));
}
}
public string StatusText {
get {
return CurrentStatus switch {
Status.Starting => "STARTING",
Status.Ready => "READY",
Status.Stopping => "STOPPING",
Status.Stopped => "STOPPED",
public string ServerStatusText => serverStatus switch {
ServerManager.Status.Starting => "STARTING",
ServerManager.Status.Started => "READY",
ServerManager.Status.Stopping => "STOPPING",
ServerManager.Status.Stopped => "STOPPED",
_ => ""
};
}
}
private readonly State state;
[Obsolete("Designer")]
public StatusBarModel() : this(new DatabaseStatistics()) {}
public StatusBarModel() : this(State.Dummy) {}
public StatusBarModel(DatabaseStatistics databaseStatistics) {
this.DatabaseStatistics = databaseStatistics;
public StatusBarModel(State state) {
this.state = state;
this.DatabaseStatistics = state.Db.Statistics;
state.Server.StatusChanged += OnServerStatusChanged;
serverStatus = state.Server.IsRunning ? ServerManager.Status.Started : ServerManager.Status.Stopped;
}
public enum Status {
Starting,
Ready,
Stopping,
Stopped
public void Dispose() {
state.Server.StatusChanged += OnServerStatusChanged;
}
private void OnServerStatusChanged(object? sender, ServerManager.Status e) {
Dispatcher.UIThread.InvokeAsync(() => {
serverStatus = e;
OnPropertyChanged(nameof(ServerStatusText));
});
}
}

View File

@ -8,6 +8,7 @@ using DHT.Desktop.Dialogs.Message;
using DHT.Desktop.Main.Screens;
using DHT.Desktop.Server;
using DHT.Server;
using DHT.Utils.Logging;
using DHT.Utils.Models;
namespace DHT.Desktop.Main;
@ -15,6 +16,8 @@ namespace DHT.Desktop.Main;
sealed class MainWindowModel : BaseModel, IAsyncDisposable {
private const string DefaultTitle = "Discord History Tracker";
private static readonly Log Log = Log.ForType<MainWindowModel>();
public string Title { get; private set; } = DefaultTitle;
public UserControl CurrentScreen { get; private set; }
@ -63,11 +66,11 @@ sealed class MainWindowModel : BaseModel, IAsyncDisposable {
}
if (args.ServerPort != null) {
ServerManager.Port = args.ServerPort.Value;
ServerConfiguration.Port = args.ServerPort.Value;
}
if (args.ServerToken != null) {
ServerManager.Token = args.ServerToken;
ServerConfiguration.Token = args.ServerToken;
}
}
@ -82,15 +85,23 @@ sealed class MainWindowModel : BaseModel, IAsyncDisposable {
await state.DisposeAsync();
}
state = welcomeScreenModel.Db == null ? null : new State(welcomeScreenModel.Db);
if (state == null) {
if (welcomeScreenModel.Db == null) {
state = null;
Title = DefaultTitle;
mainContentScreenModel = null;
mainContentScreen = null;
CurrentScreen = welcomeScreen;
}
else {
state = new State(welcomeScreenModel.Db);
try {
await state.Server.Start(ServerConfiguration.Port, ServerConfiguration.Token);
} catch (Exception ex) {
Log.Error(ex);
await Dialog.ShowOk(window, "Internal Server Error", ex.Message);
}
Title = Path.GetFileName(state.Db.Path) + " - " + DefaultTitle;
mainContentScreenModel = new MainContentScreenModel(window, state);
await mainContentScreenModel.Initialize();

View File

@ -2,7 +2,6 @@ using System;
using Avalonia.Controls;
using DHT.Desktop.Dialogs.Message;
using DHT.Desktop.Main.Controls;
using DHT.Desktop.Server;
using DHT.Server;
using DHT.Utils.Models;
@ -15,17 +14,13 @@ sealed class AdvancedPageModel : BaseModel, IDisposable {
private readonly State state;
[Obsolete("Designer")]
public AdvancedPageModel() : this(null!, State.Dummy, new ServerManager(State.Dummy)) {}
public AdvancedPageModel() : this(null!, State.Dummy) {}
public AdvancedPageModel(Window window, State state, ServerManager serverManager) {
public AdvancedPageModel(Window window, State state) {
this.window = window;
this.state = state;
ServerConfigurationModel = new ServerConfigurationPanelModel(window, serverManager);
}
public void Initialize() {
ServerConfigurationModel.Initialize();
ServerConfigurationModel = new ServerConfigurationPanelModel(window, state);
}
public void Dispose() {

View File

@ -1,9 +1,9 @@
using System;
using System.Collections.Generic;
using System.ComponentModel;
using System.Threading;
using System.Reactive.Linq;
using System.Threading.Tasks;
using Avalonia.Threading;
using Avalonia.ReactiveUI;
using DHT.Desktop.Common;
using DHT.Desktop.Main.Controls;
using DHT.Server;
@ -11,7 +11,6 @@ using DHT.Server.Data;
using DHT.Server.Data.Aggregations;
using DHT.Server.Data.Filters;
using DHT.Server.Database;
using DHT.Server.Download;
using DHT.Utils.Models;
using DHT.Utils.Tasks;
@ -61,6 +60,7 @@ sealed class AttachmentsPageModel : BaseModel, IDisposable {
private readonly State state;
private readonly AsyncValueComputer<DownloadStatusStatistics>.Single downloadStatisticsComputer;
private IDisposable? finishedItemsSubscription;
private int doneItemsCount;
private int initialFinishedCount;
private int? totalItemsToDownloadCount;
@ -76,12 +76,11 @@ sealed class AttachmentsPageModel : BaseModel, IDisposable {
downloadStatisticsComputer.Recompute();
state.Db.Statistics.PropertyChanged += OnDbStatisticsChanged;
state.Downloader.OnItemFinished += DownloaderOnOnItemFinished;
}
public void Dispose() {
state.Db.Statistics.PropertyChanged -= OnDbStatisticsChanged;
state.Downloader.OnItemFinished -= DownloaderOnOnItemFinished;
finishedItemsSubscription?.Dispose();
FilterModel.Dispose();
}
@ -139,19 +138,22 @@ sealed class AttachmentsPageModel : BaseModel, IDisposable {
OnPropertyChanged(nameof(DownloadProgress));
}
private void DownloaderOnOnItemFinished(object? sender, DownloadItem e) {
Interlocked.Increment(ref doneItemsCount);
Dispatcher.UIThread.Invoke(UpdateDownloadMessage);
private void OnItemsFinished(int finishedItemCount) {
doneItemsCount += finishedItemCount;
UpdateDownloadMessage();
downloadStatisticsComputer.Recompute();
}
public async Task OnClickToggleDownload() {
if (IsDownloading) {
IsToggleDownloadButtonEnabled = false;
if (IsDownloading) {
await state.Downloader.Stop();
finishedItemsSubscription?.Dispose();
finishedItemsSubscription = null;
downloadStatisticsComputer.Recompute();
IsToggleDownloadButtonEnabled = true;
state.Db.RemoveDownloadItems(EnqueuedItemFilter, FilterRemovalMode.RemoveMatching);
@ -161,13 +163,22 @@ sealed class AttachmentsPageModel : BaseModel, IDisposable {
UpdateDownloadMessage();
}
else {
var finishedItems = await state.Downloader.Start();
initialFinishedCount = statisticsDownloaded.Items + statisticsFailed.Items;
await state.Downloader.Start();
finishedItemsSubscription = finishedItems.Select(static _ => true)
.Buffer(TimeSpan.FromMilliseconds(100))
.Select(static items => items.Count)
.Where(static items => items > 0)
.ObserveOn(AvaloniaScheduler.Instance)
.Subscribe(OnItemsFinished);
EnqueueDownloadItems();
}
OnPropertyChanged(nameof(ToggleDownloadButtonText));
OnPropertyChanged(nameof(IsDownloading));
IsToggleDownloadButtonEnabled = true;
}
public void OnClickRetryFailedDownloads() {

View File

@ -54,7 +54,7 @@ sealed class TrackingPageModel : BaseModel {
}
public async Task<bool> OnClickCopyTrackingScript() {
string url = $"http://127.0.0.1:{ServerManager.Port}/get-tracking-script?token={HttpUtility.UrlEncode(ServerManager.Token)}";
string url = $"http://127.0.0.1:{ServerConfiguration.Port}/get-tracking-script?token={HttpUtility.UrlEncode(ServerConfiguration.Token)}";
string script = (await Resources.ReadTextAsync("tracker-loader.js")).Trim().Replace("{url}", url);
var clipboard = window.Clipboard;

View File

@ -110,7 +110,7 @@ sealed class ViewerPageModel : BaseModel, IDisposable {
TemporaryFiles.Add(fullPath);
Directory.CreateDirectory(rootPath);
await WriteViewerFile(fullPath, new LiveViewerExportStrategy(ServerManager.Port, ServerManager.Token));
await WriteViewerFile(fullPath, new LiveViewerExportStrategy(ServerConfiguration.Port, ServerConfiguration.Token));
Process.Start(new ProcessStartInfo(fullPath) { UseShellExecute = true });
}

View File

@ -1,12 +1,9 @@
using System;
using System.Threading.Tasks;
using Avalonia.Controls;
using DHT.Desktop.Dialogs.Message;
using DHT.Desktop.Main.Controls;
using DHT.Desktop.Main.Pages;
using DHT.Desktop.Server;
using DHT.Server;
using DHT.Server.Service;
using DHT.Utils.Logging;
namespace DHT.Desktop.Main.Screens;
@ -49,18 +46,10 @@ sealed class MainContentScreenModel : IDisposable {
}
}
private readonly Window window;
private readonly ServerManager serverManager;
[Obsolete("Designer")]
public MainContentScreenModel() : this(null!, State.Dummy) {}
public MainContentScreenModel(Window window, State state) {
this.window = window;
this.serverManager = new ServerManager(state);
ServerLauncher.ServerManagementExceptionCaught += ServerLauncherOnServerManagementExceptionCaught;
DatabasePageModel = new DatabasePageModel(window, state);
DatabasePage = new DatabasePage { DataContext = DatabasePageModel };
@ -73,7 +62,7 @@ sealed class MainContentScreenModel : IDisposable {
ViewerPageModel = new ViewerPageModel(window, state);
ViewerPage = new ViewerPage { DataContext = ViewerPageModel };
AdvancedPageModel = new AdvancedPageModel(window, state, serverManager);
AdvancedPageModel = new AdvancedPageModel(window, state);
AdvancedPage = new AdvancedPage { DataContext = AdvancedPageModel };
#if DEBUG
@ -83,37 +72,17 @@ sealed class MainContentScreenModel : IDisposable {
DebugPage = null;
#endif
StatusBarModel = new StatusBarModel(state.Db.Statistics);
AdvancedPageModel.ServerConfigurationModel.ServerStatusChanged += OnServerStatusChanged;
DatabaseClosed += OnDatabaseClosed;
StatusBarModel.CurrentStatus = serverManager.IsRunning ? StatusBarModel.Status.Ready : StatusBarModel.Status.Stopped;
StatusBarModel = new StatusBarModel(state);
}
public async Task Initialize() {
await TrackingPageModel.Initialize();
AdvancedPageModel.Initialize();
serverManager.Launch();
}
public void Dispose() {
ServerLauncher.ServerManagementExceptionCaught -= ServerLauncherOnServerManagementExceptionCaught;
AttachmentsPageModel.Dispose();
ViewerPageModel.Dispose();
serverManager.Dispose();
}
private void OnServerStatusChanged(object? sender, StatusBarModel.Status e) {
StatusBarModel.CurrentStatus = e;
}
private void OnDatabaseClosed(object? sender, EventArgs e) {
serverManager.Stop();
}
private async void ServerLauncherOnServerManagementExceptionCaught(object? sender, Exception ex) {
Log.Error(ex);
await Dialog.ShowOk(window, "Internal Server Error", ex.Message);
AdvancedPageModel.Dispose();
StatusBarModel.Dispose();
}
}

View File

@ -0,0 +1,8 @@
using DHT.Server.Service;
namespace DHT.Desktop.Server;
static class ServerConfiguration {
public static ushort Port { get; set; } = ServerUtils.FindAvailablePort(50000, 60000);
public static string Token { get; set; } = ServerUtils.GenerateRandomToken(20);
}

View File

@ -1,50 +0,0 @@
using System;
using DHT.Server;
using DHT.Server.Service;
namespace DHT.Desktop.Server;
sealed class ServerManager : IDisposable {
public static ushort Port { get; set; } = ServerUtils.FindAvailablePort(50000, 60000);
public static string Token { get; set; } = ServerUtils.GenerateRandomToken(20);
private static ServerManager? instance;
public bool IsRunning => ServerLauncher.IsRunning;
private readonly State state;
public ServerManager(State state) {
if (state != State.Dummy) {
if (instance != null) {
throw new InvalidOperationException("Only one instance of ServerManager can exist at the same time!");
}
instance = this;
}
this.state = state;
}
public void Launch() {
ServerLauncher.Relaunch(Port, Token, state.Db);
}
public void Relaunch(ushort port, string token) {
Port = port;
Token = token;
Launch();
}
public void Stop() {
ServerLauncher.Stop();
}
public void Dispose() {
Stop();
if (instance == this) {
instance = null;
}
}
}

View File

@ -9,8 +9,6 @@ public sealed class Downloader {
private DownloaderTask? current;
public bool IsDownloading => current != null;
public event EventHandler<DownloadItem>? OnItemFinished;
private readonly IDatabaseFile db;
private readonly SemaphoreSlim semaphore = new (1, 1);
@ -18,13 +16,11 @@ public sealed class Downloader {
this.db = db;
}
public async Task Start() {
public async Task<IObservable<DownloadItem>> Start() {
await semaphore.WaitAsync();
try {
if (current == null) {
current = new DownloaderTask(db);
current.OnItemFinished += DelegateOnItemFinished;
}
current ??= new DownloaderTask(db);
return current.FinishedItems;
} finally {
semaphore.Release();
}
@ -34,16 +30,11 @@ public sealed class Downloader {
await semaphore.WaitAsync();
try {
if (current != null) {
await current.Stop();
current.OnItemFinished -= DelegateOnItemFinished;
await current.DisposeAsync();
current = null;
}
} finally {
semaphore.Release();
}
}
private void DelegateOnItemFinished(object? sender, DownloadItem e) {
OnItemFinished?.Invoke(this, e);
}
}

View File

@ -1,25 +1,23 @@
using System;
using System.Linq;
using System.Net.Http;
using System.Reactive.Subjects;
using System.Threading;
using System.Threading.Channels;
using System.Threading.Tasks;
using DHT.Server.Database;
using DHT.Utils.Logging;
using DHT.Utils.Models;
using DHT.Utils.Tasks;
namespace DHT.Server.Download;
sealed class DownloaderTask : BaseModel {
sealed class DownloaderTask : IAsyncDisposable {
private static readonly Log Log = Log.ForType<DownloaderTask>();
private const int DownloadTasks = 4;
private const int QueueSize = 25;
private const string UserAgent = "Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/120.0.0.0 Safari/537.36";
internal event EventHandler<DownloadItem>? OnItemFinished;
private readonly Channel<DownloadItem> downloadQueue = Channel.CreateBounded<DownloadItem>(new BoundedChannelOptions(QueueSize) {
SingleReader = false,
SingleWriter = true,
@ -31,12 +29,16 @@ sealed class DownloaderTask : BaseModel {
private readonly CancellationToken cancellationToken;
private readonly IDatabaseFile db;
private readonly Subject<DownloadItem> finishedItemPublisher = new ();
private readonly Task queueWriterTask;
private readonly Task[] downloadTasks;
public IObservable<DownloadItem> FinishedItems => finishedItemPublisher;
internal DownloaderTask(IDatabaseFile db) {
this.cancellationToken = cancellationTokenSource.Token;
this.db = db;
this.cancellationToken = cancellationTokenSource.Token;
this.queueWriterTask = Task.Run(RunQueueWriterTask);
this.downloadTasks = Enumerable.Range(1, DownloadTasks).Select(taskIndex => Task.Run(() => RunDownloadTask(taskIndex))).ToArray();
}
@ -79,7 +81,7 @@ sealed class DownloaderTask : BaseModel {
log.Error(e);
} finally {
try {
OnItemFinished?.Invoke(this, item);
finishedItemPublisher.OnNext(item);
} catch (Exception e) {
log.Error("Caught exception in event handler: " + e);
}
@ -87,7 +89,7 @@ sealed class DownloaderTask : BaseModel {
}
}
internal async Task Stop() {
public async ValueTask DisposeAsync() {
try {
await cancellationTokenSource.CancelAsync();
} catch (Exception) {
@ -102,6 +104,7 @@ sealed class DownloaderTask : BaseModel {
await Task.WhenAll(downloadTasks).WaitIgnoringCancellation();
} finally {
cancellationTokenSource.Dispose();
finishedItemPublisher.OnCompleted();
}
}
}

View File

@ -12,6 +12,7 @@
<ItemGroup>
<PackageReference Include="Microsoft.Data.Sqlite" Version="8.0.0" />
<PackageReference Include="System.Reactive" Version="6.0.0" />
</ItemGroup>
<ItemGroup>

View File

@ -1,126 +0,0 @@
using System;
using System.Collections.Concurrent;
using System.Diagnostics.CodeAnalysis;
using System.Threading;
using DHT.Server.Database;
using DHT.Utils.Logging;
using Microsoft.AspNetCore.Hosting;
using Microsoft.AspNetCore.Server.Kestrel.Core;
using Microsoft.Extensions.DependencyInjection;
namespace DHT.Server.Service;
public static class ServerLauncher {
private static readonly Log Log = Log.ForType(typeof(ServerLauncher));
private static IWebHost? Server { get; set; } = null;
public static bool IsRunning { get; private set; }
public static event EventHandler? ServerStatusChanged;
public static event EventHandler<Exception>? ServerManagementExceptionCaught;
private static Thread? ManagementThread { get; set; } = null;
private static readonly Mutex ManagementThreadLock = new();
private static readonly BlockingCollection<IMessage> Messages = new(new ConcurrentQueue<IMessage>());
private static void EnqueueMessage(IMessage message) {
ManagementThreadLock.WaitOne();
try {
if (ManagementThread == null) {
ManagementThread = new Thread(RunManagementThread) {
Name = "DHT server management thread",
IsBackground = true
};
ManagementThread.Start();
}
Messages.Add(message);
} finally {
ManagementThreadLock.ReleaseMutex();
}
}
[SuppressMessage("ReSharper", "FunctionNeverReturns")]
private static void RunManagementThread() {
foreach (IMessage message in Messages.GetConsumingEnumerable()) {
try {
switch (message) {
case IMessage.StartServer start:
StopServerFromManagementThread();
StartServerFromManagementThread(start.Port, start.Token, start.Db);
break;
case IMessage.StopServer:
StopServerFromManagementThread();
break;
}
} catch (Exception e) {
ServerManagementExceptionCaught?.Invoke(null, e);
}
}
}
private static void StartServerFromManagementThread(ushort port, string token, IDatabaseFile db) {
Log.Info("Starting server on port " + port + "...");
void AddServices(IServiceCollection services) {
services.AddSingleton(typeof(IDatabaseFile), db);
services.AddSingleton(typeof(ServerParameters), new ServerParameters(port, token));
}
void SetKestrelOptions(KestrelServerOptions options) {
options.Limits.MaxRequestBodySize = null;
options.Limits.MinResponseDataRate = null;
options.ListenLocalhost(port, static listenOptions => listenOptions.Protocols = HttpProtocols.Http1);
}
Server = new WebHostBuilder()
.ConfigureServices(AddServices)
.UseKestrel(SetKestrelOptions)
.UseStartup<Startup>()
.Build();
Server.Start();
Log.Info("Server started");
IsRunning = true;
ServerStatusChanged?.Invoke(null, EventArgs.Empty);
}
private static void StopServerFromManagementThread() {
if (Server != null) {
Log.Info("Stopping server...");
Server.StopAsync().Wait();
Server.Dispose();
Server = null;
Log.Info("Server stopped");
IsRunning = false;
ServerStatusChanged?.Invoke(null, EventArgs.Empty);
}
}
public static void Relaunch(ushort port, string token, IDatabaseFile db) {
EnqueueMessage(new IMessage.StartServer(port, token, db));
}
public static void Stop() {
EnqueueMessage(new IMessage.StopServer());
}
private interface IMessage {
public sealed class StartServer : IMessage {
public ushort Port { get; }
public string Token { get; }
public IDatabaseFile Db { get; }
public StartServer(ushort port, string token, IDatabaseFile db) {
this.Port = port;
this.Token = token;
this.Db = db;
}
}
public sealed class StopServer : IMessage {}
}
}

View File

@ -0,0 +1,107 @@
using System;
using System.Threading;
using System.Threading.Tasks;
using DHT.Server.Database;
using DHT.Utils.Logging;
using Microsoft.AspNetCore.Hosting;
using Microsoft.AspNetCore.Server.Kestrel.Core;
using Microsoft.Extensions.DependencyInjection;
namespace DHT.Server.Service;
public sealed class ServerManager {
private static readonly Log Log = Log.ForType(typeof(ServerManager));
private IWebHost? server;
public bool IsRunning => server != null;
public event EventHandler<Status>? StatusChanged;
public enum Status {
Starting,
Started,
Stopping,
Stopped
}
private readonly IDatabaseFile db;
private readonly SemaphoreSlim semaphore = new (1, 1);
internal ServerManager(IDatabaseFile db) {
this.db = db;
}
public async Task Start(ushort port, string token) {
await semaphore.WaitAsync();
try {
await StartInternal(port, token);
} finally {
semaphore.Release();
}
}
public async Task Stop() {
await semaphore.WaitAsync();
try {
await StopInternal();
} finally {
semaphore.Release();
}
}
private async Task StartInternal(ushort port, string token) {
await StopInternal();
StatusChanged?.Invoke(this, Status.Starting);
void AddServices(IServiceCollection services) {
services.AddSingleton(typeof(IDatabaseFile), db);
services.AddSingleton(typeof(ServerParameters), new ServerParameters(port, token));
}
void SetKestrelOptions(KestrelServerOptions options) {
options.Limits.MaxRequestBodySize = null;
options.Limits.MinResponseDataRate = null;
options.ListenLocalhost(port, static listenOptions => listenOptions.Protocols = HttpProtocols.Http1);
}
var newServer = new WebHostBuilder()
.ConfigureServices(AddServices)
.UseKestrel(SetKestrelOptions)
.UseStartup<Startup>()
.Build();
Log.Info("Starting server on port " + port + "...");
try {
await newServer.StartAsync();
} catch (Exception) {
Log.Error("Server could not start");
StatusChanged?.Invoke(this, Status.Stopped);
throw;
}
Log.Info("Server started");
server = newServer;
StatusChanged?.Invoke(this, Status.Started);
}
private async Task StopInternal() {
if (server == null) {
return;
}
StatusChanged?.Invoke(this, Status.Stopping);
Log.Info("Stopping server...");
await server.StopAsync();
Log.Info("Server stopped");
server.Dispose();
server = null;
StatusChanged?.Invoke(this, Status.Stopped);
}
}

View File

@ -2,6 +2,7 @@ using System;
using System.Threading.Tasks;
using DHT.Server.Database;
using DHT.Server.Download;
using DHT.Server.Service;
namespace DHT.Server;
@ -10,14 +11,17 @@ public sealed class State : IAsyncDisposable {
public IDatabaseFile Db { get; }
public Downloader Downloader { get; }
public ServerManager Server { get; }
public State(IDatabaseFile db) {
Db = db;
Downloader = new Downloader(db);
Server = new ServerManager(db);
}
public async ValueTask DisposeAsync() {
await Downloader.Stop();
await Server.Stop();
Db.Dispose();
}
}