1
0
mirror of https://github.com/chylex/Discord-History-Tracker.git synced 2025-09-14 17:32:08 +02:00

20 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
3bf5acfa65 Fix trailing spaces Rider generates in code 2023-12-27 08:22:23 +01:00
f603c861c5 Encapsulate server-side state and ensure graceful shutdown when closing main window 2023-12-27 08:22:23 +01:00
d2934f4d6a Update user agent of downloader 2023-12-25 09:36:14 +01:00
567253d147 Use multiple threads to download attachments 2023-12-25 09:36:14 +01:00
aa6555990c Fix throwing exceptions in UI binding converters 2023-12-25 07:04:31 +01:00
3d9d6a454a Remove unnecessary ASP.NET features 2023-12-23 13:47:33 +01:00
ee39780928 Rewrite token authorization checks in integrated server 2023-12-23 13:47:31 +01:00
7b58f973a0 Disable ASP.NET logging and use custom logging for request duration 2023-12-23 11:19:54 +01:00
93fe018343 Add -console argument to show a console on Windows 2023-12-23 08:46:43 +01:00
4f5e27f651 Release v39.1 2023-12-22 16:31:11 +01:00
cbf81ec95a Fix missing JSON source generator when parsing integrated server requests 2023-12-22 16:31:11 +01:00
8a80cb8c20 Show progress dialog when upgrading database schema 2023-12-22 16:18:03 +01:00
865deb356a Fix progress dialog not propagating exceptions from its task 2023-12-22 14:47:55 +01:00
069ab97196 Disable reflection-based JSON serialization 2023-12-22 05:54:24 +01:00
caab038eaa Use source generators for JSON serialization everywhere 2023-12-22 05:24:28 +01:00
fb837374fc Enable single file compression and disable unnecessary .NET features 2023-12-22 02:30:24 +01:00
65d935cca1 Use compiled bindings in Avalonia XAML 2023-12-21 08:55:56 +01:00
6e64c86d7a Optimize viewer JSON export using source generators 2023-12-21 08:29:07 +01:00
90 changed files with 1552 additions and 1059 deletions

View File

@@ -1,4 +1,3 @@
using System;
using Avalonia; using Avalonia;
using Avalonia.Controls.ApplicationLifetimes; using Avalonia.Controls.ApplicationLifetimes;
using Avalonia.Markup.Xaml; using Avalonia.Markup.Xaml;
@@ -13,7 +12,7 @@ sealed class App : Application {
public override void OnFrameworkInitializationCompleted() { public override void OnFrameworkInitializationCompleted() {
if (ApplicationLifetime is IClassicDesktopStyleApplicationLifetime desktop) { if (ApplicationLifetime is IClassicDesktopStyleApplicationLifetime desktop) {
desktop.MainWindow = new MainWindow(new Arguments(desktop.Args ?? Array.Empty<string>())); desktop.MainWindow = new MainWindow(Program.Arguments);
} }
base.OnFrameworkInitializationCompleted(); base.OnFrameworkInitializationCompleted();

View File

@@ -6,25 +6,32 @@ namespace DHT.Desktop;
sealed class Arguments { sealed class Arguments {
private static readonly Log Log = Log.ForType<Arguments>(); private static readonly Log Log = Log.ForType<Arguments>();
private const int FirstArgument = 1;
public static Arguments Empty => new(Array.Empty<string>()); public static Arguments Empty => new(Array.Empty<string>());
public bool Console { get; }
public string? DatabaseFile { get; } public string? DatabaseFile { get; }
public ushort? ServerPort { get; } public ushort? ServerPort { get; }
public string? ServerToken { get; } public string? ServerToken { get; }
public Arguments(string[] args) { public Arguments(string[] args) {
for (int i = 0; i < args.Length; i++) { for (int i = FirstArgument; i < args.Length; i++) {
string key = args[i]; string key = args[i];
switch (key) { switch (key) {
case "-debug": case "-debug":
Log.IsDebugEnabled = true; Log.IsDebugEnabled = true;
continue; continue;
case "-console":
Console = true;
continue;
} }
string value; string value;
if (i == 0 && !key.StartsWith('-')) { if (i == FirstArgument && !key.StartsWith('-')) {
value = key; value = key;
key = "-db"; key = "-db";
} }

View File

@@ -1,9 +1,11 @@
using System; using System;
using System.Collections.Generic; using System.Collections.Generic;
using System.IO; using System.IO;
using System.Threading;
using System.Threading.Tasks; using System.Threading.Tasks;
using Avalonia.Controls; using Avalonia.Controls;
using Avalonia.Platform.Storage; using Avalonia.Platform.Storage;
using Avalonia.Threading;
using DHT.Desktop.Dialogs.File; using DHT.Desktop.Dialogs.File;
using DHT.Desktop.Dialogs.Message; using DHT.Desktop.Dialogs.Message;
using DHT.Server.Database; using DHT.Server.Database;
@@ -41,11 +43,16 @@ static class DatabaseGui {
}); });
} }
public static async Task<IDatabaseFile?> TryOpenOrCreateDatabaseFromPath(string path, Window window, Func<Task<bool>> checkCanUpgradeDatabase) { public static async Task<IDatabaseFile?> TryOpenOrCreateDatabaseFromPath(string path, Window window, ISchemaUpgradeCallbacks schemaUpgradeCallbacks) {
var prevSynchronizationContext = SynchronizationContext.Current;
SynchronizationContext.SetSynchronizationContext(new AvaloniaSynchronizationContext());
var taskScheduler = TaskScheduler.FromCurrentSynchronizationContext();
SynchronizationContext.SetSynchronizationContext(prevSynchronizationContext);
IDatabaseFile? file = null; IDatabaseFile? file = null;
try { try {
file = await SqliteDatabaseFile.OpenOrCreate(path, checkCanUpgradeDatabase); file = await SqliteDatabaseFile.OpenOrCreate(path, schemaUpgradeCallbacks, taskScheduler);
} catch (InvalidDatabaseVersionException ex) { } catch (InvalidDatabaseVersionException ex) {
await Dialog.ShowOk(window, "Database Error", "Database '" + Path.GetFileName(path) + "' appears to be corrupted (invalid version: " + ex.Version + ")."); await Dialog.ShowOk(window, "Database Error", "Database '" + Path.GetFileName(path) + "' appears to be corrupted (invalid version: " + ex.Version + ").");
} catch (DatabaseTooNewException ex) { } catch (DatabaseTooNewException ex) {

View File

@@ -9,6 +9,7 @@
<PropertyGroup> <PropertyGroup>
<OutputType>WinExe</OutputType> <OutputType>WinExe</OutputType>
<ApplicationIcon>./Resources/icon.ico</ApplicationIcon> <ApplicationIcon>./Resources/icon.ico</ApplicationIcon>
<AvaloniaUseCompiledBindingsByDefault>true</AvaloniaUseCompiledBindingsByDefault>
<CheckForOverflowUnderflow>true</CheckForOverflowUnderflow> <CheckForOverflowUnderflow>true</CheckForOverflowUnderflow>
<SatelliteResourceLanguages>en</SatelliteResourceLanguages> <SatelliteResourceLanguages>en</SatelliteResourceLanguages>
</PropertyGroup> </PropertyGroup>
@@ -20,6 +21,7 @@
<PackageReference Include="Avalonia.Desktop" Version="11.0.6" /> <PackageReference Include="Avalonia.Desktop" Version="11.0.6" />
<PackageReference Include="Avalonia.Diagnostics" Version="11.0.6" Condition=" '$(Configuration)' == 'Debug' " /> <PackageReference Include="Avalonia.Diagnostics" Version="11.0.6" Condition=" '$(Configuration)' == 'Debug' " />
<PackageReference Include="Avalonia.Fonts.Inter" Version="11.0.6" /> <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" /> <PackageReference Include="Avalonia.Themes.Fluent" Version="11.0.6" />
</ItemGroup> </ItemGroup>

View File

@@ -5,6 +5,7 @@
xmlns:namespace="clr-namespace:DHT.Desktop.Dialogs.CheckBox" xmlns:namespace="clr-namespace:DHT.Desktop.Dialogs.CheckBox"
mc:Ignorable="d" d:DesignWidth="500" mc:Ignorable="d" d:DesignWidth="500"
x:Class="DHT.Desktop.Dialogs.CheckBox.CheckBoxDialog" x:Class="DHT.Desktop.Dialogs.CheckBox.CheckBoxDialog"
x:DataType="namespace:CheckBoxDialogModel"
Title="{Binding Title}" Title="{Binding Title}"
Icon="avares://DiscordHistoryTracker/Resources/icon.ico" Icon="avares://DiscordHistoryTracker/Resources/icon.ico"
Width="500" SizeToContent="Height" CanResize="False" Width="500" SizeToContent="Height" CanResize="False"

View File

@@ -5,6 +5,7 @@
xmlns:namespace="clr-namespace:DHT.Desktop.Dialogs.Message" xmlns:namespace="clr-namespace:DHT.Desktop.Dialogs.Message"
mc:Ignorable="d" d:DesignWidth="500" mc:Ignorable="d" d:DesignWidth="500"
x:Class="DHT.Desktop.Dialogs.Message.MessageDialog" x:Class="DHT.Desktop.Dialogs.Message.MessageDialog"
x:DataType="namespace:MessageDialogModel"
Title="{Binding Title}" Title="{Binding Title}"
Icon="avares://DiscordHistoryTracker/Resources/icon.ico" Icon="avares://DiscordHistoryTracker/Resources/icon.ico"
Width="500" SizeToContent="Height" CanResize="False" Width="500" SizeToContent="Height" CanResize="False"

View File

@@ -4,4 +4,5 @@ namespace DHT.Desktop.Dialogs.Progress;
interface IProgressCallback { interface IProgressCallback {
Task Update(string message, int finishedItems, int totalItems); Task Update(string message, int finishedItems, int totalItems);
Task Hide();
} }

View File

@@ -5,6 +5,7 @@
xmlns:namespace="clr-namespace:DHT.Desktop.Dialogs.Progress" xmlns:namespace="clr-namespace:DHT.Desktop.Dialogs.Progress"
mc:Ignorable="d" d:DesignWidth="500" mc:Ignorable="d" d:DesignWidth="500"
x:Class="DHT.Desktop.Dialogs.Progress.ProgressDialog" x:Class="DHT.Desktop.Dialogs.Progress.ProgressDialog"
x:DataType="namespace:ProgressDialogModel"
Title="{Binding Title}" Title="{Binding Title}"
Icon="avares://DiscordHistoryTracker/Resources/icon.ico" Icon="avares://DiscordHistoryTracker/Resources/icon.ico"
Opened="OnOpened" Opened="OnOpened"
@@ -31,12 +32,18 @@
</Style> </Style>
</Window.Styles> </Window.Styles>
<StackPanel Margin="20"> <ItemsRepeater ItemsSource="{Binding Items}" Margin="0 10">
<ItemsRepeater.ItemTemplate>
<DataTemplate>
<StackPanel Margin="20 10" IsHitTestVisible="{Binding IsVisible}" Opacity="{Binding Opacity}">
<DockPanel> <DockPanel>
<TextBlock DockPanel.Dock="Right" Text="{Binding Items}" Classes="items" /> <TextBlock DockPanel.Dock="Right" Text="{Binding Items}" Classes="items" />
<TextBlock DockPanel.Dock="Left" Text="{Binding Message}" /> <TextBlock DockPanel.Dock="Left" Text="{Binding Message}" />
</DockPanel> </DockPanel>
<ProgressBar Value="{Binding Progress}" /> <ProgressBar Value="{Binding Progress}" />
</StackPanel> </StackPanel>
</DataTemplate>
</ItemsRepeater.ItemTemplate>
</ItemsRepeater>
</Window> </Window>

View File

@@ -8,6 +8,7 @@ namespace DHT.Desktop.Dialogs.Progress;
[SuppressMessage("ReSharper", "MemberCanBeInternal")] [SuppressMessage("ReSharper", "MemberCanBeInternal")]
public sealed partial class ProgressDialog : Window { public sealed partial class ProgressDialog : Window {
private bool isFinished = false; private bool isFinished = false;
private Task progressTask = Task.CompletedTask;
public ProgressDialog() { public ProgressDialog() {
InitializeComponent(); InitializeComponent();
@@ -15,7 +16,8 @@ public sealed partial class ProgressDialog : Window {
public void OnOpened(object? sender, EventArgs e) { public void OnOpened(object? sender, EventArgs e) {
if (DataContext is ProgressDialogModel model) { if (DataContext is ProgressDialogModel model) {
Task.Run(model.StartTask).ContinueWith(OnFinished, TaskScheduler.FromCurrentSynchronizationContext()); progressTask = Task.Run(model.StartTask);
progressTask.ContinueWith(OnFinished, TaskScheduler.FromCurrentSynchronizationContext());
} }
} }
@@ -27,4 +29,9 @@ public sealed partial class ProgressDialog : Window {
isFinished = true; isFinished = true;
Close(); Close();
} }
public async Task ShowProgressDialog(Window owner) {
await ShowDialog(owner);
await progressTask;
}
} }

View File

@@ -1,4 +1,6 @@
using System; using System;
using System.Collections.Generic;
using System.Linq;
using System.Threading.Tasks; using System.Threading.Tasks;
using Avalonia.Threading; using Avalonia.Threading;
using DHT.Desktop.Common; using DHT.Desktop.Common;
@@ -9,57 +11,43 @@ namespace DHT.Desktop.Dialogs.Progress;
sealed class ProgressDialogModel : BaseModel { sealed class ProgressDialogModel : BaseModel {
public string Title { get; init; } = ""; public string Title { get; init; } = "";
private string message = ""; public IReadOnlyList<ProgressItem> Items { get; } = Array.Empty<ProgressItem>();
public string Message {
get => message;
private set => Change(ref message, value);
}
private string items = "";
public string Items {
get => items;
private set => Change(ref items, value);
}
private int progress = 0;
public int Progress {
get => progress;
private set => Change(ref progress, value);
}
private readonly TaskRunner? task; private readonly TaskRunner? task;
[Obsolete("Designer")] [Obsolete("Designer")]
public ProgressDialogModel() {} public ProgressDialogModel() {}
public ProgressDialogModel(TaskRunner task) { public ProgressDialogModel(TaskRunner task, int progressItems = 1) {
this.Items = Enumerable.Range(0, progressItems).Select(static _ => new ProgressItem()).ToArray();
this.task = task; this.task = task;
} }
internal async Task StartTask() { internal async Task StartTask() {
if (task != null) { if (task != null) {
await task(new Callback(this)); await task(Items.Select(static item => new Callback(item)).ToArray());
} }
} }
public delegate Task TaskRunner(IProgressCallback callback); public delegate Task TaskRunner(IReadOnlyList<IProgressCallback> callbacks);
private sealed class Callback : IProgressCallback { private sealed class Callback : IProgressCallback {
private readonly ProgressDialogModel model; private readonly ProgressItem item;
public Callback(ProgressDialogModel model) { public Callback(ProgressItem item) {
this.model = model; this.item = item;
} }
async Task IProgressCallback.Update(string message, int finishedItems, int totalItems) { public async Task Update(string message, int finishedItems, int totalItems) {
await Dispatcher.UIThread.InvokeAsync(() => { await Dispatcher.UIThread.InvokeAsync(() => {
model.Message = message; item.Message = message;
model.Items = finishedItems.Format() + " / " + totalItems.Format(); item.Items = totalItems == 0 ? string.Empty : finishedItems.Format() + " / " + totalItems.Format();
model.Progress = 100 * finishedItems / totalItems; item.Progress = totalItems == 0 ? 0 : 100 * finishedItems / totalItems;
}); });
} }
public Task Hide() {
return Update(string.Empty, 0, 0);
}
} }
} }

View File

@@ -0,0 +1,41 @@
using DHT.Utils.Models;
namespace DHT.Desktop.Dialogs.Progress;
sealed class ProgressItem : BaseModel {
private bool isVisible = false;
public bool IsVisible {
get => isVisible;
private set {
Change(ref isVisible, value);
OnPropertyChanged(nameof(Opacity));
}
}
public double Opacity => IsVisible ? 1.0 : 0.0;
private string message = "";
public string Message {
get => message;
set {
Change(ref message, value);
IsVisible = !string.IsNullOrEmpty(value);
}
}
private string items = "";
public string Items {
get => items;
set => Change(ref items, value);
}
private int progress = 0;
public int Progress {
get => progress;
set => Change(ref progress, value);
}
}

View File

@@ -5,6 +5,7 @@
xmlns:namespace="clr-namespace:DHT.Desktop.Dialogs.TextBox" xmlns:namespace="clr-namespace:DHT.Desktop.Dialogs.TextBox"
mc:Ignorable="d" d:DesignWidth="500" mc:Ignorable="d" d:DesignWidth="500"
x:Class="DHT.Desktop.Dialogs.TextBox.TextBoxDialog" x:Class="DHT.Desktop.Dialogs.TextBox.TextBoxDialog"
x:DataType="namespace:TextBoxDialogModel"
Title="{Binding Title}" Title="{Binding Title}"
Icon="avares://DiscordHistoryTracker/Resources/icon.ico" Icon="avares://DiscordHistoryTracker/Resources/icon.ico"
Width="500" SizeToContent="Height" CanResize="False" Width="500" SizeToContent="Height" CanResize="False"

View File

@@ -1,9 +1,9 @@
using System; using System;
using System.Collections.Generic;
using System.Diagnostics.CodeAnalysis; using System.Diagnostics.CodeAnalysis;
using System.IO; using System.IO;
using System.Runtime.InteropServices; using System.Runtime.InteropServices;
using System.Text.Json; using System.Text.Json;
using System.Text.Json.Nodes;
using System.Threading.Tasks; using System.Threading.Tasks;
using DHT.Utils.Logging; using DHT.Utils.Logging;
using static System.Environment.SpecialFolder; using static System.Environment.SpecialFolder;
@@ -47,12 +47,12 @@ static class DiscordAppSettings {
} }
} }
private static bool AreDevToolsEnabled(Dictionary<string, object?> json) { private static bool AreDevToolsEnabled(JsonObject json) {
return json.TryGetValue(JsonKeyDevTools, out var value) && value is JsonElement { ValueKind: JsonValueKind.True }; return json.TryGetPropertyValue(JsonKeyDevTools, out var node) && node?.GetValueKind() == JsonValueKind.True;
} }
public static async Task<SettingsJsonResult> ConfigureDevTools(bool enable) { public static async Task<SettingsJsonResult> ConfigureDevTools(bool enable) {
Dictionary<string, object?> json; JsonObject json;
try { try {
json = await ReadSettingsJson(); json = await ReadSettingsJson();
@@ -109,13 +109,13 @@ static class DiscordAppSettings {
return SettingsJsonResult.Success; return SettingsJsonResult.Success;
} }
private static async Task<Dictionary<string, object?>> ReadSettingsJson() { private static async Task<JsonObject> ReadSettingsJson() {
await using var stream = new FileStream(JsonFilePath, FileMode.Open, FileAccess.Read, FileShare.Read); await using var stream = new FileStream(JsonFilePath, FileMode.Open, FileAccess.Read, FileShare.Read);
return await JsonSerializer.DeserializeAsync<Dictionary<string, object?>?>(stream) ?? throw new JsonException(); return await JsonSerializer.DeserializeAsync(stream, DiscordAppSettingsJsonContext.Default.JsonObject) ?? throw new JsonException();
} }
private static async Task WriteSettingsJson(Dictionary<string, object?> json) { private static async Task WriteSettingsJson(JsonObject json) {
await using var stream = new FileStream(JsonFilePath, FileMode.Truncate, FileAccess.Write, FileShare.None); await using var stream = new FileStream(JsonFilePath, FileMode.Truncate, FileAccess.Write, FileShare.None);
await JsonSerializer.SerializeAsync(stream, json, new JsonSerializerOptions { WriteIndented = true }); await JsonSerializer.SerializeAsync(stream, json, DiscordAppSettingsJsonContext.Default.JsonObject);
} }
} }

View File

@@ -0,0 +1,8 @@
using System.Text.Json.Nodes;
using System.Text.Json.Serialization;
namespace DHT.Desktop.Discord;
[JsonSourceGenerationOptions(GenerationMode = JsonSourceGenerationMode.Default, WriteIndented = true)]
[JsonSerializable(typeof(JsonObject))]
sealed partial class DiscordAppSettingsJsonContext : JsonSerializerContext {}

View File

@@ -5,6 +5,7 @@
xmlns:main="clr-namespace:DHT.Desktop.Main" xmlns:main="clr-namespace:DHT.Desktop.Main"
mc:Ignorable="d" d:DesignWidth="480" d:DesignHeight="295" mc:Ignorable="d" d:DesignWidth="480" d:DesignHeight="295"
x:Class="DHT.Desktop.Main.AboutWindow" x:Class="DHT.Desktop.Main.AboutWindow"
x:DataType="main:AboutWindowModel"
Title="About Discord History Tracker" Title="About Discord History Tracker"
Icon="avares://DiscordHistoryTracker/Resources/icon.ico" Icon="avares://DiscordHistoryTracker/Resources/icon.ico"
Width="480" Height="295" CanResize="False" Width="480" Height="295" CanResize="False"

View File

@@ -4,7 +4,8 @@
xmlns:mc="http://schemas.openxmlformats.org/markup-compatibility/2006" xmlns:mc="http://schemas.openxmlformats.org/markup-compatibility/2006"
xmlns:controls="clr-namespace:DHT.Desktop.Main.Controls" xmlns:controls="clr-namespace:DHT.Desktop.Main.Controls"
mc:Ignorable="d" mc:Ignorable="d"
x:Class="DHT.Desktop.Main.Controls.AttachmentFilterPanel"> x:Class="DHT.Desktop.Main.Controls.AttachmentFilterPanel"
x:DataType="controls:AttachmentFilterPanelModel">
<Design.DataContext> <Design.DataContext>
<controls:AttachmentFilterPanelModel /> <controls:AttachmentFilterPanelModel />

View File

@@ -2,6 +2,7 @@ using System;
using System.Collections.Generic; using System.Collections.Generic;
using System.ComponentModel; using System.ComponentModel;
using DHT.Desktop.Common; using DHT.Desktop.Common;
using DHT.Server;
using DHT.Server.Data.Filters; using DHT.Server.Data.Filters;
using DHT.Server.Database; using DHT.Server.Database;
using DHT.Utils.Models; using DHT.Utils.Models;
@@ -47,7 +48,7 @@ sealed class AttachmentFilterPanelModel : BaseModel, IDisposable {
public IEnumerable<Unit> Units => AllUnits; public IEnumerable<Unit> Units => AllUnits;
private readonly IDatabaseFile db; private readonly State state;
private readonly string verb; private readonly string verb;
private readonly AsyncValueComputer<long> matchingAttachmentCountComputer; private readonly AsyncValueComputer<long> matchingAttachmentCountComputer;
@@ -55,10 +56,10 @@ sealed class AttachmentFilterPanelModel : BaseModel, IDisposable {
private long? totalAttachmentCount; private long? totalAttachmentCount;
[Obsolete("Designer")] [Obsolete("Designer")]
public AttachmentFilterPanelModel() : this(DummyDatabaseFile.Instance) {} public AttachmentFilterPanelModel() : this(State.Dummy) {}
public AttachmentFilterPanelModel(IDatabaseFile db, string verb = "Matches") { public AttachmentFilterPanelModel(State state, string verb = "Matches") {
this.db = db; this.state = state;
this.verb = verb; this.verb = verb;
this.matchingAttachmentCountComputer = AsyncValueComputer<long>.WithResultProcessor(SetAttachmentCounts).Build(); this.matchingAttachmentCountComputer = AsyncValueComputer<long>.WithResultProcessor(SetAttachmentCounts).Build();
@@ -66,11 +67,11 @@ sealed class AttachmentFilterPanelModel : BaseModel, IDisposable {
UpdateFilterStatistics(); UpdateFilterStatistics();
PropertyChanged += OnPropertyChanged; PropertyChanged += OnPropertyChanged;
db.Statistics.PropertyChanged += OnDbStatisticsChanged; state.Db.Statistics.PropertyChanged += OnDbStatisticsChanged;
} }
public void Dispose() { public void Dispose() {
db.Statistics.PropertyChanged -= OnDbStatisticsChanged; state.Db.Statistics.PropertyChanged -= OnDbStatisticsChanged;
} }
private void OnPropertyChanged(object? sender, PropertyChangedEventArgs e) { private void OnPropertyChanged(object? sender, PropertyChangedEventArgs e) {
@@ -81,7 +82,7 @@ sealed class AttachmentFilterPanelModel : BaseModel, IDisposable {
private void OnDbStatisticsChanged(object? sender, PropertyChangedEventArgs e) { private void OnDbStatisticsChanged(object? sender, PropertyChangedEventArgs e) {
if (e.PropertyName == nameof(DatabaseStatistics.TotalAttachments)) { if (e.PropertyName == nameof(DatabaseStatistics.TotalAttachments)) {
totalAttachmentCount = db.Statistics.TotalAttachments; totalAttachmentCount = state.Db.Statistics.TotalAttachments;
UpdateFilterStatistics(); UpdateFilterStatistics();
} }
} }
@@ -96,7 +97,7 @@ sealed class AttachmentFilterPanelModel : BaseModel, IDisposable {
else { else {
matchingAttachmentCount = null; matchingAttachmentCount = null;
UpdateFilterStatisticsText(); UpdateFilterStatisticsText();
matchingAttachmentCountComputer.Compute(() => db.CountAttachments(filter)); matchingAttachmentCountComputer.Compute(() => state.Db.CountAttachments(filter));
} }
} }

View File

@@ -4,7 +4,8 @@
xmlns:mc="http://schemas.openxmlformats.org/markup-compatibility/2006" xmlns:mc="http://schemas.openxmlformats.org/markup-compatibility/2006"
xmlns:controls="clr-namespace:DHT.Desktop.Main.Controls" xmlns:controls="clr-namespace:DHT.Desktop.Main.Controls"
mc:Ignorable="d" mc:Ignorable="d"
x:Class="DHT.Desktop.Main.Controls.MessageFilterPanel"> x:Class="DHT.Desktop.Main.Controls.MessageFilterPanel"
x:DataType="controls:MessageFilterPanelModel">
<Design.DataContext> <Design.DataContext>
<controls:MessageFilterPanelModel /> <controls:MessageFilterPanelModel />

View File

@@ -8,6 +8,7 @@ using Avalonia.Controls;
using DHT.Desktop.Common; using DHT.Desktop.Common;
using DHT.Desktop.Dialogs.CheckBox; using DHT.Desktop.Dialogs.CheckBox;
using DHT.Desktop.Dialogs.Message; using DHT.Desktop.Dialogs.Message;
using DHT.Server;
using DHT.Server.Data; using DHT.Server.Data;
using DHT.Server.Data.Filters; using DHT.Server.Data.Filters;
using DHT.Server.Database; using DHT.Server.Database;
@@ -62,7 +63,7 @@ sealed class MessageFilterPanelModel : BaseModel, IDisposable {
} }
public HashSet<ulong> IncludedChannels { public HashSet<ulong> IncludedChannels {
get => includedChannels ?? db.GetAllChannels().Select(static channel => channel.Id).ToHashSet(); get => includedChannels ?? state.Db.GetAllChannels().Select(static channel => channel.Id).ToHashSet();
set => Change(ref includedChannels, value); set => Change(ref includedChannels, value);
} }
@@ -72,7 +73,7 @@ sealed class MessageFilterPanelModel : BaseModel, IDisposable {
} }
public HashSet<ulong> IncludedUsers { public HashSet<ulong> IncludedUsers {
get => includedUsers ?? db.GetAllUsers().Select(static user => user.Id).ToHashSet(); get => includedUsers ?? state.Db.GetAllUsers().Select(static user => user.Id).ToHashSet();
set => Change(ref includedUsers, value); set => Change(ref includedUsers, value);
} }
@@ -91,7 +92,7 @@ sealed class MessageFilterPanelModel : BaseModel, IDisposable {
} }
private readonly Window window; private readonly Window window;
private readonly IDatabaseFile db; private readonly State state;
private readonly string verb; private readonly string verb;
private readonly AsyncValueComputer<long> exportedMessageCountComputer; private readonly AsyncValueComputer<long> exportedMessageCountComputer;
@@ -99,11 +100,11 @@ sealed class MessageFilterPanelModel : BaseModel, IDisposable {
private long? totalMessageCount; private long? totalMessageCount;
[Obsolete("Designer")] [Obsolete("Designer")]
public MessageFilterPanelModel() : this(null!, DummyDatabaseFile.Instance) {} public MessageFilterPanelModel() : this(null!, State.Dummy) {}
public MessageFilterPanelModel(Window window, IDatabaseFile db, string verb = "Matches") { public MessageFilterPanelModel(Window window, State state, string verb = "Matches") {
this.window = window; this.window = window;
this.db = db; this.state = state;
this.verb = verb; this.verb = verb;
this.exportedMessageCountComputer = AsyncValueComputer<long>.WithResultProcessor(SetExportedMessageCount).Build(); this.exportedMessageCountComputer = AsyncValueComputer<long>.WithResultProcessor(SetExportedMessageCount).Build();
@@ -113,11 +114,11 @@ sealed class MessageFilterPanelModel : BaseModel, IDisposable {
UpdateUserFilterLabel(); UpdateUserFilterLabel();
PropertyChanged += OnPropertyChanged; PropertyChanged += OnPropertyChanged;
db.Statistics.PropertyChanged += OnDbStatisticsChanged; state.Db.Statistics.PropertyChanged += OnDbStatisticsChanged;
} }
public void Dispose() { public void Dispose() {
db.Statistics.PropertyChanged -= OnDbStatisticsChanged; state.Db.Statistics.PropertyChanged -= OnDbStatisticsChanged;
} }
private void OnPropertyChanged(object? sender, PropertyChangedEventArgs e) { private void OnPropertyChanged(object? sender, PropertyChangedEventArgs e) {
@@ -136,7 +137,7 @@ sealed class MessageFilterPanelModel : BaseModel, IDisposable {
private void OnDbStatisticsChanged(object? sender, PropertyChangedEventArgs e) { private void OnDbStatisticsChanged(object? sender, PropertyChangedEventArgs e) {
if (e.PropertyName == nameof(DatabaseStatistics.TotalMessages)) { if (e.PropertyName == nameof(DatabaseStatistics.TotalMessages)) {
totalMessageCount = db.Statistics.TotalMessages; totalMessageCount = state.Db.Statistics.TotalMessages;
UpdateFilterStatistics(); UpdateFilterStatistics();
} }
else if (e.PropertyName == nameof(DatabaseStatistics.TotalChannels)) { else if (e.PropertyName == nameof(DatabaseStatistics.TotalChannels)) {
@@ -157,7 +158,7 @@ sealed class MessageFilterPanelModel : BaseModel, IDisposable {
else { else {
exportedMessageCount = null; exportedMessageCount = null;
UpdateFilterStatisticsText(); UpdateFilterStatisticsText();
exportedMessageCountComputer.Compute(() => db.CountMessages(filter)); exportedMessageCountComputer.Compute(() => state.Db.CountMessages(filter));
} }
} }
@@ -175,11 +176,11 @@ sealed class MessageFilterPanelModel : BaseModel, IDisposable {
} }
public async void OpenChannelFilterDialog() { public async void OpenChannelFilterDialog() {
var servers = db.GetAllServers().ToDictionary(static server => server.Id); var servers = state.Db.GetAllServers().ToDictionary(static server => server.Id);
var items = new List<CheckBoxItem<ulong>>(); var items = new List<CheckBoxItem<ulong>>();
var included = IncludedChannels; var included = IncludedChannels;
foreach (var channel in db.GetAllChannels()) { foreach (var channel in state.Db.GetAllChannels()) {
var channelId = channel.Id; var channelId = channel.Id;
var channelName = channel.Name; var channelName = channel.Name;
@@ -223,7 +224,7 @@ sealed class MessageFilterPanelModel : BaseModel, IDisposable {
var items = new List<CheckBoxItem<ulong>>(); var items = new List<CheckBoxItem<ulong>>();
var included = IncludedUsers; var included = IncludedUsers;
foreach (var user in db.GetAllUsers()) { foreach (var user in state.Db.GetAllUsers()) {
var name = user.Name; var name = user.Name;
var discriminator = user.Discriminator; var discriminator = user.Discriminator;
@@ -240,13 +241,13 @@ sealed class MessageFilterPanelModel : BaseModel, IDisposable {
} }
private void UpdateChannelFilterLabel() { private void UpdateChannelFilterLabel() {
long total = db.Statistics.TotalChannels; long total = state.Db.Statistics.TotalChannels;
long included = FilterByChannel ? IncludedChannels.Count : total; long included = FilterByChannel ? IncludedChannels.Count : total;
ChannelFilterLabel = "Selected " + included.Format() + " / " + total.Pluralize("channel") + "."; ChannelFilterLabel = "Selected " + included.Format() + " / " + total.Pluralize("channel") + ".";
} }
private void UpdateUserFilterLabel() { private void UpdateUserFilterLabel() {
long total = db.Statistics.TotalUsers; long total = state.Db.Statistics.TotalUsers;
long included = FilterByUser ? IncludedUsers.Count : total; long included = FilterByUser ? IncludedUsers.Count : total;
UserFilterLabel = "Selected " + included.Format() + " / " + total.Pluralize("user") + "."; UserFilterLabel = "Selected " + included.Format() + " / " + total.Pluralize("user") + ".";
} }

View File

@@ -4,7 +4,8 @@
xmlns:mc="http://schemas.openxmlformats.org/markup-compatibility/2006" xmlns:mc="http://schemas.openxmlformats.org/markup-compatibility/2006"
xmlns:controls="clr-namespace:DHT.Desktop.Main.Controls" xmlns:controls="clr-namespace:DHT.Desktop.Main.Controls"
mc:Ignorable="d" mc:Ignorable="d"
x:Class="DHT.Desktop.Main.Controls.ServerConfigurationPanel"> x:Class="DHT.Desktop.Main.Controls.ServerConfigurationPanel"
x:DataType="controls:ServerConfigurationPanelModel">
<Design.DataContext> <Design.DataContext>
<controls:ServerConfigurationPanelModel /> <controls:ServerConfigurationPanelModel />

View File

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

View File

@@ -4,7 +4,8 @@
xmlns:mc="http://schemas.openxmlformats.org/markup-compatibility/2006" xmlns:mc="http://schemas.openxmlformats.org/markup-compatibility/2006"
xmlns:controls="clr-namespace:DHT.Desktop.Main.Controls" xmlns:controls="clr-namespace:DHT.Desktop.Main.Controls"
mc:Ignorable="d" mc:Ignorable="d"
x:Class="DHT.Desktop.Main.Controls.StatusBar"> x:Class="DHT.Desktop.Main.Controls.StatusBar"
x:DataType="controls:StatusBarModel">
<Design.DataContext> <Design.DataContext>
<controls:StatusBarModel /> <controls:StatusBarModel />
@@ -39,22 +40,22 @@
<StackPanel Orientation="Horizontal" Margin="6 3"> <StackPanel Orientation="Horizontal" Margin="6 3">
<StackPanel Orientation="Vertical" Width="65"> <StackPanel Orientation="Vertical" Width="65">
<TextBlock Classes="label">Status</TextBlock> <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> </StackPanel>
<Rectangle /> <Rectangle />
<StackPanel Orientation="Vertical"> <StackPanel Orientation="Vertical">
<TextBlock Classes="label">Servers</TextBlock> <TextBlock Classes="label">Servers</TextBlock>
<TextBlock Classes="value" Text="{Binding DatabaseStatistics.TotalServers, Converter={StaticResource NumberValueConverter}}" /> <TextBlock Classes="value" Text="{Binding DatabaseStatistics.TotalServers, Mode=OneWay, Converter={StaticResource NumberValueConverter}}" />
</StackPanel> </StackPanel>
<Rectangle /> <Rectangle />
<StackPanel Orientation="Vertical"> <StackPanel Orientation="Vertical">
<TextBlock Classes="label">Channels</TextBlock> <TextBlock Classes="label">Channels</TextBlock>
<TextBlock Classes="value" Text="{Binding DatabaseStatistics.TotalChannels, Converter={StaticResource NumberValueConverter}}" /> <TextBlock Classes="value" Text="{Binding DatabaseStatistics.TotalChannels, Mode=OneWay, Converter={StaticResource NumberValueConverter}}" />
</StackPanel> </StackPanel>
<Rectangle /> <Rectangle />
<StackPanel Orientation="Vertical"> <StackPanel Orientation="Vertical">
<TextBlock Classes="label">Messages</TextBlock> <TextBlock Classes="label">Messages</TextBlock>
<TextBlock Classes="value" Text="{Binding DatabaseStatistics.TotalMessages, Converter={StaticResource NumberValueConverter}}" /> <TextBlock Classes="value" Text="{Binding DatabaseStatistics.TotalMessages, Mode=OneWay, Converter={StaticResource NumberValueConverter}}" />
</StackPanel> </StackPanel>
</StackPanel> </StackPanel>

View File

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

View File

@@ -5,12 +5,13 @@
xmlns:main="clr-namespace:DHT.Desktop.Main" xmlns:main="clr-namespace:DHT.Desktop.Main"
mc:Ignorable="d" d:DesignWidth="800" d:DesignHeight="450" mc:Ignorable="d" d:DesignWidth="800" d:DesignHeight="450"
x:Class="DHT.Desktop.Main.MainWindow" x:Class="DHT.Desktop.Main.MainWindow"
x:DataType="main:MainWindowModel"
Title="{Binding Title}" Title="{Binding Title}"
Icon="avares://DiscordHistoryTracker/Resources/icon.ico" Icon="avares://DiscordHistoryTracker/Resources/icon.ico"
Width="800" Height="500" Width="800" Height="500"
MinWidth="520" MinHeight="300" MinWidth="520" MinHeight="300"
WindowStartupLocation="CenterScreen" WindowStartupLocation="CenterScreen"
Closed="OnClosed"> Closing="OnClosing">
<Design.DataContext> <Design.DataContext>
<main:MainWindowModel /> <main:MainWindowModel />

View File

@@ -1,14 +1,18 @@
using System; using System;
using System.Diagnostics.CodeAnalysis; using System.Diagnostics.CodeAnalysis;
using System.IO; using System.IO;
using System.Threading.Tasks;
using Avalonia.Controls; using Avalonia.Controls;
using DHT.Desktop.Main.Pages; using DHT.Desktop.Main.Pages;
using DHT.Utils.Logging;
using JetBrains.Annotations; using JetBrains.Annotations;
namespace DHT.Desktop.Main; namespace DHT.Desktop.Main;
[SuppressMessage("ReSharper", "MemberCanBeInternal")] [SuppressMessage("ReSharper", "MemberCanBeInternal")]
public sealed partial class MainWindow : Window { public sealed partial class MainWindow : Window {
private static readonly Log Log = Log.ForType<MainWindow>();
[UsedImplicitly] [UsedImplicitly]
public MainWindow() { public MainWindow() {
InitializeComponent(); InitializeComponent();
@@ -20,9 +24,24 @@ public sealed partial class MainWindow : Window {
DataContext = new MainWindowModel(this, args); DataContext = new MainWindowModel(this, args);
} }
public void OnClosed(object? sender, EventArgs e) { public async void OnClosing(object? sender, WindowClosingEventArgs e) {
if (DataContext is IDisposable disposable) { e.Cancel = true;
disposable.Dispose(); Closing -= OnClosing;
try {
await Dispose();
} finally {
Close();
}
}
private async Task Dispose() {
if (DataContext is MainWindowModel model) {
try {
await model.DisposeAsync();
} catch (Exception ex) {
Log.Error("Caught exception while disposing window: " + ex);
}
} }
foreach (var temporaryFile in ViewerPageModel.TemporaryFiles) { foreach (var temporaryFile in ViewerPageModel.TemporaryFiles) {

View File

@@ -7,14 +7,17 @@ using Avalonia.Controls;
using DHT.Desktop.Dialogs.Message; using DHT.Desktop.Dialogs.Message;
using DHT.Desktop.Main.Screens; using DHT.Desktop.Main.Screens;
using DHT.Desktop.Server; using DHT.Desktop.Server;
using DHT.Server.Database; using DHT.Server;
using DHT.Utils.Logging;
using DHT.Utils.Models; using DHT.Utils.Models;
namespace DHT.Desktop.Main; namespace DHT.Desktop.Main;
sealed class MainWindowModel : BaseModel, IDisposable { sealed class MainWindowModel : BaseModel, IAsyncDisposable {
private const string DefaultTitle = "Discord History Tracker"; private const string DefaultTitle = "Discord History Tracker";
private static readonly Log Log = Log.ForType<MainWindowModel>();
public string Title { get; private set; } = DefaultTitle; public string Title { get; private set; } = DefaultTitle;
public UserControl CurrentScreen { get; private set; } public UserControl CurrentScreen { get; private set; }
@@ -27,7 +30,7 @@ sealed class MainWindowModel : BaseModel, IDisposable {
private readonly Window window; private readonly Window window;
private IDatabaseFile? db; private State? state;
[Obsolete("Designer")] [Obsolete("Designer")]
public MainWindowModel() : this(null!, Arguments.Empty) {} public MainWindowModel() : this(null!, Arguments.Empty) {}
@@ -63,11 +66,11 @@ sealed class MainWindowModel : BaseModel, IDisposable {
} }
if (args.ServerPort != null) { if (args.ServerPort != null) {
ServerManager.Port = args.ServerPort.Value; ServerConfiguration.Port = args.ServerPort.Value;
} }
if (args.ServerToken != null) { if (args.ServerToken != null) {
ServerManager.Token = args.ServerToken; ServerConfiguration.Token = args.ServerToken;
} }
} }
@@ -78,18 +81,29 @@ sealed class MainWindowModel : BaseModel, IDisposable {
mainContentScreenModel.Dispose(); mainContentScreenModel.Dispose();
} }
db?.Dispose(); if (state != null) {
db = welcomeScreenModel.Db; await state.DisposeAsync();
}
if (db == null) { if (welcomeScreenModel.Db == null) {
state = null;
Title = DefaultTitle; Title = DefaultTitle;
mainContentScreenModel = null; mainContentScreenModel = null;
mainContentScreen = null; mainContentScreen = null;
CurrentScreen = welcomeScreen; CurrentScreen = welcomeScreen;
} }
else { else {
Title = Path.GetFileName(db.Path) + " - " + DefaultTitle; state = new State(welcomeScreenModel.Db);
mainContentScreenModel = new MainContentScreenModel(window, 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(); await mainContentScreenModel.Initialize();
mainContentScreenModel.DatabaseClosed += MainContentScreenModelOnDatabaseClosed; mainContentScreenModel.DatabaseClosed += MainContentScreenModelOnDatabaseClosed;
mainContentScreen = new MainContentScreen { DataContext = mainContentScreenModel }; mainContentScreen = new MainContentScreen { DataContext = mainContentScreenModel };
@@ -107,10 +121,14 @@ sealed class MainWindowModel : BaseModel, IDisposable {
welcomeScreenModel.CloseDatabase(); welcomeScreenModel.CloseDatabase();
} }
public void Dispose() { public async ValueTask DisposeAsync() {
welcomeScreenModel.Dispose();
mainContentScreenModel?.Dispose(); mainContentScreenModel?.Dispose();
db?.Dispose();
db = null; if (state != null) {
await state.DisposeAsync();
state = null;
}
welcomeScreenModel.Dispose();
} }
} }

View File

@@ -5,7 +5,8 @@
xmlns:pages="clr-namespace:DHT.Desktop.Main.Pages" xmlns:pages="clr-namespace:DHT.Desktop.Main.Pages"
xmlns:controls="clr-namespace:DHT.Desktop.Main.Controls" xmlns:controls="clr-namespace:DHT.Desktop.Main.Controls"
mc:Ignorable="d" d:DesignWidth="800" d:DesignHeight="450" mc:Ignorable="d" d:DesignWidth="800" d:DesignHeight="450"
x:Class="DHT.Desktop.Main.Pages.AdvancedPage"> x:Class="DHT.Desktop.Main.Pages.AdvancedPage"
x:DataType="pages:AdvancedPageModel">
<Design.DataContext> <Design.DataContext>
<pages:AdvancedPageModel /> <pages:AdvancedPageModel />

View File

@@ -2,8 +2,7 @@ using System;
using Avalonia.Controls; using Avalonia.Controls;
using DHT.Desktop.Dialogs.Message; using DHT.Desktop.Dialogs.Message;
using DHT.Desktop.Main.Controls; using DHT.Desktop.Main.Controls;
using DHT.Desktop.Server; using DHT.Server;
using DHT.Server.Database;
using DHT.Utils.Models; using DHT.Utils.Models;
namespace DHT.Desktop.Main.Pages; namespace DHT.Desktop.Main.Pages;
@@ -12,20 +11,16 @@ sealed class AdvancedPageModel : BaseModel, IDisposable {
public ServerConfigurationPanelModel ServerConfigurationModel { get; } public ServerConfigurationPanelModel ServerConfigurationModel { get; }
private readonly Window window; private readonly Window window;
private readonly IDatabaseFile db; private readonly State state;
[Obsolete("Designer")] [Obsolete("Designer")]
public AdvancedPageModel() : this(null!, DummyDatabaseFile.Instance, new ServerManager(DummyDatabaseFile.Instance)) {} public AdvancedPageModel() : this(null!, State.Dummy) {}
public AdvancedPageModel(Window window, IDatabaseFile db, ServerManager serverManager) { public AdvancedPageModel(Window window, State state) {
this.window = window; this.window = window;
this.db = db; this.state = state;
ServerConfigurationModel = new ServerConfigurationPanelModel(window, serverManager); ServerConfigurationModel = new ServerConfigurationPanelModel(window, state);
}
public void Initialize() {
ServerConfigurationModel.Initialize();
} }
public void Dispose() { public void Dispose() {
@@ -33,7 +28,7 @@ sealed class AdvancedPageModel : BaseModel, IDisposable {
} }
public async void VacuumDatabase() { public async void VacuumDatabase() {
db.Vacuum(); state.Db.Vacuum();
await Dialog.ShowOk(window, "Vacuum Database", "Done."); await Dialog.ShowOk(window, "Vacuum Database", "Done.");
} }
} }

View File

@@ -5,7 +5,8 @@
xmlns:pages="clr-namespace:DHT.Desktop.Main.Pages" xmlns:pages="clr-namespace:DHT.Desktop.Main.Pages"
xmlns:controls="clr-namespace:DHT.Desktop.Main.Controls" xmlns:controls="clr-namespace:DHT.Desktop.Main.Controls"
mc:Ignorable="d" d:DesignWidth="800" d:DesignHeight="450" mc:Ignorable="d" d:DesignWidth="800" d:DesignHeight="450"
x:Class="DHT.Desktop.Main.Pages.AttachmentsPage"> x:Class="DHT.Desktop.Main.Pages.AttachmentsPage"
x:DataType="pages:AttachmentsPageModel">
<Design.DataContext> <Design.DataContext>
<pages:AttachmentsPageModel /> <pages:AttachmentsPageModel />
@@ -32,17 +33,17 @@
<StackPanel Orientation="Vertical" Spacing="20"> <StackPanel Orientation="Vertical" Spacing="20">
<DockPanel> <DockPanel>
<Button Command="{Binding OnClickToggleDownload}" Content="{Binding ToggleDownloadButtonText}" IsEnabled="{Binding IsToggleDownloadButtonEnabled}" DockPanel.Dock="Left" /> <Button Command="{Binding OnClickToggleDownload}" Content="{Binding ToggleDownloadButtonText}" IsEnabled="{Binding IsToggleDownloadButtonEnabled}" DockPanel.Dock="Left" />
<TextBlock Text="{Binding DownloadMessage}" Margin="10 0 0 0" VerticalAlignment="Center" DockPanel.Dock="Left" /> <TextBlock Text="{Binding DownloadMessage}" MinWidth="100" Margin="10 0 0 0" VerticalAlignment="Center" TextAlignment="Right" DockPanel.Dock="Left" />
<ProgressBar Value="{Binding DownloadProgress}" IsVisible="{Binding IsDownloading}" Margin="15 0" VerticalAlignment="Center" DockPanel.Dock="Right" /> <ProgressBar Value="{Binding DownloadProgress}" IsVisible="{Binding IsDownloading}" Margin="15 0" VerticalAlignment="Center" DockPanel.Dock="Right" />
</DockPanel> </DockPanel>
<controls:AttachmentFilterPanel DataContext="{Binding FilterModel}" IsEnabled="{Binding !DataContext.IsDownloading, RelativeSource={RelativeSource AncestorType=UserControl}}" /> <controls:AttachmentFilterPanel DataContext="{Binding FilterModel}" IsEnabled="{Binding !IsDownloading, RelativeSource={RelativeSource AncestorType=pages:AttachmentsPageModel}}" />
<StackPanel Orientation="Vertical" Spacing="12"> <StackPanel Orientation="Vertical" Spacing="12">
<Expander Header="Download Status" IsExpanded="True"> <Expander Header="Download Status" IsExpanded="True">
<DataGrid ItemsSource="{Binding StatisticsRows}" AutoGenerateColumns="False" CanUserReorderColumns="False" CanUserResizeColumns="False" CanUserSortColumns="False" IsReadOnly="True"> <DataGrid ItemsSource="{Binding StatisticsRows}" AutoGenerateColumns="False" CanUserReorderColumns="False" CanUserResizeColumns="False" CanUserSortColumns="False" IsReadOnly="True">
<DataGrid.Columns> <DataGrid.Columns>
<DataGridTextColumn Header="State" Binding="{Binding State}" Width="*" /> <DataGridTextColumn Header="State" Binding="{Binding State}" Width="*" />
<DataGridTextColumn Header="Attachments" Binding="{Binding Items, Converter={StaticResource NumberValueConverter}}" Width="*" CellStyleClasses="right" /> <DataGridTextColumn Header="Attachments" Binding="{Binding Items, Mode=OneWay, Converter={StaticResource NumberValueConverter}}" Width="*" CellStyleClasses="right" />
<DataGridTextColumn Header="Size" Binding="{Binding Size, Converter={StaticResource BytesValueConverter}}" Width="*" CellStyleClasses="right" /> <DataGridTextColumn Header="Size" Binding="{Binding Size, Mode=OneWay, Converter={StaticResource BytesValueConverter}}" Width="*" CellStyleClasses="right" />
</DataGrid.Columns> </DataGrid.Columns>
</DataGrid> </DataGrid>
</Expander> </Expander>

View File

@@ -1,30 +1,32 @@
using System; using System;
using System.Collections.Generic; using System.Collections.Generic;
using System.ComponentModel; using System.ComponentModel;
using System.Threading; using System.Reactive.Linq;
using Avalonia.Threading; using System.Threading.Tasks;
using Avalonia.ReactiveUI;
using DHT.Desktop.Common; using DHT.Desktop.Common;
using DHT.Desktop.Main.Controls; using DHT.Desktop.Main.Controls;
using DHT.Server;
using DHT.Server.Data; using DHT.Server.Data;
using DHT.Server.Data.Aggregations; using DHT.Server.Data.Aggregations;
using DHT.Server.Data.Filters; using DHT.Server.Data.Filters;
using DHT.Server.Database; using DHT.Server.Database;
using DHT.Server.Download;
using DHT.Utils.Models; using DHT.Utils.Models;
using DHT.Utils.Tasks; using DHT.Utils.Tasks;
namespace DHT.Desktop.Main.Pages; namespace DHT.Desktop.Main.Pages;
sealed class AttachmentsPageModel : BaseModel, IDisposable { sealed class AttachmentsPageModel : BaseModel, IDisposable {
private static readonly DownloadItemFilter EnqueuedItemFilter = new() { private static readonly DownloadItemFilter EnqueuedItemFilter = new () {
IncludeStatuses = new HashSet<DownloadStatus> { IncludeStatuses = new HashSet<DownloadStatus> {
DownloadStatus.Enqueued DownloadStatus.Enqueued,
DownloadStatus.Downloading
} }
}; };
private bool isThreadDownloadButtonEnabled = true; private bool isThreadDownloadButtonEnabled = true;
public string ToggleDownloadButtonText => downloadThread == null ? "Start Downloading" : "Stop Downloading"; public string ToggleDownloadButtonText => IsDownloading ? "Stop Downloading" : "Start Downloading";
public bool IsToggleDownloadButtonEnabled { public bool IsToggleDownloadButtonEnabled {
get => isThreadDownloadButtonEnabled; get => isThreadDownloadButtonEnabled;
@@ -32,7 +34,7 @@ sealed class AttachmentsPageModel : BaseModel, IDisposable {
} }
public string DownloadMessage { get; set; } = ""; public string DownloadMessage { get; set; } = "";
public double DownloadProgress => allItemsCount is null or 0 ? 0.0 : 100.0 * doneItemsCount / allItemsCount.Value; public double DownloadProgress => totalItemsToDownloadCount is null or 0 ? 0.0 : 100.0 * doneItemsCount / totalItemsToDownloadCount.Value;
public AttachmentFilterPanelModel FilterModel { get; } public AttachmentFilterPanelModel FilterModel { get; }
@@ -52,33 +54,34 @@ sealed class AttachmentsPageModel : BaseModel, IDisposable {
} }
} }
public bool IsDownloading => downloadThread != null; public bool IsDownloading => state.Downloader.IsDownloading;
public bool HasFailedDownloads => statisticsFailed.Items > 0; public bool HasFailedDownloads => statisticsFailed.Items > 0;
private readonly IDatabaseFile db; private readonly State state;
private readonly AsyncValueComputer<DownloadStatusStatistics>.Single downloadStatisticsComputer; private readonly AsyncValueComputer<DownloadStatusStatistics>.Single downloadStatisticsComputer;
private BackgroundDownloadThread? downloadThread;
private IDisposable? finishedItemsSubscription;
private int doneItemsCount; private int doneItemsCount;
private int? allItemsCount; private int initialFinishedCount;
private int? totalItemsToDownloadCount;
public AttachmentsPageModel() : this(DummyDatabaseFile.Instance) {} public AttachmentsPageModel() : this(State.Dummy) {}
public AttachmentsPageModel(IDatabaseFile db) { public AttachmentsPageModel(State state) {
this.db = db; this.state = state;
this.FilterModel = new AttachmentFilterPanelModel(db);
this.downloadStatisticsComputer = AsyncValueComputer<DownloadStatusStatistics>.WithResultProcessor(UpdateStatistics).WithOutdatedResults().BuildWithComputer(db.GetDownloadStatusStatistics); FilterModel = new AttachmentFilterPanelModel(state);
this.downloadStatisticsComputer.Recompute();
db.Statistics.PropertyChanged += OnDbStatisticsChanged; downloadStatisticsComputer = AsyncValueComputer<DownloadStatusStatistics>.WithResultProcessor(UpdateStatistics).WithOutdatedResults().BuildWithComputer(state.Db.GetDownloadStatusStatistics);
downloadStatisticsComputer.Recompute();
state.Db.Statistics.PropertyChanged += OnDbStatisticsChanged;
} }
public void Dispose() { public void Dispose() {
db.Statistics.PropertyChanged -= OnDbStatisticsChanged; state.Db.Statistics.PropertyChanged -= OnDbStatisticsChanged;
finishedItemsSubscription?.Dispose();
FilterModel.Dispose(); FilterModel.Dispose();
DisposeDownloadThread();
} }
private void OnDbStatisticsChanged(object? sender, PropertyChangedEventArgs e) { private void OnDbStatisticsChanged(object? sender, PropertyChangedEventArgs e) {
@@ -98,7 +101,7 @@ sealed class AttachmentsPageModel : BaseModel, IDisposable {
private void EnqueueDownloadItems() { private void EnqueueDownloadItems() {
var filter = FilterModel.CreateFilter(); var filter = FilterModel.CreateFilter();
filter.DownloadItemRule = AttachmentFilter.DownloadItemRules.OnlyNotPresent; filter.DownloadItemRule = AttachmentFilter.DownloadItemRules.OnlyNotPresent;
db.EnqueueDownloadItems(filter); state.Db.EnqueueDownloadItems(filter);
downloadStatisticsComputer.Recompute(); downloadStatisticsComputer.Recompute();
} }
@@ -124,75 +127,76 @@ sealed class AttachmentsPageModel : BaseModel, IDisposable {
OnPropertyChanged(nameof(HasFailedDownloads)); OnPropertyChanged(nameof(HasFailedDownloads));
} }
allItemsCount = doneItemsCount + statisticsEnqueued.Items; totalItemsToDownloadCount = statisticsEnqueued.Items + statisticsDownloaded.Items + statisticsFailed.Items - initialFinishedCount;
UpdateDownloadMessage(); UpdateDownloadMessage();
} }
private void UpdateDownloadMessage() { private void UpdateDownloadMessage() {
DownloadMessage = IsDownloading ? doneItemsCount.Format() + " / " + (allItemsCount?.Format() ?? "?") : ""; DownloadMessage = IsDownloading ? doneItemsCount.Format() + " / " + (totalItemsToDownloadCount?.Format() ?? "?") : "";
OnPropertyChanged(nameof(DownloadMessage)); OnPropertyChanged(nameof(DownloadMessage));
OnPropertyChanged(nameof(DownloadProgress)); OnPropertyChanged(nameof(DownloadProgress));
} }
private void DownloadThreadOnOnItemFinished(object? sender, DownloadItem e) { private void OnItemsFinished(int finishedItemCount) {
Interlocked.Increment(ref doneItemsCount); doneItemsCount += finishedItemCount;
UpdateDownloadMessage();
Dispatcher.UIThread.Invoke(UpdateDownloadMessage);
downloadStatisticsComputer.Recompute(); downloadStatisticsComputer.Recompute();
} }
private void DownloadThreadOnOnServerStopped(object? sender, EventArgs e) { public async Task OnClickToggleDownload() {
downloadStatisticsComputer.Recompute();
IsToggleDownloadButtonEnabled = true;
}
public void OnClickToggleDownload() {
if (downloadThread == null) {
EnqueueDownloadItems();
downloadThread = new BackgroundDownloadThread(db);
downloadThread.OnItemFinished += DownloadThreadOnOnItemFinished;
downloadThread.OnServerStopped += DownloadThreadOnOnServerStopped;
}
else {
IsToggleDownloadButtonEnabled = false; IsToggleDownloadButtonEnabled = false;
DisposeDownloadThread();
db.RemoveDownloadItems(EnqueuedItemFilter, FilterRemovalMode.RemoveMatching); if (IsDownloading) {
await state.Downloader.Stop();
finishedItemsSubscription?.Dispose();
finishedItemsSubscription = null;
downloadStatisticsComputer.Recompute();
state.Db.RemoveDownloadItems(EnqueuedItemFilter, FilterRemovalMode.RemoveMatching);
doneItemsCount = 0; doneItemsCount = 0;
allItemsCount = null; initialFinishedCount = 0;
totalItemsToDownloadCount = null;
UpdateDownloadMessage(); UpdateDownloadMessage();
} }
else {
var finishedItems = await state.Downloader.Start();
initialFinishedCount = statisticsDownloaded.Items + statisticsFailed.Items;
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(ToggleDownloadButtonText));
OnPropertyChanged(nameof(IsDownloading)); OnPropertyChanged(nameof(IsDownloading));
IsToggleDownloadButtonEnabled = true;
} }
public void OnClickRetryFailedDownloads() { public void OnClickRetryFailedDownloads() {
var allExceptFailedFilter = new DownloadItemFilter { var allExceptFailedFilter = new DownloadItemFilter {
IncludeStatuses = new HashSet<DownloadStatus> { IncludeStatuses = new HashSet<DownloadStatus> {
DownloadStatus.Enqueued, DownloadStatus.Enqueued,
DownloadStatus.Downloading,
DownloadStatus.Success DownloadStatus.Success
} }
}; };
db.RemoveDownloadItems(allExceptFailedFilter, FilterRemovalMode.KeepMatching); state.Db.RemoveDownloadItems(allExceptFailedFilter, FilterRemovalMode.KeepMatching);
if (IsDownloading) { if (IsDownloading) {
EnqueueDownloadItems(); EnqueueDownloadItems();
} }
} }
private void DisposeDownloadThread() {
if (downloadThread != null) {
downloadThread.OnItemFinished -= DownloadThreadOnOnItemFinished;
downloadThread.StopThread();
}
downloadThread = null;
}
public sealed class StatisticsRow { public sealed class StatisticsRow {
public string State { get; } public string State { get; }
public int Items { get; set; } public int Items { get; set; }

View File

@@ -4,7 +4,8 @@
xmlns:mc="http://schemas.openxmlformats.org/markup-compatibility/2006" xmlns:mc="http://schemas.openxmlformats.org/markup-compatibility/2006"
xmlns:pages="clr-namespace:DHT.Desktop.Main.Pages" xmlns:pages="clr-namespace:DHT.Desktop.Main.Pages"
mc:Ignorable="d" d:DesignWidth="800" d:DesignHeight="450" mc:Ignorable="d" d:DesignWidth="800" d:DesignHeight="450"
x:Class="DHT.Desktop.Main.Pages.DatabasePage"> x:Class="DHT.Desktop.Main.Pages.DatabasePage"
x:DataType="pages:DatabasePageModel">
<Design.DataContext> <Design.DataContext>
<pages:DatabasePageModel /> <pages:DatabasePageModel />

View File

@@ -14,9 +14,11 @@ using DHT.Desktop.Dialogs.File;
using DHT.Desktop.Dialogs.Message; using DHT.Desktop.Dialogs.Message;
using DHT.Desktop.Dialogs.Progress; using DHT.Desktop.Dialogs.Progress;
using DHT.Desktop.Dialogs.TextBox; using DHT.Desktop.Dialogs.TextBox;
using DHT.Server;
using DHT.Server.Data; using DHT.Server.Data;
using DHT.Server.Database; using DHT.Server.Database;
using DHT.Server.Database.Import; using DHT.Server.Database.Import;
using DHT.Server.Database.Sqlite;
using DHT.Utils.Logging; using DHT.Utils.Logging;
using DHT.Utils.Models; using DHT.Utils.Models;
@@ -32,11 +34,11 @@ sealed class DatabasePageModel : BaseModel {
private readonly Window window; private readonly Window window;
[Obsolete("Designer")] [Obsolete("Designer")]
public DatabasePageModel() : this(null!, DummyDatabaseFile.Instance) {} public DatabasePageModel() : this(null!, State.Dummy) {}
public DatabasePageModel(Window window, IDatabaseFile db) { public DatabasePageModel(Window window, State state) {
this.window = window; this.window = window;
this.Db = db; this.Db = state.Db;
} }
public async void OpenDatabaseFolder() { public async void OpenDatabaseFolder() {
@@ -77,31 +79,18 @@ sealed class DatabasePageModel : BaseModel {
} }
ProgressDialog progressDialog = new ProgressDialog(); ProgressDialog progressDialog = new ProgressDialog();
progressDialog.DataContext = new ProgressDialogModel(async callback => await MergeWithDatabaseFromPaths(Db, paths, progressDialog, callback)) { progressDialog.DataContext = new ProgressDialogModel(async callbacks => await MergeWithDatabaseFromPaths(Db, paths, progressDialog, callbacks[0])) {
Title = "Database Merge" Title = "Database Merge"
}; };
await progressDialog.ShowDialog(window); await progressDialog.ShowProgressDialog(window);
} }
private static async Task MergeWithDatabaseFromPaths(IDatabaseFile target, string[] paths, ProgressDialog dialog, IProgressCallback callback) { private static async Task MergeWithDatabaseFromPaths(IDatabaseFile target, string[] paths, ProgressDialog dialog, IProgressCallback callback) {
int total = paths.Length; var schemaUpgradeCallbacks = new SchemaUpgradeCallbacks(dialog, paths.Length);
DialogResult.YesNo? upgradeResult = null;
async Task<bool> CheckCanUpgradeDatabase() {
upgradeResult ??= total > 1
? await DatabaseGui.ShowCanUpgradeMultipleDatabaseDialog(dialog)
: await DatabaseGui.ShowCanUpgradeDatabaseDialog(dialog);
return DialogResult.YesNo.Yes == upgradeResult;
}
await PerformImport(target, paths, dialog, callback, "Database Merge", "Database Error", "database file", async path => { await PerformImport(target, paths, dialog, callback, "Database Merge", "Database Error", "database file", async path => {
SynchronizationContext? prevSyncContext = SynchronizationContext.Current; IDatabaseFile? db = await DatabaseGui.TryOpenOrCreateDatabaseFromPath(path, dialog, schemaUpgradeCallbacks);
SynchronizationContext.SetSynchronizationContext(new AvaloniaSynchronizationContext());
IDatabaseFile? db = await DatabaseGui.TryOpenOrCreateDatabaseFromPath(path, dialog, CheckCanUpgradeDatabase);
SynchronizationContext.SetSynchronizationContext(prevSyncContext);
if (db == null) { if (db == null) {
return false; return false;
@@ -116,6 +105,41 @@ sealed class DatabasePageModel : BaseModel {
}); });
} }
private sealed class SchemaUpgradeCallbacks : ISchemaUpgradeCallbacks {
private readonly ProgressDialog dialog;
private readonly int total;
private bool? decision;
public SchemaUpgradeCallbacks(ProgressDialog dialog, int total) {
this.total = total;
this.dialog = dialog;
}
public async Task<bool> CanUpgrade() {
return decision ??= (total > 1
? await DatabaseGui.ShowCanUpgradeMultipleDatabaseDialog(dialog)
: await DatabaseGui.ShowCanUpgradeDatabaseDialog(dialog)) == DialogResult.YesNo.Yes;
}
public Task Start(int versionSteps, Func<ISchemaUpgradeCallbacks.IProgressReporter, Task> doUpgrade) {
return doUpgrade(new NullReporter());
}
private sealed class NullReporter : ISchemaUpgradeCallbacks.IProgressReporter {
public Task NextVersion() {
return Task.CompletedTask;
}
public Task MainWork(string message, int finishedItems, int totalItems) {
return Task.CompletedTask;
}
public Task SubWork(string message, int finishedItems, int totalItems) {
return Task.CompletedTask;
}
}
}
public async void ImportLegacyArchive() { public async void ImportLegacyArchive() {
var paths = await window.StorageProvider.OpenFiles(new FilePickerOpenOptions { var paths = await window.StorageProvider.OpenFiles(new FilePickerOpenOptions {
Title = "Open Legacy DHT Archive", Title = "Open Legacy DHT Archive",
@@ -128,11 +152,11 @@ sealed class DatabasePageModel : BaseModel {
} }
ProgressDialog progressDialog = new ProgressDialog(); ProgressDialog progressDialog = new ProgressDialog();
progressDialog.DataContext = new ProgressDialogModel(async callback => await ImportLegacyArchiveFromPaths(Db, paths, progressDialog, callback)) { progressDialog.DataContext = new ProgressDialogModel(async callbacks => await ImportLegacyArchiveFromPaths(Db, paths, progressDialog, callbacks[0])) {
Title = "Legacy Archive Import" Title = "Legacy Archive Import"
}; };
await progressDialog.ShowDialog(window); await progressDialog.ShowProgressDialog(window);
} }
private static async Task ImportLegacyArchiveFromPaths(IDatabaseFile target, string[] paths, ProgressDialog dialog, IProgressCallback callback) { private static async Task ImportLegacyArchiveFromPaths(IDatabaseFile target, string[] paths, ProgressDialog dialog, IProgressCallback callback) {

View File

@@ -4,7 +4,8 @@
xmlns:mc="http://schemas.openxmlformats.org/markup-compatibility/2006" xmlns:mc="http://schemas.openxmlformats.org/markup-compatibility/2006"
xmlns:pages="clr-namespace:DHT.Desktop.Main.Pages" xmlns:pages="clr-namespace:DHT.Desktop.Main.Pages"
mc:Ignorable="d" d:DesignWidth="800" d:DesignHeight="450" mc:Ignorable="d" d:DesignWidth="800" d:DesignHeight="450"
x:Class="DHT.Desktop.Main.Pages.DebugPage"> x:Class="DHT.Desktop.Main.Pages.DebugPage"
x:DataType="pages:DebugPageModel">
<Design.DataContext> <Design.DataContext>
<pages:DebugPageModel /> <pages:DebugPageModel />

View File

@@ -6,8 +6,8 @@ using System.Threading.Tasks;
using Avalonia.Controls; using Avalonia.Controls;
using DHT.Desktop.Dialogs.Message; using DHT.Desktop.Dialogs.Message;
using DHT.Desktop.Dialogs.Progress; using DHT.Desktop.Dialogs.Progress;
using DHT.Server;
using DHT.Server.Data; using DHT.Server.Data;
using DHT.Server.Database;
using DHT.Server.Service; using DHT.Server.Service;
using DHT.Utils.Models; using DHT.Utils.Models;
@@ -18,14 +18,14 @@ namespace DHT.Desktop.Main.Pages {
public string GenerateMessages { get; set; } = "0"; public string GenerateMessages { get; set; } = "0";
private readonly Window window; private readonly Window window;
private readonly IDatabaseFile db; private readonly State state;
[Obsolete("Designer")] [Obsolete("Designer")]
public DebugPageModel() : this(null!, DummyDatabaseFile.Instance) {} public DebugPageModel() : this(null!, State.Dummy) {}
public DebugPageModel(Window window, IDatabaseFile db) { public DebugPageModel(Window window, State state) {
this.window = window; this.window = window;
this.db = db; this.state = state;
} }
public async void OnClickAddRandomDataToDatabase() { public async void OnClickAddRandomDataToDatabase() {
@@ -45,12 +45,12 @@ namespace DHT.Desktop.Main.Pages {
} }
ProgressDialog progressDialog = new ProgressDialog { ProgressDialog progressDialog = new ProgressDialog {
DataContext = new ProgressDialogModel(async callback => await GenerateRandomData(channels, users, messages, callback)) { DataContext = new ProgressDialogModel(async callbacks => await GenerateRandomData(channels, users, messages, callbacks[0])) {
Title = "Generating Random Data" Title = "Generating Random Data"
} }
}; };
await progressDialog.ShowDialog(window); await progressDialog.ShowProgressDialog(window);
} }
private const int BatchSize = 500; private const int BatchSize = 500;
@@ -83,11 +83,11 @@ namespace DHT.Desktop.Main.Pages {
Discriminator = rand.Next(0, 9999).ToString(), Discriminator = rand.Next(0, 9999).ToString(),
}).ToArray(); }).ToArray();
db.AddServer(server); state.Db.AddServer(server);
db.AddUsers(users); state.Db.AddUsers(users);
foreach (var channel in channels) { foreach (var channel in channels) {
db.AddChannel(channel); state.Db.AddChannel(channel);
} }
var now = DateTimeOffset.Now; var now = DateTimeOffset.Now;
@@ -117,7 +117,7 @@ namespace DHT.Desktop.Main.Pages {
}; };
}).ToArray(); }).ToArray();
db.AddMessages(messages); state.Db.AddMessages(messages);
messageCount -= BatchSize; messageCount -= BatchSize;
await callback.Update("Adding messages in batches of " + BatchSize, ++batchIndex, batchCount); await callback.Update("Adding messages in batches of " + BatchSize, ++batchIndex, batchCount);

View File

@@ -4,7 +4,8 @@
xmlns:mc="http://schemas.openxmlformats.org/markup-compatibility/2006" xmlns:mc="http://schemas.openxmlformats.org/markup-compatibility/2006"
xmlns:pages="clr-namespace:DHT.Desktop.Main.Pages" xmlns:pages="clr-namespace:DHT.Desktop.Main.Pages"
mc:Ignorable="d" d:DesignWidth="800" d:DesignHeight="450" mc:Ignorable="d" d:DesignWidth="800" d:DesignHeight="450"
x:Class="DHT.Desktop.Main.Pages.TrackingPage"> x:Class="DHT.Desktop.Main.Pages.TrackingPage"
x:DataType="pages:TrackingPageModel">
<Design.DataContext> <Design.DataContext>
<pages:TrackingPageModel /> <pages:TrackingPageModel />

View File

@@ -54,7 +54,7 @@ sealed class TrackingPageModel : BaseModel {
} }
public async Task<bool> OnClickCopyTrackingScript() { 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); string script = (await Resources.ReadTextAsync("tracker-loader.js")).Trim().Replace("{url}", url);
var clipboard = window.Clipboard; var clipboard = window.Clipboard;

View File

@@ -5,7 +5,8 @@
xmlns:pages="clr-namespace:DHT.Desktop.Main.Pages" xmlns:pages="clr-namespace:DHT.Desktop.Main.Pages"
xmlns:controls="clr-namespace:DHT.Desktop.Main.Controls" xmlns:controls="clr-namespace:DHT.Desktop.Main.Controls"
mc:Ignorable="d" d:DesignWidth="800" d:DesignHeight="450" mc:Ignorable="d" d:DesignWidth="800" d:DesignHeight="450"
x:Class="DHT.Desktop.Main.Pages.ViewerPage"> x:Class="DHT.Desktop.Main.Pages.ViewerPage"
x:DataType="pages:ViewerPageModel">
<Design.DataContext> <Design.DataContext>
<pages:ViewerPageModel /> <pages:ViewerPageModel />

View File

@@ -13,8 +13,8 @@ using DHT.Desktop.Dialogs.File;
using DHT.Desktop.Dialogs.Message; using DHT.Desktop.Dialogs.Message;
using DHT.Desktop.Main.Controls; using DHT.Desktop.Main.Controls;
using DHT.Desktop.Server; using DHT.Desktop.Server;
using DHT.Server;
using DHT.Server.Data.Filters; using DHT.Server.Data.Filters;
using DHT.Server.Database;
using DHT.Server.Database.Export; using DHT.Server.Database.Export;
using DHT.Server.Database.Export.Strategy; using DHT.Server.Database.Export.Strategy;
using DHT.Utils.Models; using DHT.Utils.Models;
@@ -35,19 +35,19 @@ sealed class ViewerPageModel : BaseModel, IDisposable {
set => Change(ref hasFilters, value); set => Change(ref hasFilters, value);
} }
private MessageFilterPanelModel FilterModel { get; } public MessageFilterPanelModel FilterModel { get; }
private readonly Window window; private readonly Window window;
private readonly IDatabaseFile db; private readonly State state;
[Obsolete("Designer")] [Obsolete("Designer")]
public ViewerPageModel() : this(null!, DummyDatabaseFile.Instance) {} public ViewerPageModel() : this(null!, State.Dummy) {}
public ViewerPageModel(Window window, IDatabaseFile db) { public ViewerPageModel(Window window, State state) {
this.window = window; this.window = window;
this.db = db; this.state = state;
FilterModel = new MessageFilterPanelModel(window, db, "Will export"); FilterModel = new MessageFilterPanelModel(window, state, "Will export");
FilterModel.FilterPropertyChanged += OnFilterPropertyChanged; FilterModel.FilterPropertyChanged += OnFilterPropertyChanged;
} }
@@ -72,7 +72,7 @@ sealed class ViewerPageModel : BaseModel, IDisposable {
string jsonTempFile = path + ".tmp"; string jsonTempFile = path + ".tmp";
await using (var jsonStream = new FileStream(jsonTempFile, FileMode.Create, FileAccess.ReadWrite, FileShare.Read)) { await using (var jsonStream = new FileStream(jsonTempFile, FileMode.Create, FileAccess.ReadWrite, FileShare.Read)) {
await ViewerJsonExport.Generate(jsonStream, strategy, db, FilterModel.CreateFilter()); await ViewerJsonExport.Generate(jsonStream, strategy, state.Db, FilterModel.CreateFilter());
char[] jsonBuffer = new char[Math.Min(32768, jsonStream.Position)]; char[] jsonBuffer = new char[Math.Min(32768, jsonStream.Position)];
jsonStream.Position = 0; jsonStream.Position = 0;
@@ -98,7 +98,7 @@ sealed class ViewerPageModel : BaseModel, IDisposable {
public async void OnClickOpenViewer() { public async void OnClickOpenViewer() {
string rootPath = Path.Combine(Path.GetTempPath(), "DiscordHistoryTracker"); string rootPath = Path.Combine(Path.GetTempPath(), "DiscordHistoryTracker");
string filenameBase = Path.GetFileNameWithoutExtension(db.Path) + "-" + DateTime.Now.ToString("yyyy-MM-dd"); string filenameBase = Path.GetFileNameWithoutExtension(state.Db.Path) + "-" + DateTime.Now.ToString("yyyy-MM-dd");
string fullPath = Path.Combine(rootPath, filenameBase + ".html"); string fullPath = Path.Combine(rootPath, filenameBase + ".html");
int counter = 0; int counter = 0;
@@ -110,7 +110,7 @@ sealed class ViewerPageModel : BaseModel, IDisposable {
TemporaryFiles.Add(fullPath); TemporaryFiles.Add(fullPath);
Directory.CreateDirectory(rootPath); 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 }); Process.Start(new ProcessStartInfo(fullPath) { UseShellExecute = true });
} }
@@ -123,8 +123,8 @@ sealed class ViewerPageModel : BaseModel, IDisposable {
string? path = await window.StorageProvider.SaveFile(new FilePickerSaveOptions { string? path = await window.StorageProvider.SaveFile(new FilePickerSaveOptions {
Title = "Save Viewer", Title = "Save Viewer",
FileTypeChoices = ViewerFileTypes, FileTypeChoices = ViewerFileTypes,
SuggestedFileName = Path.GetFileNameWithoutExtension(db.Path) + ".html", SuggestedFileName = Path.GetFileNameWithoutExtension(state.Db.Path) + ".html",
SuggestedStartLocation = await FileDialogs.GetSuggestedStartLocation(window, Path.GetDirectoryName(db.Path)), SuggestedStartLocation = await FileDialogs.GetSuggestedStartLocation(window, Path.GetDirectoryName(state.Db.Path)),
}); });
if (path != null) { if (path != null) {
@@ -136,13 +136,13 @@ sealed class ViewerPageModel : BaseModel, IDisposable {
var filter = FilterModel.CreateFilter(); var filter = FilterModel.CreateFilter();
if (DatabaseToolFilterModeKeep) { if (DatabaseToolFilterModeKeep) {
if (DialogResult.YesNo.Yes == await Dialog.ShowYesNo(window, "Keep Matching Messages in This Database", db.CountMessages(filter).Pluralize("message") + " will be kept, and the rest will be removed from this database. This action cannot be undone. Proceed?")) { if (DialogResult.YesNo.Yes == await Dialog.ShowYesNo(window, "Keep Matching Messages in This Database", state.Db.CountMessages(filter).Pluralize("message") + " will be kept, and the rest will be removed from this database. This action cannot be undone. Proceed?")) {
db.RemoveMessages(filter, FilterRemovalMode.KeepMatching); state.Db.RemoveMessages(filter, FilterRemovalMode.KeepMatching);
} }
} }
else if (DatabaseToolFilterModeRemove) { else if (DatabaseToolFilterModeRemove) {
if (DialogResult.YesNo.Yes == await Dialog.ShowYesNo(window, "Remove Matching Messages in This Database", db.CountMessages(filter).Pluralize("message") + " will be removed from this database. This action cannot be undone. Proceed?")) { if (DialogResult.YesNo.Yes == await Dialog.ShowYesNo(window, "Remove Matching Messages in This Database", state.Db.CountMessages(filter).Pluralize("message") + " will be removed from this database. This action cannot be undone. Proceed?")) {
db.RemoveMessages(filter, FilterRemovalMode.RemoveMatching); state.Db.RemoveMessages(filter, FilterRemovalMode.RemoveMatching);
} }
} }
} }

View File

@@ -5,7 +5,8 @@
xmlns:controls="clr-namespace:DHT.Desktop.Main.Controls" xmlns:controls="clr-namespace:DHT.Desktop.Main.Controls"
xmlns:screens="clr-namespace:DHT.Desktop.Main.Screens" xmlns:screens="clr-namespace:DHT.Desktop.Main.Screens"
mc:Ignorable="d" d:DesignWidth="800" d:DesignHeight="450" mc:Ignorable="d" d:DesignWidth="800" d:DesignHeight="450"
x:Class="DHT.Desktop.Main.Screens.MainContentScreen"> x:Class="DHT.Desktop.Main.Screens.MainContentScreen"
x:DataType="screens:MainContentScreenModel">
<Design.DataContext> <Design.DataContext>
<screens:MainContentScreenModel /> <screens:MainContentScreenModel />

View File

@@ -1,12 +1,9 @@
using System; using System;
using System.Threading.Tasks; using System.Threading.Tasks;
using Avalonia.Controls; using Avalonia.Controls;
using DHT.Desktop.Dialogs.Message;
using DHT.Desktop.Main.Controls; using DHT.Desktop.Main.Controls;
using DHT.Desktop.Main.Pages; using DHT.Desktop.Main.Pages;
using DHT.Desktop.Server; using DHT.Server;
using DHT.Server.Database;
using DHT.Server.Service;
using DHT.Utils.Logging; using DHT.Utils.Logging;
namespace DHT.Desktop.Main.Screens; namespace DHT.Desktop.Main.Screens;
@@ -49,71 +46,43 @@ sealed class MainContentScreenModel : IDisposable {
} }
} }
private readonly Window window;
private readonly ServerManager serverManager;
[Obsolete("Designer")] [Obsolete("Designer")]
public MainContentScreenModel() : this(null!, DummyDatabaseFile.Instance) {} public MainContentScreenModel() : this(null!, State.Dummy) {}
public MainContentScreenModel(Window window, IDatabaseFile db) { public MainContentScreenModel(Window window, State state) {
this.window = window; DatabasePageModel = new DatabasePageModel(window, state);
this.serverManager = new ServerManager(db);
ServerLauncher.ServerManagementExceptionCaught += ServerLauncherOnServerManagementExceptionCaught;
DatabasePageModel = new DatabasePageModel(window, db);
DatabasePage = new DatabasePage { DataContext = DatabasePageModel }; DatabasePage = new DatabasePage { DataContext = DatabasePageModel };
TrackingPageModel = new TrackingPageModel(window); TrackingPageModel = new TrackingPageModel(window);
TrackingPage = new TrackingPage { DataContext = TrackingPageModel }; TrackingPage = new TrackingPage { DataContext = TrackingPageModel };
AttachmentsPageModel = new AttachmentsPageModel(db); AttachmentsPageModel = new AttachmentsPageModel(state);
AttachmentsPage = new AttachmentsPage { DataContext = AttachmentsPageModel }; AttachmentsPage = new AttachmentsPage { DataContext = AttachmentsPageModel };
ViewerPageModel = new ViewerPageModel(window, db); ViewerPageModel = new ViewerPageModel(window, state);
ViewerPage = new ViewerPage { DataContext = ViewerPageModel }; ViewerPage = new ViewerPage { DataContext = ViewerPageModel };
AdvancedPageModel = new AdvancedPageModel(window, db, serverManager); AdvancedPageModel = new AdvancedPageModel(window, state);
AdvancedPage = new AdvancedPage { DataContext = AdvancedPageModel }; AdvancedPage = new AdvancedPage { DataContext = AdvancedPageModel };
#if DEBUG #if DEBUG
DebugPageModel = new DebugPageModel(window, db); DebugPageModel = new DebugPageModel(window, state);
DebugPage = new DebugPage { DataContext = DebugPageModel }; DebugPage = new DebugPage { DataContext = DebugPageModel };
#else #else
DebugPage = null; DebugPage = null;
#endif #endif
StatusBarModel = new StatusBarModel(db.Statistics); StatusBarModel = new StatusBarModel(state);
AdvancedPageModel.ServerConfigurationModel.ServerStatusChanged += OnServerStatusChanged;
DatabaseClosed += OnDatabaseClosed;
StatusBarModel.CurrentStatus = serverManager.IsRunning ? StatusBarModel.Status.Ready : StatusBarModel.Status.Stopped;
} }
public async Task Initialize() { public async Task Initialize() {
await TrackingPageModel.Initialize(); await TrackingPageModel.Initialize();
AdvancedPageModel.Initialize();
serverManager.Launch();
} }
public void Dispose() { public void Dispose() {
ServerLauncher.ServerManagementExceptionCaught -= ServerLauncherOnServerManagementExceptionCaught;
AttachmentsPageModel.Dispose(); AttachmentsPageModel.Dispose();
ViewerPageModel.Dispose(); ViewerPageModel.Dispose();
serverManager.Dispose(); AdvancedPageModel.Dispose();
} StatusBarModel.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);
} }
} }

View File

@@ -4,7 +4,8 @@
xmlns:mc="http://schemas.openxmlformats.org/markup-compatibility/2006" xmlns:mc="http://schemas.openxmlformats.org/markup-compatibility/2006"
xmlns:screens="clr-namespace:DHT.Desktop.Main.Screens" xmlns:screens="clr-namespace:DHT.Desktop.Main.Screens"
mc:Ignorable="d" d:DesignWidth="800" d:DesignHeight="450" mc:Ignorable="d" d:DesignWidth="800" d:DesignHeight="450"
x:Class="DHT.Desktop.Main.Screens.WelcomeScreen"> x:Class="DHT.Desktop.Main.Screens.WelcomeScreen"
x:DataType="screens:WelcomeScreenModel">
<Design.DataContext> <Design.DataContext>
<screens:WelcomeScreenModel /> <screens:WelcomeScreenModel />

View File

@@ -1,10 +1,13 @@
using System; using System;
using System.Collections.Generic;
using System.IO; using System.IO;
using System.Threading.Tasks; using System.Threading.Tasks;
using Avalonia.Controls; using Avalonia.Controls;
using DHT.Desktop.Common; using DHT.Desktop.Common;
using DHT.Desktop.Dialogs.Message; using DHT.Desktop.Dialogs.Message;
using DHT.Desktop.Dialogs.Progress;
using DHT.Server.Database; using DHT.Server.Database;
using DHT.Server.Database.Sqlite;
using DHT.Utils.Models; using DHT.Utils.Models;
namespace DHT.Desktop.Main.Screens; namespace DHT.Desktop.Main.Screens;
@@ -39,16 +42,73 @@ sealed class WelcomeScreenModel : BaseModel, IDisposable {
} }
dbFilePath = path; dbFilePath = path;
Db = await DatabaseGui.TryOpenOrCreateDatabaseFromPath(path, window, CheckCanUpgradeDatabase); Db = await DatabaseGui.TryOpenOrCreateDatabaseFromPath(path, window, new SchemaUpgradeCallbacks(window));
OnPropertyChanged(nameof(Db)); OnPropertyChanged(nameof(Db));
OnPropertyChanged(nameof(HasDatabase)); OnPropertyChanged(nameof(HasDatabase));
} }
private async Task<bool> CheckCanUpgradeDatabase() { private sealed class SchemaUpgradeCallbacks : ISchemaUpgradeCallbacks {
private readonly Window window;
public SchemaUpgradeCallbacks(Window window) {
this.window = window;
}
public async Task<bool> CanUpgrade() {
return DialogResult.YesNo.Yes == await DatabaseGui.ShowCanUpgradeDatabaseDialog(window); return DialogResult.YesNo.Yes == await DatabaseGui.ShowCanUpgradeDatabaseDialog(window);
} }
public async Task Start(int versionSteps, Func<ISchemaUpgradeCallbacks.IProgressReporter, Task> doUpgrade) {
async Task StartUpgrade(IReadOnlyList<IProgressCallback> callbacks) {
var reporter = new ProgressReporter(versionSteps, callbacks);
await reporter.NextVersion();
await Task.Delay(TimeSpan.FromMilliseconds(800));
await doUpgrade(reporter);
await Task.Delay(TimeSpan.FromMilliseconds(600));
}
await new ProgressDialog {
DataContext = new ProgressDialogModel(StartUpgrade, progressItems: 3) {
Title = "Upgrading Database"
}
}.ShowProgressDialog(window);
}
private sealed class ProgressReporter : ISchemaUpgradeCallbacks.IProgressReporter {
private readonly IReadOnlyList<IProgressCallback> callbacks;
private readonly int versionSteps;
private int versionProgress = 0;
public ProgressReporter(int versionSteps, IReadOnlyList<IProgressCallback> callbacks) {
this.callbacks = callbacks;
this.versionSteps = versionSteps;
}
public async Task NextVersion() {
await callbacks[0].Update("Upgrading schema version...", versionProgress++, versionSteps);
await HideChildren(0);
}
public async Task MainWork(string message, int finishedItems, int totalItems) {
await callbacks[1].Update(message, finishedItems, totalItems);
await HideChildren(1);
}
public async Task SubWork(string message, int finishedItems, int totalItems) {
await callbacks[2].Update(message, finishedItems, totalItems);
await HideChildren(2);
}
private async Task HideChildren(int parentIndex) {
for (int i = parentIndex + 1; i < callbacks.Count; i++) {
await callbacks[i].Hide();
}
}
}
}
public void CloseDatabase() { public void CloseDatabase() {
Dispose(); Dispose();
OnPropertyChanged(nameof(Db)); OnPropertyChanged(nameof(Db));

View File

@@ -1,6 +1,8 @@
using System.Globalization; using System;
using System.Globalization;
using System.Reflection; using System.Reflection;
using Avalonia; using Avalonia;
using DHT.Utils.Logging;
using DHT.Utils.Resources; using DHT.Utils.Resources;
namespace DHT.Desktop; namespace DHT.Desktop;
@@ -9,6 +11,7 @@ static class Program {
public static string Version { get; } public static string Version { get; }
public static CultureInfo Culture { get; } public static CultureInfo Culture { get; }
public static ResourceLoader Resources { get; } public static ResourceLoader Resources { get; }
public static Arguments Arguments { get; }
static Program() { static Program() {
var assembly = Assembly.GetExecutingAssembly(); var assembly = Assembly.GetExecutingAssembly();
@@ -25,10 +28,21 @@ static class Program {
CultureInfo.DefaultThreadCurrentUICulture = CultureInfo.InvariantCulture; CultureInfo.DefaultThreadCurrentUICulture = CultureInfo.InvariantCulture;
Resources = new ResourceLoader(assembly); Resources = new ResourceLoader(assembly);
Arguments = new Arguments(Environment.GetCommandLineArgs());
} }
public static void Main(string[] args) { public static void Main(string[] args) {
if (Arguments.Console && OperatingSystem.IsWindows()) {
WindowsConsole.AllocConsole();
}
try {
BuildAvaloniaApp().StartWithClassicDesktopLifetime(args); BuildAvaloniaApp().StartWithClassicDesktopLifetime(args);
} finally {
if (Arguments.Console && OperatingSystem.IsWindows()) {
WindowsConsole.FreeConsole();
}
}
} }
private static AppBuilder BuildAvaloniaApp() { private static AppBuilder BuildAvaloniaApp() {

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.Database;
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 IDatabaseFile db;
public ServerManager(IDatabaseFile db) {
if (db != DummyDatabaseFile.Instance) {
if (instance != null) {
throw new InvalidOperationException("Only one instance of ServerManager can exist at the same time!");
}
instance = this;
}
this.db = db;
}
public void Launch() {
ServerLauncher.Relaunch(Port, Token, 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

@@ -19,9 +19,21 @@
</PropertyGroup> </PropertyGroup>
<PropertyGroup> <PropertyGroup>
<SuppressTrimAnalysisWarnings>false</SuppressTrimAnalysisWarnings>
<PublishTrimmed>true</PublishTrimmed> <PublishTrimmed>true</PublishTrimmed>
<TrimMode>partial</TrimMode> <TrimMode>partial</TrimMode>
<JsonSerializerIsReflectionEnabledByDefault>true</JsonSerializerIsReflectionEnabledByDefault> <EnableUnsafeBinaryFormatterSerialization>false</EnableUnsafeBinaryFormatterSerialization>
<EnableUnsafeUTF7Encoding>false</EnableUnsafeUTF7Encoding>
<EventSourceSupport>false</EventSourceSupport>
<HttpActivityPropagationSupport>false</HttpActivityPropagationSupport>
<JsonSerializerIsReflectionEnabledByDefault>false</JsonSerializerIsReflectionEnabledByDefault>
</PropertyGroup>
<PropertyGroup>
<PublishSingleFile>true</PublishSingleFile>
<PublishReadyToRun>false</PublishReadyToRun>
<EnableCompressionInSingleFile>true</EnableCompressionInSingleFile>
<IncludeNativeLibrariesForSelfExtract>true</IncludeNativeLibrariesForSelfExtract>
</PropertyGroup> </PropertyGroup>
<PropertyGroup Condition=" '$(Configuration)' == 'Release' "> <PropertyGroup Condition=" '$(Configuration)' == 'Release' ">

View File

@@ -8,5 +8,6 @@ namespace DHT.Server.Data;
public enum DownloadStatus { public enum DownloadStatus {
Enqueued = 0, Enqueued = 0,
GenericError = 1, GenericError = 1,
Downloading = 2,
Success = HttpStatusCode.OK Success = HttpStatusCode.OK
} }

View File

@@ -74,7 +74,7 @@ public sealed class DummyDatabaseFile : IDatabaseFile {
public void EnqueueDownloadItems(AttachmentFilter? filter = null) {} public void EnqueueDownloadItems(AttachmentFilter? filter = null) {}
public List<DownloadItem> GetEnqueuedDownloadItems(int count) { public List<DownloadItem> PullEnqueuedDownloadItems(int count) {
return new(); return new();
} }

View File

@@ -0,0 +1,3 @@
namespace DHT.Server.Database.Export;
readonly record struct Snowflake(ulong Id);

View File

@@ -0,0 +1,23 @@
using System;
using System.Text.Json;
using System.Text.Json.Serialization;
namespace DHT.Server.Database.Export;
sealed class SnowflakeJsonSerializer : JsonConverter<Snowflake> {
public override Snowflake Read(ref Utf8JsonReader reader, Type typeToConvert, JsonSerializerOptions options) {
return new Snowflake(ulong.Parse(reader.GetString()!));
}
public override void Write(Utf8JsonWriter writer, Snowflake value, JsonSerializerOptions options) {
writer.WriteStringValue(value.Id.ToString());
}
public override Snowflake ReadAsPropertyName(ref Utf8JsonReader reader, Type typeToConvert, JsonSerializerOptions options) {
return new Snowflake(ulong.Parse(reader.GetString()!));
}
public override void WriteAsPropertyName(Utf8JsonWriter writer, Snowflake value, JsonSerializerOptions options) {
writer.WritePropertyName(value.Id.ToString());
}
}

View File

@@ -0,0 +1,93 @@
using System.Collections.Generic;
using System.Text.Json.Serialization;
namespace DHT.Server.Database.Export;
sealed class ViewerJson {
public required JsonMeta Meta { get; init; }
public required Dictionary<Snowflake, Dictionary<Snowflake, JsonMessage>> Data { get; init; }
public sealed class JsonMeta {
public required Dictionary<Snowflake, JsonUser> Users { get; init; }
public required List<Snowflake> Userindex { get; init; }
public required List<JsonServer> Servers { get; init; }
public required Dictionary<Snowflake, JsonChannel> Channels { get; init; }
}
public sealed class JsonUser {
public required string Name { get; init; }
[JsonIgnore(Condition = JsonIgnoreCondition.WhenWritingNull)]
public string? Avatar { get; init; }
[JsonIgnore(Condition = JsonIgnoreCondition.WhenWritingNull)]
public string? Tag { get; init; }
}
public sealed class JsonServer {
public required string Name { get; init; }
public required string Type { get; init; }
}
public sealed class JsonChannel {
public required int Server { get; init; }
public required string Name { get; init; }
[JsonIgnore(Condition = JsonIgnoreCondition.WhenWritingNull)]
public string? Parent { get; init; }
[JsonIgnore(Condition = JsonIgnoreCondition.WhenWritingNull)]
public int? Position { get; init; }
[JsonIgnore(Condition = JsonIgnoreCondition.WhenWritingNull)]
public string? Topic { get; init; }
[JsonIgnore(Condition = JsonIgnoreCondition.WhenWritingNull)]
public bool? Nsfw { get; init; }
}
public sealed class JsonMessage {
public required int U { get; init; }
public required long T { get; init; }
[JsonIgnore(Condition = JsonIgnoreCondition.WhenWritingNull)]
public string? M { get; init; }
[JsonIgnore(Condition = JsonIgnoreCondition.WhenWritingNull)]
public long? Te { get; init; }
[JsonIgnore(Condition = JsonIgnoreCondition.WhenWritingNull)]
public string? R { get; init; }
[JsonIgnore(Condition = JsonIgnoreCondition.WhenWritingNull)]
public JsonMessageAttachment[]? A { get; init; }
[JsonIgnore(Condition = JsonIgnoreCondition.WhenWritingNull)]
public string[]? E { get; init; }
[JsonIgnore(Condition = JsonIgnoreCondition.WhenWritingNull)]
public JsonMessageReaction[]? Re { get; init; }
}
public sealed class JsonMessageAttachment {
public required string Url { get; init; }
public required string Name { get; init; }
[JsonIgnore(Condition = JsonIgnoreCondition.WhenWritingNull)]
public int? Width { get; set; }
[JsonIgnore(Condition = JsonIgnoreCondition.WhenWritingNull)]
public int? Height { get; set; }
}
public sealed class JsonMessageReaction {
[JsonIgnore(Condition = JsonIgnoreCondition.WhenWritingNull)]
public string? Id { get; init; }
[JsonIgnore(Condition = JsonIgnoreCondition.WhenWritingNull)]
public string? N { get; init; }
public required bool A { get; init; }
public required int C { get; init; }
}
}

View File

@@ -0,0 +1,11 @@
using System.Text.Json.Serialization;
namespace DHT.Server.Database.Export;
[JsonSourceGenerationOptions(
Converters = new [] { typeof(SnowflakeJsonSerializer) },
PropertyNamingPolicy = JsonKnownNamingPolicy.CamelCase,
GenerationMode = JsonSourceGenerationMode.Default
)]
[JsonSerializable(typeof(ViewerJson))]
sealed partial class ViewerJsonContext : JsonSerializerContext {}

View File

@@ -42,26 +42,28 @@ public static class ViewerJsonExport {
perf.Step("Collect database data"); perf.Step("Collect database data");
var value = new { var value = new ViewerJson {
meta = new { users, userindex, servers, channels }, Meta = new ViewerJson.JsonMeta {
data = GenerateMessageList(includedMessages, userIndices, strategy), Users = users,
Userindex = userindex,
Servers = servers,
Channels = channels
},
Data = GenerateMessageList(includedMessages, userIndices, strategy)
}; };
perf.Step("Generate value object"); perf.Step("Generate value object");
var opts = new JsonSerializerOptions(); await JsonSerializer.SerializeAsync(stream, value, ViewerJsonContext.Default.ViewerJson);
opts.Converters.Add(new ViewerJsonSnowflakeSerializer());
await JsonSerializer.SerializeAsync(stream, value, opts);
perf.Step("Serialize to JSON"); perf.Step("Serialize to JSON");
perf.End(); perf.End();
} }
private static Dictionary<string, object> GenerateUserList(IDatabaseFile db, HashSet<ulong> userIds, out List<string> userindex, out Dictionary<ulong, object> userIndices) { private static Dictionary<Snowflake, ViewerJson.JsonUser> GenerateUserList(IDatabaseFile db, HashSet<ulong> userIds, out List<Snowflake> userindex, out Dictionary<ulong, int> userIndices) {
var users = new Dictionary<string, object>(); var users = new Dictionary<Snowflake, ViewerJson.JsonUser>();
userindex = new List<string>(); userindex = new List<Snowflake>();
userIndices = new Dictionary<ulong, object>(); userIndices = new Dictionary<ulong, int>();
foreach (var user in db.GetAllUsers()) { foreach (var user in db.GetAllUsers()) {
var id = user.Id; var id = user.Id;
@@ -69,30 +71,23 @@ public static class ViewerJsonExport {
continue; continue;
} }
var obj = new Dictionary<string, object> { var idSnowflake = new Snowflake(id);
["name"] = user.Name
};
if (user.AvatarUrl != null) {
obj["avatar"] = user.AvatarUrl;
}
if (user.Discriminator != null) {
obj["tag"] = user.Discriminator;
}
var idStr = id.ToString();
userIndices[id] = users.Count; userIndices[id] = users.Count;
userindex.Add(idStr); userindex.Add(idSnowflake);
users[idStr] = obj;
users[idSnowflake] = new ViewerJson.JsonUser {
Name = user.Name,
Avatar = user.AvatarUrl,
Tag = user.Discriminator
};
} }
return users; return users;
} }
private static List<object> GenerateServerList(IDatabaseFile db, HashSet<ulong> serverIds, out Dictionary<ulong, object> serverIndices) { private static List<ViewerJson.JsonServer> GenerateServerList(IDatabaseFile db, HashSet<ulong> serverIds, out Dictionary<ulong, int> serverIndices) {
var servers = new List<object>(); var servers = new List<ViewerJson.JsonServer>();
serverIndices = new Dictionary<ulong, object>(); serverIndices = new Dictionary<ulong, int>();
foreach (var server in db.GetAllServers()) { foreach (var server in db.GetAllServers()) {
var id = server.Id; var id = server.Id;
@@ -101,113 +96,78 @@ public static class ViewerJsonExport {
} }
serverIndices[id] = servers.Count; serverIndices[id] = servers.Count;
servers.Add(new Dictionary<string, object> {
["name"] = server.Name, servers.Add(new ViewerJson.JsonServer {
["type"] = ServerTypes.ToJsonViewerString(server.Type), Name = server.Name,
Type = ServerTypes.ToJsonViewerString(server.Type)
}); });
} }
return servers; return servers;
} }
private static Dictionary<string, object> GenerateChannelList(List<Channel> includedChannels, Dictionary<ulong, object> serverIndices) { private static Dictionary<Snowflake, ViewerJson.JsonChannel> GenerateChannelList(List<Channel> includedChannels, Dictionary<ulong, int> serverIndices) {
var channels = new Dictionary<string, object>(); var channels = new Dictionary<Snowflake, ViewerJson.JsonChannel>();
foreach (var channel in includedChannels) { foreach (var channel in includedChannels) {
var obj = new Dictionary<string, object> { var channelIdSnowflake = new Snowflake(channel.Id);
["server"] = serverIndices[channel.Server],
["name"] = channel.Name, channels[channelIdSnowflake] = new ViewerJson.JsonChannel {
Server = serverIndices[channel.Server],
Name = channel.Name,
Parent = channel.ParentId?.ToString(),
Position = channel.Position,
Topic = channel.Topic,
Nsfw = channel.Nsfw
}; };
if (channel.ParentId != null) {
obj["parent"] = channel.ParentId;
}
if (channel.Position != null) {
obj["position"] = channel.Position;
}
if (channel.Topic != null) {
obj["topic"] = channel.Topic;
}
if (channel.Nsfw != null) {
obj["nsfw"] = channel.Nsfw;
}
channels[channel.Id.ToString()] = obj;
} }
return channels; return channels;
} }
private static Dictionary<string, Dictionary<string, object>> GenerateMessageList( List<Message> includedMessages, Dictionary<ulong, object> userIndices, IViewerExportStrategy strategy) { private static Dictionary<Snowflake, Dictionary<Snowflake, ViewerJson.JsonMessage>> GenerateMessageList(List<Message> includedMessages, Dictionary<ulong, int> userIndices, IViewerExportStrategy strategy) {
var data = new Dictionary<string, Dictionary<string, object>>(); var data = new Dictionary<Snowflake, Dictionary<Snowflake, ViewerJson.JsonMessage>>();
foreach (var grouping in includedMessages.GroupBy(static message => message.Channel)) { foreach (var grouping in includedMessages.GroupBy(static message => message.Channel)) {
var channel = grouping.Key.ToString(); var channelIdSnowflake = new Snowflake(grouping.Key);
var channelData = new Dictionary<string, object>(); var channelData = new Dictionary<Snowflake, ViewerJson.JsonMessage>();
foreach (var message in grouping) { foreach (var message in grouping) {
var obj = new Dictionary<string, object> { var messageIdSnowflake = new Snowflake(message.Id);
["u"] = userIndices[message.Sender],
["t"] = message.Timestamp,
};
if (!string.IsNullOrEmpty(message.Text)) { channelData[messageIdSnowflake] = new ViewerJson.JsonMessage {
obj["m"] = message.Text; U = userIndices[message.Sender],
} T = message.Timestamp,
M = string.IsNullOrEmpty(message.Text) ? null : message.Text,
Te = message.EditTimestamp,
R = message.RepliedToId?.ToString(),
if (message.EditTimestamp != null) { A = message.Attachments.IsEmpty ? null : message.Attachments.Select(attachment => {
obj["te"] = message.EditTimestamp; var a = new ViewerJson.JsonMessageAttachment {
} Url = strategy.GetAttachmentUrl(attachment),
Name = Uri.TryCreate(attachment.NormalizedUrl, UriKind.Absolute, out var uri) ? Path.GetFileName(uri.LocalPath) : attachment.NormalizedUrl
if (message.RepliedToId != null) {
obj["r"] = message.RepliedToId.Value;
}
if (!message.Attachments.IsEmpty) {
obj["a"] = message.Attachments.Select(attachment => {
var a = new Dictionary<string, object> {
{ "url", strategy.GetAttachmentUrl(attachment) },
{ "name", Uri.TryCreate(attachment.NormalizedUrl, UriKind.Absolute, out var uri) ? Path.GetFileName(uri.LocalPath) : attachment.NormalizedUrl },
}; };
if (attachment is { Width: not null, Height: not null }) { if (attachment is { Width: not null, Height: not null }) {
a["width"] = attachment.Width; a.Width = attachment.Width;
a["height"] = attachment.Height; a.Height = attachment.Height;
} }
return a; return a;
}).ToArray(); }).ToArray(),
E = message.Embeds.IsEmpty ? null : message.Embeds.Select(static embed => embed.Json).ToArray(),
Re = message.Reactions.IsEmpty ? null : message.Reactions.Select(static reaction => new ViewerJson.JsonMessageReaction {
Id = reaction.EmojiId?.ToString(),
N = reaction.EmojiName,
A = reaction.EmojiFlags.HasFlag(EmojiFlags.Animated),
C = reaction.Count
}).ToArray()
};
} }
if (!message.Embeds.IsEmpty) { data[channelIdSnowflake] = channelData;
obj["e"] = message.Embeds.Select(static embed => embed.Json).ToArray();
}
if (!message.Reactions.IsEmpty) {
obj["re"] = message.Reactions.Select(static reaction => {
var r = new Dictionary<string, object>();
if (reaction.EmojiId != null) {
r["id"] = reaction.EmojiId.Value;
}
if (reaction.EmojiName != null) {
r["n"] = reaction.EmojiName;
}
r["a"] = reaction.EmojiFlags.HasFlag(EmojiFlags.Animated);
r["c"] = reaction.Count;
return r;
}).ToArray();
}
channelData[message.Id.ToString()] = obj;
}
data[channel] = channelData;
} }
return data; return data;

View File

@@ -1,15 +0,0 @@
using System;
using System.Text.Json;
using System.Text.Json.Serialization;
namespace DHT.Server.Database.Export;
sealed class ViewerJsonSnowflakeSerializer : JsonConverter<ulong> {
public override ulong Read(ref Utf8JsonReader reader, Type typeToConvert, JsonSerializerOptions options) {
return ulong.Parse(reader.GetString()!);
}
public override void Write(Utf8JsonWriter writer, ulong value, JsonSerializerOptions options) {
writer.WriteStringValue(value.ToString());
}
}

View File

@@ -35,7 +35,7 @@ public interface IDatabaseFile : IDisposable {
DownloadedAttachment? GetDownloadedAttachment(string url); DownloadedAttachment? GetDownloadedAttachment(string url);
void EnqueueDownloadItems(AttachmentFilter? filter = null); void EnqueueDownloadItems(AttachmentFilter? filter = null);
List<DownloadItem> GetEnqueuedDownloadItems(int count); List<DownloadItem> PullEnqueuedDownloadItems(int count);
void RemoveDownloadItems(DownloadItemFilter? filter, FilterRemovalMode mode); void RemoveDownloadItems(DownloadItemFilter? filter, FilterRemovalMode mode);
DownloadStatusStatistics GetDownloadStatusStatistics(); DownloadStatusStatistics GetDownloadStatusStatistics();

View File

@@ -0,0 +1,23 @@
using System.Text.Json.Serialization;
namespace DHT.Server.Database.Import;
sealed class DiscordEmbedLegacyJson {
public required string Url { get; init; }
public required string Type { get; init; }
public bool DhtLegacy { get; } = true;
[JsonIgnore(Condition = JsonIgnoreCondition.WhenWritingNull)]
public string? Title { get; init; }
[JsonIgnore(Condition = JsonIgnoreCondition.WhenWritingNull)]
public string? Description { get; init; }
[JsonIgnore(Condition = JsonIgnoreCondition.WhenWritingNull)]
public ImageJson? Image { get; init; }
public sealed class ImageJson {
public required string Url { get; init; }
}
}

View File

@@ -0,0 +1,7 @@
using System.Text.Json.Serialization;
namespace DHT.Server.Database.Import;
[JsonSourceGenerationOptions(PropertyNamingPolicy = JsonKnownNamingPolicy.SnakeCaseLower, GenerationMode = JsonSourceGenerationMode.Default)]
[JsonSerializable(typeof(DiscordEmbedLegacyJson))]
sealed partial class DiscordEmbedLegacyJsonContext : JsonSerializerContext {}

View File

@@ -21,7 +21,7 @@ public static class LegacyArchiveImport {
public static async Task<bool> Read(Stream stream, IDatabaseFile db, FakeSnowflake fakeSnowflake, Func<Data.Server[], Task<Dictionary<Data.Server, ulong>?>> askForServerIds) { public static async Task<bool> Read(Stream stream, IDatabaseFile db, FakeSnowflake fakeSnowflake, Func<Data.Server[], Task<Dictionary<Data.Server, ulong>?>> askForServerIds) {
var perf = Log.Start(); var perf = Log.Start();
var root = await JsonSerializer.DeserializeAsync<JsonElement>(stream); var root = await JsonSerializer.DeserializeAsync(stream, JsonElementContext.Default.JsonElement);
try { try {
var meta = root.RequireObject("meta"); var meta = root.RequireObject("meta");
@@ -213,29 +213,16 @@ public static class LegacyArchiveImport {
string url = embedObj.RequireString("url", path); string url = embedObj.RequireString("url", path);
string type = embedObj.RequireString("type", path); string type = embedObj.RequireString("type", path);
var embedJson = new Dictionary<string, object> { var embed = new DiscordEmbedLegacyJson {
{ "url", url }, Url = url,
{ "type", type }, Type = type,
{ "dht_legacy", true }, Title = type == "rich" && embedObj.HasKey("t") ? embedObj.RequireString("t", path) : null,
Description = type == "rich" && embedObj.HasKey("d") ? embedObj.RequireString("d", path) : null,
Image = type == "image" ? new DiscordEmbedLegacyJson.ImageJson { Url = url } : null
}; };
if (type == "image") {
embedJson["image"] = new Dictionary<string, string> {
{ "url", url }
};
}
else if (type == "rich") {
if (embedObj.HasKey("t")) {
embedJson["title"] = embedObj.RequireString("t", path);
}
if (embedObj.HasKey("d")) {
embedJson["description"] = embedObj.RequireString("d", path);
}
}
return new Embed { return new Embed {
Json = JsonSerializer.Serialize(embedJson) Json = JsonSerializer.Serialize(embed, DiscordEmbedLegacyJsonContext.Default.DiscordEmbedLegacyJson)
}; };
}); });
} }

View File

@@ -0,0 +1,15 @@
using System;
using System.Threading.Tasks;
namespace DHT.Server.Database.Sqlite;
public interface ISchemaUpgradeCallbacks {
Task<bool> CanUpgrade();
Task Start(int versionSteps, Func<IProgressReporter, Task> doUpgrade);
public interface IProgressReporter {
Task NextVersion();
Task MainWork(string message, int finishedItems, int totalItems);
Task SubWork(string message, int finishedItems, int totalItems);
}
}

View File

@@ -1,4 +1,3 @@
using System;
using System.Collections.Generic; using System.Collections.Generic;
using System.Threading.Tasks; using System.Threading.Tasks;
using DHT.Server.Database.Exceptions; using DHT.Server.Database.Exceptions;
@@ -20,12 +19,8 @@ sealed class Schema {
this.conn = conn; this.conn = conn;
} }
private void Execute(string sql) { public async Task<bool> Setup(ISchemaUpgradeCallbacks callbacks) {
conn.Command(sql).ExecuteNonQuery(); conn.Execute(@"CREATE TABLE IF NOT EXISTS metadata (key TEXT PRIMARY KEY, value TEXT)");
}
public async Task<bool> Setup(Func<Task<bool>> checkCanUpgradeSchemas) {
Execute(@"CREATE TABLE IF NOT EXISTS metadata (key TEXT PRIMARY KEY, value TEXT)");
var dbVersionStr = conn.SelectScalar("SELECT value FROM metadata WHERE key = 'version'"); var dbVersionStr = conn.SelectScalar("SELECT value FROM metadata WHERE key = 'version'");
if (dbVersionStr == null) { if (dbVersionStr == null) {
@@ -38,19 +33,19 @@ sealed class Schema {
throw new DatabaseTooNewException(dbVersion); throw new DatabaseTooNewException(dbVersion);
} }
else if (dbVersion < Version) { else if (dbVersion < Version) {
var proceed = await checkCanUpgradeSchemas(); var proceed = await callbacks.CanUpgrade();
if (!proceed) { if (!proceed) {
return false; return false;
} }
UpgradeSchemas(dbVersion); await callbacks.Start(Version - dbVersion, async reporter => await UpgradeSchemas(dbVersion, reporter));
} }
return true; return true;
} }
private void InitializeSchemas() { private void InitializeSchemas() {
Execute(""" conn.Execute("""
CREATE TABLE users ( CREATE TABLE users (
id INTEGER PRIMARY KEY NOT NULL, id INTEGER PRIMARY KEY NOT NULL,
name TEXT NOT NULL, name TEXT NOT NULL,
@@ -59,7 +54,7 @@ sealed class Schema {
) )
"""); """);
Execute(""" conn.Execute("""
CREATE TABLE servers ( CREATE TABLE servers (
id INTEGER PRIMARY KEY NOT NULL, id INTEGER PRIMARY KEY NOT NULL,
name TEXT NOT NULL, name TEXT NOT NULL,
@@ -67,7 +62,7 @@ sealed class Schema {
) )
"""); """);
Execute(""" conn.Execute("""
CREATE TABLE channels ( CREATE TABLE channels (
id INTEGER PRIMARY KEY NOT NULL, id INTEGER PRIMARY KEY NOT NULL,
server INTEGER NOT NULL, server INTEGER NOT NULL,
@@ -79,7 +74,7 @@ sealed class Schema {
) )
"""); """);
Execute(""" conn.Execute("""
CREATE TABLE messages ( CREATE TABLE messages (
message_id INTEGER PRIMARY KEY NOT NULL, message_id INTEGER PRIMARY KEY NOT NULL,
sender_id INTEGER NOT NULL, sender_id INTEGER NOT NULL,
@@ -89,7 +84,7 @@ sealed class Schema {
) )
"""); """);
Execute(""" conn.Execute("""
CREATE TABLE attachments ( CREATE TABLE attachments (
message_id INTEGER NOT NULL, message_id INTEGER NOT NULL,
attachment_id INTEGER NOT NULL PRIMARY KEY NOT NULL, attachment_id INTEGER NOT NULL PRIMARY KEY NOT NULL,
@@ -103,14 +98,14 @@ sealed class Schema {
) )
"""); """);
Execute(""" conn.Execute("""
CREATE TABLE embeds ( CREATE TABLE embeds (
message_id INTEGER NOT NULL, message_id INTEGER NOT NULL,
json TEXT NOT NULL json TEXT NOT NULL
) )
"""); """);
Execute(""" conn.Execute("""
CREATE TABLE downloads ( CREATE TABLE downloads (
normalized_url TEXT NOT NULL PRIMARY KEY, normalized_url TEXT NOT NULL PRIMARY KEY,
download_url TEXT, download_url TEXT,
@@ -120,7 +115,7 @@ sealed class Schema {
) )
"""); """);
Execute(""" conn.Execute("""
CREATE TABLE reactions ( CREATE TABLE reactions (
message_id INTEGER NOT NULL, message_id INTEGER NOT NULL,
emoji_id INTEGER, emoji_id INTEGER,
@@ -133,15 +128,15 @@ sealed class Schema {
CreateMessageEditTimestampTable(); CreateMessageEditTimestampTable();
CreateMessageRepliedToTable(); CreateMessageRepliedToTable();
Execute("CREATE INDEX attachments_message_ix ON attachments(message_id)"); conn.Execute("CREATE INDEX attachments_message_ix ON attachments(message_id)");
Execute("CREATE INDEX embeds_message_ix ON embeds(message_id)"); conn.Execute("CREATE INDEX embeds_message_ix ON embeds(message_id)");
Execute("CREATE INDEX reactions_message_ix ON reactions(message_id)"); conn.Execute("CREATE INDEX reactions_message_ix ON reactions(message_id)");
Execute("INSERT INTO metadata (key, value) VALUES ('version', " + Version + ")"); conn.Execute("INSERT INTO metadata (key, value) VALUES ('version', " + Version + ")");
} }
private void CreateMessageEditTimestampTable() { private void CreateMessageEditTimestampTable() {
Execute(""" conn.Execute("""
CREATE TABLE edit_timestamps ( CREATE TABLE edit_timestamps (
message_id INTEGER PRIMARY KEY NOT NULL, message_id INTEGER PRIMARY KEY NOT NULL,
edit_timestamp INTEGER NOT NULL edit_timestamp INTEGER NOT NULL
@@ -150,7 +145,7 @@ sealed class Schema {
} }
private void CreateMessageRepliedToTable() { private void CreateMessageRepliedToTable() {
Execute(""" conn.Execute("""
CREATE TABLE replied_to ( CREATE TABLE replied_to (
message_id INTEGER PRIMARY KEY NOT NULL, message_id INTEGER PRIMARY KEY NOT NULL,
replied_to_id INTEGER NOT NULL replied_to_id INTEGER NOT NULL
@@ -158,11 +153,13 @@ sealed class Schema {
"""); """);
} }
private void NormalizeAttachmentUrls() { private async Task NormalizeAttachmentUrls(ISchemaUpgradeCallbacks.IProgressReporter reporter) {
await reporter.SubWork("Preparing attachments...", 0, 0);
var normalizedUrls = new Dictionary<long, string>(); var normalizedUrls = new Dictionary<long, string>();
using (var selectCmd = conn.Command("SELECT attachment_id, url FROM attachments")) { await using (var selectCmd = conn.Command("SELECT attachment_id, url FROM attachments")) {
using var reader = selectCmd.ExecuteReader(); await using var reader = await selectCmd.ExecuteReaderAsync();
while (reader.Read()) { while (reader.Read()) {
var attachmentId = reader.GetInt64(0); var attachmentId = reader.GetInt64(0);
@@ -171,28 +168,39 @@ sealed class Schema {
} }
} }
using var tx = conn.BeginTransaction(); await using var tx = conn.BeginTransaction();
using (var updateCmd = conn.Command("UPDATE attachments SET download_url = url, url = :normalized_url WHERE attachment_id = :attachment_id")) { int totalUrls = normalizedUrls.Count;
updateCmd.Parameters.Add(":attachment_id", SqliteType.Integer); int processedUrls = -1;
updateCmd.Parameters.Add(":normalized_url", SqliteType.Text);
await using (var updateCmd = conn.Command("UPDATE attachments SET download_url = url, url = :normalized_url WHERE attachment_id = :attachment_id")) {
updateCmd.Add(":attachment_id", SqliteType.Integer);
updateCmd.Add(":normalized_url", SqliteType.Text);
foreach (var (attachmentId, normalizedUrl) in normalizedUrls) { foreach (var (attachmentId, normalizedUrl) in normalizedUrls) {
if (++processedUrls % 1000 == 0) {
await reporter.SubWork("Updating URLs...", processedUrls, totalUrls);
}
updateCmd.Set(":attachment_id", attachmentId); updateCmd.Set(":attachment_id", attachmentId);
updateCmd.Set(":normalized_url", normalizedUrl); updateCmd.Set(":normalized_url", normalizedUrl);
updateCmd.ExecuteNonQuery(); updateCmd.ExecuteNonQuery();
} }
} }
tx.Commit(); await reporter.SubWork("Updating URLs...", totalUrls, totalUrls);
await tx.CommitAsync();
} }
private void NormalizeDownloadUrls() { private async Task NormalizeDownloadUrls(ISchemaUpgradeCallbacks.IProgressReporter reporter) {
await reporter.SubWork("Preparing downloads...", 0, 0);
var normalizedUrlsToOriginalUrls = new Dictionary<string, string>(); var normalizedUrlsToOriginalUrls = new Dictionary<string, string>();
var duplicateUrlsToDelete = new HashSet<string>(); var duplicateUrlsToDelete = new HashSet<string>();
using (var selectCmd = conn.Command("SELECT url FROM downloads ORDER BY CASE WHEN status = 200 THEN 0 ELSE 1 END")) { await using (var selectCmd = conn.Command("SELECT url FROM downloads ORDER BY CASE WHEN status = 200 THEN 0 ELSE 1 END")) {
using var reader = selectCmd.ExecuteReader(); await using var reader = await selectCmd.ExecuteReaderAsync();
while (reader.Read()) { while (reader.Read()) {
var originalUrl = reader.GetString(0); var originalUrl = reader.GetString(0);
@@ -204,68 +212,106 @@ sealed class Schema {
} }
} }
using var tx = conn.BeginTransaction(); conn.Execute("PRAGMA cache_size = -20000");
using (var deleteCmd = conn.Delete("downloads", ("url", SqliteType.Text))) { SqliteTransaction tx;
await using (tx = conn.BeginTransaction()) {
await reporter.SubWork("Deleting duplicates...", 0, 0);
await using (var deleteCmd = conn.Delete("downloads", ("url", SqliteType.Text))) {
foreach (var duplicateUrl in duplicateUrlsToDelete) { foreach (var duplicateUrl in duplicateUrlsToDelete) {
deleteCmd.Set(":url", duplicateUrl); deleteCmd.Set(":url", duplicateUrl);
deleteCmd.ExecuteNonQuery(); deleteCmd.ExecuteNonQuery();
} }
} }
using (var updateCmd = conn.Command("UPDATE downloads SET download_url = :download_url, url = :normalized_url WHERE url = :download_url")) { await tx.CommitAsync();
updateCmd.Parameters.Add(":normalized_url", SqliteType.Text); }
updateCmd.Parameters.Add(":download_url", SqliteType.Text);
int totalUrls = normalizedUrlsToOriginalUrls.Count;
int processedUrls = -1;
tx = conn.BeginTransaction();
await using (var updateCmd = conn.Command("UPDATE downloads SET download_url = :download_url, url = :normalized_url WHERE url = :download_url")) {
updateCmd.Add(":normalized_url", SqliteType.Text);
updateCmd.Add(":download_url", SqliteType.Text);
foreach (var (normalizedUrl, downloadUrl) in normalizedUrlsToOriginalUrls) { foreach (var (normalizedUrl, downloadUrl) in normalizedUrlsToOriginalUrls) {
if (++processedUrls % 100 == 0) {
await reporter.SubWork("Updating URLs...", processedUrls, totalUrls);
// Not proper way of dealing with transactions, but it avoids a long commit at the end.
// Schema upgrades are already non-atomic anyways, so this doesn't make it worse.
await tx.CommitAsync();
await tx.DisposeAsync();
tx = conn.BeginTransaction();
updateCmd.Transaction = tx;
}
updateCmd.Set(":normalized_url", normalizedUrl); updateCmd.Set(":normalized_url", normalizedUrl);
updateCmd.Set(":download_url", downloadUrl); updateCmd.Set(":download_url", downloadUrl);
updateCmd.ExecuteNonQuery(); updateCmd.ExecuteNonQuery();
} }
} }
tx.Commit(); await reporter.SubWork("Updating URLs...", totalUrls, totalUrls);
await tx.CommitAsync();
await tx.DisposeAsync();
conn.Execute("PRAGMA cache_size = -2000");
} }
private void UpgradeSchemas(int dbVersion) { private async Task UpgradeSchemas(int dbVersion, ISchemaUpgradeCallbacks.IProgressReporter reporter) {
var perf = Log.Start("from version " + dbVersion); var perf = Log.Start("from version " + dbVersion);
Execute("UPDATE metadata SET value = " + Version + " WHERE key = 'version'"); conn.Execute("UPDATE metadata SET value = " + Version + " WHERE key = 'version'");
if (dbVersion <= 1) { if (dbVersion <= 1) {
Execute("ALTER TABLE channels ADD parent_id INTEGER"); await reporter.MainWork("Applying schema changes...", 0, 1);
conn.Execute("ALTER TABLE channels ADD parent_id INTEGER");
perf.Step("Upgrade to version 2"); perf.Step("Upgrade to version 2");
await reporter.NextVersion();
} }
if (dbVersion <= 2) { if (dbVersion <= 2) {
await reporter.MainWork("Applying schema changes...", 0, 1);
CreateMessageEditTimestampTable(); CreateMessageEditTimestampTable();
CreateMessageRepliedToTable(); CreateMessageRepliedToTable();
Execute(""" conn.Execute("""
INSERT INTO edit_timestamps (message_id, edit_timestamp) INSERT INTO edit_timestamps (message_id, edit_timestamp)
SELECT message_id, edit_timestamp SELECT message_id, edit_timestamp
FROM messages FROM messages
WHERE edit_timestamp IS NOT NULL WHERE edit_timestamp IS NOT NULL
"""); """);
Execute(""" conn.Execute("""
INSERT INTO replied_to (message_id, replied_to_id) INSERT INTO replied_to (message_id, replied_to_id)
SELECT message_id, replied_to_id SELECT message_id, replied_to_id
FROM messages FROM messages
WHERE replied_to_id IS NOT NULL WHERE replied_to_id IS NOT NULL
"""); """);
Execute("ALTER TABLE messages DROP COLUMN replied_to_id"); conn.Execute("ALTER TABLE messages DROP COLUMN replied_to_id");
Execute("ALTER TABLE messages DROP COLUMN edit_timestamp"); conn.Execute("ALTER TABLE messages DROP COLUMN edit_timestamp");
perf.Step("Upgrade to version 3"); perf.Step("Upgrade to version 3");
Execute("VACUUM"); await reporter.MainWork("Vacuuming the database...", 1, 1);
conn.Execute("VACUUM");
perf.Step("Vacuum"); perf.Step("Vacuum");
await reporter.NextVersion();
} }
if (dbVersion <= 3) { if (dbVersion <= 3) {
Execute(""" conn.Execute("""
CREATE TABLE downloads ( CREATE TABLE downloads (
url TEXT NOT NULL PRIMARY KEY, url TEXT NOT NULL PRIMARY KEY,
status INTEGER NOT NULL, status INTEGER NOT NULL,
@@ -275,25 +321,35 @@ sealed class Schema {
"""); """);
perf.Step("Upgrade to version 4"); perf.Step("Upgrade to version 4");
await reporter.NextVersion();
} }
if (dbVersion <= 4) { if (dbVersion <= 4) {
Execute("ALTER TABLE attachments ADD width INTEGER"); await reporter.MainWork("Applying schema changes...", 0, 1);
Execute("ALTER TABLE attachments ADD height INTEGER"); conn.Execute("ALTER TABLE attachments ADD width INTEGER");
conn.Execute("ALTER TABLE attachments ADD height INTEGER");
perf.Step("Upgrade to version 5"); perf.Step("Upgrade to version 5");
await reporter.NextVersion();
} }
if (dbVersion <= 5) { if (dbVersion <= 5) {
Execute("ALTER TABLE attachments ADD download_url TEXT"); await reporter.MainWork("Applying schema changes...", 0, 3);
Execute("ALTER TABLE downloads ADD download_url TEXT"); conn.Execute("ALTER TABLE attachments ADD download_url TEXT");
conn.Execute("ALTER TABLE downloads ADD download_url TEXT");
NormalizeAttachmentUrls(); await reporter.MainWork("Updating attachments...", 1, 3);
NormalizeDownloadUrls(); await NormalizeAttachmentUrls(reporter);
Execute("ALTER TABLE attachments RENAME COLUMN url TO normalized_url"); await reporter.MainWork("Updating downloads...", 2, 3);
Execute("ALTER TABLE downloads RENAME COLUMN url TO normalized_url"); await NormalizeDownloadUrls(reporter);
await reporter.MainWork("Applying schema changes...", 3, 3);
conn.Execute("ALTER TABLE attachments RENAME COLUMN url TO normalized_url");
conn.Execute("ALTER TABLE downloads RENAME COLUMN url TO normalized_url");
perf.Step("Upgrade to version 6"); perf.Step("Upgrade to version 6");
await reporter.NextVersion();
} }
perf.End(); perf.End();

View File

@@ -18,7 +18,7 @@ namespace DHT.Server.Database.Sqlite;
public sealed class SqliteDatabaseFile : IDatabaseFile { public sealed class SqliteDatabaseFile : IDatabaseFile {
private const int DefaultPoolSize = 5; private const int DefaultPoolSize = 5;
public static async Task<SqliteDatabaseFile?> OpenOrCreate(string path, Func<Task<bool>> checkCanUpgradeSchemas) { public static async Task<SqliteDatabaseFile?> OpenOrCreate(string path, ISchemaUpgradeCallbacks schemaUpgradeCallbacks, TaskScheduler computeTaskResultScheduler) {
var connectionString = new SqliteConnectionStringBuilder { var connectionString = new SqliteConnectionStringBuilder {
DataSource = path, DataSource = path,
Mode = SqliteOpenMode.ReadWriteCreate, Mode = SqliteOpenMode.ReadWriteCreate,
@@ -27,12 +27,16 @@ public sealed class SqliteDatabaseFile : IDatabaseFile {
var pool = new SqliteConnectionPool(connectionString, DefaultPoolSize); var pool = new SqliteConnectionPool(connectionString, DefaultPoolSize);
bool wasOpened; bool wasOpened;
using (var conn = pool.Take()) { try {
wasOpened = await new Schema(conn).Setup(checkCanUpgradeSchemas); using var conn = pool.Take();
wasOpened = await new Schema(conn).Setup(schemaUpgradeCallbacks);
} catch (Exception) {
pool.Dispose();
throw;
} }
if (wasOpened) { if (wasOpened) {
return new SqliteDatabaseFile(path, pool); return new SqliteDatabaseFile(path, pool, computeTaskResultScheduler);
} }
else { else {
pool.Dispose(); pool.Dispose();
@@ -49,13 +53,13 @@ public sealed class SqliteDatabaseFile : IDatabaseFile {
private readonly AsyncValueComputer<long>.Single totalAttachmentsComputer; private readonly AsyncValueComputer<long>.Single totalAttachmentsComputer;
private readonly AsyncValueComputer<long>.Single totalDownloadsComputer; private readonly AsyncValueComputer<long>.Single totalDownloadsComputer;
private SqliteDatabaseFile(string path, SqliteConnectionPool pool) { private SqliteDatabaseFile(string path, SqliteConnectionPool pool, TaskScheduler computeTaskResultScheduler) {
this.log = Log.ForType(typeof(SqliteDatabaseFile), System.IO.Path.GetFileName(path)); this.log = Log.ForType(typeof(SqliteDatabaseFile), System.IO.Path.GetFileName(path));
this.pool = pool; this.pool = pool;
this.totalMessagesComputer = AsyncValueComputer<long>.WithResultProcessor(UpdateMessageStatistics).WithOutdatedResults().BuildWithComputer(ComputeMessageStatistics); this.totalMessagesComputer = AsyncValueComputer<long>.WithResultProcessor(UpdateMessageStatistics, computeTaskResultScheduler).WithOutdatedResults().BuildWithComputer(ComputeMessageStatistics);
this.totalAttachmentsComputer = AsyncValueComputer<long>.WithResultProcessor(UpdateAttachmentStatistics).WithOutdatedResults().BuildWithComputer(ComputeAttachmentStatistics); this.totalAttachmentsComputer = AsyncValueComputer<long>.WithResultProcessor(UpdateAttachmentStatistics, computeTaskResultScheduler).WithOutdatedResults().BuildWithComputer(ComputeAttachmentStatistics);
this.totalDownloadsComputer = AsyncValueComputer<long>.WithResultProcessor(UpdateDownloadStatistics).WithOutdatedResults().BuildWithComputer(ComputeDownloadStatistics); this.totalDownloadsComputer = AsyncValueComputer<long>.WithResultProcessor(UpdateDownloadStatistics, computeTaskResultScheduler).WithOutdatedResults().BuildWithComputer(ComputeDownloadStatistics);
this.Path = path; this.Path = path;
this.Statistics = new DatabaseStatistics(); this.Statistics = new DatabaseStatistics();
@@ -518,25 +522,42 @@ public sealed class SqliteDatabaseFile : IDatabaseFile {
cmd.ExecuteNonQuery(); cmd.ExecuteNonQuery();
} }
public List<DownloadItem> GetEnqueuedDownloadItems(int count) { public List<DownloadItem> PullEnqueuedDownloadItems(int count) {
var list = new List<DownloadItem>(); var found = new List<DownloadItem>();
var pulled = new List<DownloadItem>();
using var conn = pool.Take(); using var conn = pool.Take();
using var cmd = conn.Command("SELECT normalized_url, download_url, size FROM downloads WHERE status = :enqueued LIMIT :limit"); using (var cmd = conn.Command("SELECT normalized_url, download_url, size FROM downloads WHERE status = :enqueued LIMIT :limit")) {
cmd.AddAndSet(":enqueued", SqliteType.Integer, (int) DownloadStatus.Enqueued); cmd.AddAndSet(":enqueued", SqliteType.Integer, (int) DownloadStatus.Enqueued);
cmd.AddAndSet(":limit", SqliteType.Integer, Math.Max(0, count)); cmd.AddAndSet(":limit", SqliteType.Integer, Math.Max(0, count));
using var reader = cmd.ExecuteReader(); using var reader = cmd.ExecuteReader();
while (reader.Read()) { while (reader.Read()) {
list.Add(new DownloadItem { found.Add(new DownloadItem {
NormalizedUrl = reader.GetString(0), NormalizedUrl = reader.GetString(0),
DownloadUrl = reader.GetString(1), DownloadUrl = reader.GetString(1),
Size = reader.GetUint64(2), Size = reader.GetUint64(2),
}); });
} }
}
return list; if (found.Count != 0) {
using var cmd = conn.Command("UPDATE downloads SET status = :downloading WHERE normalized_url = :normalized_url AND status = :enqueued");
cmd.AddAndSet(":enqueued", SqliteType.Integer, (int) DownloadStatus.Enqueued);
cmd.AddAndSet(":downloading", SqliteType.Integer, (int) DownloadStatus.Downloading);
cmd.Add(":normalized_url", SqliteType.Text);
foreach (var item in found) {
cmd.Set(":normalized_url", item.NormalizedUrl);
if (cmd.ExecuteNonQuery() == 1) {
pulled.Add(item);
}
}
}
return pulled;
} }
public void RemoveDownloadItems(DownloadItemFilter? filter, FilterRemovalMode mode) { public void RemoveDownloadItems(DownloadItemFilter? filter, FilterRemovalMode mode) {
@@ -558,15 +579,16 @@ public sealed class SqliteDatabaseFile : IDatabaseFile {
static void LoadSuccessStatistics(ISqliteConnection conn, DownloadStatusStatistics result) { static void LoadSuccessStatistics(ISqliteConnection conn, DownloadStatusStatistics result) {
using var cmd = conn.Command(""" using var cmd = conn.Command("""
SELECT SELECT
IFNULL(SUM(CASE WHEN status = :enqueued THEN 1 ELSE 0 END), 0), IFNULL(SUM(CASE WHEN status IN (:enqueued, :downloading) THEN 1 ELSE 0 END), 0),
IFNULL(SUM(CASE WHEN status = :enqueued THEN size ELSE 0 END), 0), IFNULL(SUM(CASE WHEN status IN (:enqueued, :downloading) THEN size ELSE 0 END), 0),
IFNULL(SUM(CASE WHEN status = :success THEN 1 ELSE 0 END), 0), IFNULL(SUM(CASE WHEN status = :success THEN 1 ELSE 0 END), 0),
IFNULL(SUM(CASE WHEN status = :success THEN size ELSE 0 END), 0), IFNULL(SUM(CASE WHEN status = :success THEN size ELSE 0 END), 0),
IFNULL(SUM(CASE WHEN status != :enqueued AND status != :success THEN 1 ELSE 0 END), 0), IFNULL(SUM(CASE WHEN status NOT IN (:enqueued, :downloading) AND status != :success THEN 1 ELSE 0 END), 0),
IFNULL(SUM(CASE WHEN status != :enqueued AND status != :success THEN size ELSE 0 END), 0) IFNULL(SUM(CASE WHEN status NOT IN (:enqueued, :downloading) AND status != :success THEN size ELSE 0 END), 0)
FROM downloads FROM downloads
"""); """);
cmd.AddAndSet(":enqueued", SqliteType.Integer, (int) DownloadStatus.Enqueued); cmd.AddAndSet(":enqueued", SqliteType.Integer, (int) DownloadStatus.Enqueued);
cmd.AddAndSet(":downloading", SqliteType.Integer, (int) DownloadStatus.Downloading);
cmd.AddAndSet(":success", SqliteType.Integer, (int) DownloadStatus.Success); cmd.AddAndSet(":success", SqliteType.Integer, (int) DownloadStatus.Success);
using var reader = cmd.ExecuteReader(); using var reader = cmd.ExecuteReader();

View File

@@ -5,14 +5,19 @@ using Microsoft.Data.Sqlite;
namespace DHT.Server.Database.Sqlite.Utils; namespace DHT.Server.Database.Sqlite.Utils;
static class SqliteExtensions { static class SqliteExtensions {
public static SqliteTransaction BeginTransaction(this ISqliteConnection conn) {
return conn.InnerConnection.BeginTransaction();
}
public static SqliteCommand Command(this ISqliteConnection conn, string sql) { public static SqliteCommand Command(this ISqliteConnection conn, string sql) {
var cmd = conn.InnerConnection.CreateCommand(); var cmd = conn.InnerConnection.CreateCommand();
cmd.CommandText = sql; cmd.CommandText = sql;
return cmd; return cmd;
} }
public static SqliteTransaction BeginTransaction(this ISqliteConnection conn) { public static void Execute(this ISqliteConnection conn, string sql) {
return conn.InnerConnection.BeginTransaction(); using var cmd = conn.Command(sql);
cmd.ExecuteNonQuery();
} }
public static object? SelectScalar(this ISqliteConnection conn, string sql) { public static object? SelectScalar(this ISqliteConnection conn, string sql) {
@@ -57,6 +62,10 @@ static class SqliteExtensions {
} }
} }
public static void Add(this SqliteCommand cmd, string key, SqliteType type) {
cmd.Parameters.Add(key, type);
}
public static void AddAndSet(this SqliteCommand cmd, string key, SqliteType type, object? value) { public static void AddAndSet(this SqliteCommand cmd, string key, SqliteType type, object? value) {
cmd.Parameters.Add(key, type).Value = value ?? DBNull.Value; cmd.Parameters.Add(key, type).Value = value ?? DBNull.Value;
} }

View File

@@ -1,130 +0,0 @@
using System;
using System.Collections.Concurrent;
using System.Net.Http;
using System.Threading;
using DHT.Server.Database;
using DHT.Utils.Logging;
using DHT.Utils.Models;
namespace DHT.Server.Download;
public sealed class BackgroundDownloadThread : BaseModel {
private static readonly Log Log = Log.ForType<BackgroundDownloadThread>();
public event EventHandler<DownloadItem>? OnItemFinished {
add => parameters.OnItemFinished += value;
remove => parameters.OnItemFinished -= value;
}
public event EventHandler? OnServerStopped {
add => parameters.OnServerStopped += value;
remove => parameters.OnServerStopped -= value;
}
private readonly CancellationTokenSource cancellationTokenSource;
private readonly ThreadInstance.Parameters parameters;
public BackgroundDownloadThread(IDatabaseFile db) {
this.cancellationTokenSource = new CancellationTokenSource();
this.parameters = new ThreadInstance.Parameters(db, cancellationTokenSource);
var thread = new Thread(new ThreadInstance().Work) {
Name = "DHT download thread"
};
thread.Start(parameters);
}
public void StopThread() {
try {
cancellationTokenSource.Cancel();
} catch (ObjectDisposedException) {
Log.Warn("Attempted to stop background download thread after the cancellation token has been disposed.");
}
}
private sealed class ThreadInstance {
private const int QueueSize = 32;
public sealed class Parameters {
public event EventHandler<DownloadItem>? OnItemFinished;
public event EventHandler? OnServerStopped;
public IDatabaseFile Db { get; }
public CancellationTokenSource CancellationTokenSource { get; }
public Parameters(IDatabaseFile db, CancellationTokenSource cancellationTokenSource) {
Db = db;
CancellationTokenSource = cancellationTokenSource;
}
public void FireOnItemFinished(DownloadItem item) {
OnItemFinished?.Invoke(null, item);
}
public void FireOnServerStopped() {
OnServerStopped?.Invoke(null, EventArgs.Empty);
}
}
private readonly HttpClient client = new ();
public ThreadInstance() {
client.DefaultRequestHeaders.UserAgent.ParseAdd("Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/99.0.4844.51 Safari/537.36");
}
public async void Work(object? obj) {
var parameters = (Parameters) obj!;
var cancellationTokenSource = parameters.CancellationTokenSource;
var cancellationToken = cancellationTokenSource.Token;
var db = parameters.Db;
var queue = new ConcurrentQueue<DownloadItem>();
try {
while (!cancellationToken.IsCancellationRequested) {
FillQueue(db, queue, cancellationToken);
while (!cancellationToken.IsCancellationRequested && queue.TryDequeue(out var item)) {
var downloadUrl = item.DownloadUrl;
Log.Debug("Downloading " + downloadUrl + "...");
try {
db.AddDownload(Data.Download.NewSuccess(item, await client.GetByteArrayAsync(downloadUrl, cancellationToken)));
} catch (HttpRequestException e) {
db.AddDownload(Data.Download.NewFailure(item, e.StatusCode, item.Size));
Log.Error(e);
} catch (Exception e) {
db.AddDownload(Data.Download.NewFailure(item, null, item.Size));
Log.Error(e);
} finally {
parameters.FireOnItemFinished(item);
}
}
}
} catch (OperationCanceledException) {
//
} catch (ObjectDisposedException) {
//
} finally {
cancellationTokenSource.Dispose();
parameters.FireOnServerStopped();
}
}
private static void FillQueue(IDatabaseFile db, ConcurrentQueue<DownloadItem> queue, CancellationToken cancellationToken) {
while (!cancellationToken.IsCancellationRequested && queue.IsEmpty) {
var newItems = db.GetEnqueuedDownloadItems(QueueSize);
if (newItems.Count == 0) {
Thread.Sleep(TimeSpan.FromMilliseconds(50));
}
else {
foreach (var item in newItems) {
queue.Enqueue(item);
}
}
}
}
}
}

View File

@@ -0,0 +1,40 @@
using System;
using System.Threading;
using System.Threading.Tasks;
using DHT.Server.Database;
namespace DHT.Server.Download;
public sealed class Downloader {
private DownloaderTask? current;
public bool IsDownloading => current != null;
private readonly IDatabaseFile db;
private readonly SemaphoreSlim semaphore = new (1, 1);
internal Downloader(IDatabaseFile db) {
this.db = db;
}
public async Task<IObservable<DownloadItem>> Start() {
await semaphore.WaitAsync();
try {
current ??= new DownloaderTask(db);
return current.FinishedItems;
} finally {
semaphore.Release();
}
}
public async Task Stop() {
await semaphore.WaitAsync();
try {
if (current != null) {
await current.DisposeAsync();
current = null;
}
} finally {
semaphore.Release();
}
}
}

View File

@@ -0,0 +1,110 @@
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.Tasks;
namespace DHT.Server.Download;
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";
private readonly Channel<DownloadItem> downloadQueue = Channel.CreateBounded<DownloadItem>(new BoundedChannelOptions(QueueSize) {
SingleReader = false,
SingleWriter = true,
AllowSynchronousContinuations = false,
FullMode = BoundedChannelFullMode.Wait
});
private readonly CancellationTokenSource cancellationTokenSource = new ();
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.db = db;
this.cancellationToken = cancellationTokenSource.Token;
this.queueWriterTask = Task.Run(RunQueueWriterTask);
this.downloadTasks = Enumerable.Range(1, DownloadTasks).Select(taskIndex => Task.Run(() => RunDownloadTask(taskIndex))).ToArray();
}
private async Task RunQueueWriterTask() {
while (await downloadQueue.Writer.WaitToWriteAsync(cancellationToken)) {
var newItems = db.PullEnqueuedDownloadItems(QueueSize);
if (newItems.Count == 0) {
await Task.Delay(TimeSpan.FromMilliseconds(50), cancellationToken);
continue;
}
foreach (var newItem in newItems) {
await downloadQueue.Writer.WriteAsync(newItem, cancellationToken);
}
}
}
private async Task RunDownloadTask(int taskIndex) {
var log = Log.ForType<DownloaderTask>("Task " + taskIndex);
var client = new HttpClient();
client.DefaultRequestHeaders.UserAgent.ParseAdd(UserAgent);
client.Timeout = TimeSpan.FromSeconds(30);
while (!cancellationToken.IsCancellationRequested) {
var item = await downloadQueue.Reader.ReadAsync(cancellationToken);
log.Debug("Downloading " + item.DownloadUrl + "...");
try {
var downloadedBytes = await client.GetByteArrayAsync(item.DownloadUrl, cancellationToken);
db.AddDownload(Data.Download.NewSuccess(item, downloadedBytes));
} catch (OperationCanceledException) {
// Ignore.
} catch (HttpRequestException e) {
db.AddDownload(Data.Download.NewFailure(item, e.StatusCode, item.Size));
log.Error(e);
} catch (Exception e) {
db.AddDownload(Data.Download.NewFailure(item, null, item.Size));
log.Error(e);
} finally {
try {
finishedItemPublisher.OnNext(item);
} catch (Exception e) {
log.Error("Caught exception in event handler: " + e);
}
}
}
}
public async ValueTask DisposeAsync() {
try {
await cancellationTokenSource.CancelAsync();
} catch (Exception) {
Log.Warn("Attempted to stop background download twice.");
return;
}
downloadQueue.Writer.Complete();
try {
await queueWriterTask.WaitIgnoringCancellation();
await Task.WhenAll(downloadTasks).WaitIgnoringCancellation();
} finally {
cancellationTokenSource.Dispose();
finishedItemPublisher.OnCompleted();
}
}
}

View File

@@ -3,12 +3,9 @@ using System.Net;
using System.Text.Json; using System.Text.Json;
using System.Threading.Tasks; using System.Threading.Tasks;
using DHT.Server.Database; using DHT.Server.Database;
using DHT.Server.Service;
using DHT.Utils.Http; using DHT.Utils.Http;
using DHT.Utils.Logging; using DHT.Utils.Logging;
using Microsoft.AspNetCore.Http; using Microsoft.AspNetCore.Http;
using Microsoft.AspNetCore.Http.Extensions;
using Microsoft.Extensions.Primitives;
namespace DHT.Server.Endpoints; namespace DHT.Server.Endpoints;
@@ -16,25 +13,14 @@ abstract class BaseEndpoint {
private static readonly Log Log = Log.ForType<BaseEndpoint>(); private static readonly Log Log = Log.ForType<BaseEndpoint>();
protected IDatabaseFile Db { get; } protected IDatabaseFile Db { get; }
protected ServerParameters Parameters { get; }
protected BaseEndpoint(IDatabaseFile db, ServerParameters parameters) { protected BaseEndpoint(IDatabaseFile db) {
this.Db = db; this.Db = db;
this.Parameters = parameters;
} }
private async Task Handle(HttpContext ctx, StringValues token) { public async Task Handle(HttpContext ctx) {
var request = ctx.Request;
var response = ctx.Response; var response = ctx.Response;
Log.Info("Request: " + request.GetDisplayUrl() + " (" + request.ContentLength + " B)");
if (token.Count != 1 || token[0] != Parameters.Token) {
Log.Error("Token: " + (token.Count == 1 ? token[0] : "<missing>"));
response.StatusCode = (int) HttpStatusCode.Forbidden;
return;
}
try { try {
response.StatusCode = (int) HttpStatusCode.OK; response.StatusCode = (int) HttpStatusCode.OK;
var output = await Respond(ctx); var output = await Respond(ctx);
@@ -49,17 +35,13 @@ abstract class BaseEndpoint {
} }
} }
public async Task HandleGet(HttpContext ctx) {
await Handle(ctx, ctx.Request.Query["token"]);
}
public async Task HandlePost(HttpContext ctx) {
await Handle(ctx, ctx.Request.Headers["X-DHT-Token"]);
}
protected abstract Task<IHttpOutput> Respond(HttpContext ctx); protected abstract Task<IHttpOutput> Respond(HttpContext ctx);
protected static async Task<JsonElement> ReadJson(HttpContext ctx) { protected static async Task<JsonElement> ReadJson(HttpContext ctx) {
return await ctx.Request.ReadFromJsonAsync<JsonElement?>() ?? throw new HttpException(HttpStatusCode.UnsupportedMediaType, "This endpoint only accepts JSON."); try {
return await ctx.Request.ReadFromJsonAsync(JsonElementContext.Default.JsonElement);
} catch (JsonException) {
throw new HttpException(HttpStatusCode.UnsupportedMediaType, "This endpoint only accepts JSON.");
}
} }
} }

View File

@@ -2,14 +2,13 @@ using System.Net;
using System.Threading.Tasks; using System.Threading.Tasks;
using DHT.Server.Data; using DHT.Server.Data;
using DHT.Server.Database; using DHT.Server.Database;
using DHT.Server.Service;
using DHT.Utils.Http; using DHT.Utils.Http;
using Microsoft.AspNetCore.Http; using Microsoft.AspNetCore.Http;
namespace DHT.Server.Endpoints; namespace DHT.Server.Endpoints;
sealed class GetAttachmentEndpoint : BaseEndpoint { sealed class GetAttachmentEndpoint : BaseEndpoint {
public GetAttachmentEndpoint(IDatabaseFile db, ServerParameters parameters) : base(db, parameters) {} public GetAttachmentEndpoint(IDatabaseFile db) : base(db) {}
protected override Task<IHttpOutput> Respond(HttpContext ctx) { protected override Task<IHttpOutput> Respond(HttpContext ctx) {
string attachmentUrl = WebUtility.UrlDecode((string) ctx.Request.RouteValues["url"]!); string attachmentUrl = WebUtility.UrlDecode((string) ctx.Request.RouteValues["url"]!);

View File

@@ -13,12 +13,16 @@ namespace DHT.Server.Endpoints;
sealed class GetTrackingScriptEndpoint : BaseEndpoint { sealed class GetTrackingScriptEndpoint : BaseEndpoint {
private static ResourceLoader Resources { get; } = new (Assembly.GetExecutingAssembly()); private static ResourceLoader Resources { get; } = new (Assembly.GetExecutingAssembly());
public GetTrackingScriptEndpoint(IDatabaseFile db, ServerParameters parameters) : base(db, parameters) {} private readonly ServerParameters serverParameters;
public GetTrackingScriptEndpoint(IDatabaseFile db, ServerParameters parameters) : base(db) {
serverParameters = parameters;
}
protected override async Task<IHttpOutput> Respond(HttpContext ctx) { protected override async Task<IHttpOutput> Respond(HttpContext ctx) {
string bootstrap = await Resources.ReadTextAsync("Tracker/bootstrap.js"); string bootstrap = await Resources.ReadTextAsync("Tracker/bootstrap.js");
string script = bootstrap.Replace("= 0; /*[PORT]*/", "= " + Parameters.Port + ";") string script = bootstrap.Replace("= 0; /*[PORT]*/", "= " + serverParameters.Port + ";")
.Replace("/*[TOKEN]*/", HttpUtility.JavaScriptStringEncode(Parameters.Token)) .Replace("/*[TOKEN]*/", HttpUtility.JavaScriptStringEncode(serverParameters.Token))
.Replace("/*[IMPORTS]*/", await Resources.ReadJoinedAsync("Tracker/scripts/", '\n')) .Replace("/*[IMPORTS]*/", await Resources.ReadJoinedAsync("Tracker/scripts/", '\n'))
.Replace("/*[CSS-CONTROLLER]*/", await Resources.ReadTextAsync("Tracker/styles/controller.css")) .Replace("/*[CSS-CONTROLLER]*/", await Resources.ReadTextAsync("Tracker/styles/controller.css"))
.Replace("/*[CSS-SETTINGS]*/", await Resources.ReadTextAsync("Tracker/styles/settings.css")) .Replace("/*[CSS-SETTINGS]*/", await Resources.ReadTextAsync("Tracker/styles/settings.css"))

View File

@@ -3,14 +3,13 @@ using System.Text.Json;
using System.Threading.Tasks; using System.Threading.Tasks;
using DHT.Server.Data; using DHT.Server.Data;
using DHT.Server.Database; using DHT.Server.Database;
using DHT.Server.Service;
using DHT.Utils.Http; using DHT.Utils.Http;
using Microsoft.AspNetCore.Http; using Microsoft.AspNetCore.Http;
namespace DHT.Server.Endpoints; namespace DHT.Server.Endpoints;
sealed class TrackChannelEndpoint : BaseEndpoint { sealed class TrackChannelEndpoint : BaseEndpoint {
public TrackChannelEndpoint(IDatabaseFile db, ServerParameters parameters) : base(db, parameters) {} public TrackChannelEndpoint(IDatabaseFile db) : base(db) {}
protected override async Task<IHttpOutput> Respond(HttpContext ctx) { protected override async Task<IHttpOutput> Respond(HttpContext ctx) {
var root = await ReadJson(ctx); var root = await ReadJson(ctx);

View File

@@ -9,7 +9,6 @@ using DHT.Server.Data;
using DHT.Server.Data.Filters; using DHT.Server.Data.Filters;
using DHT.Server.Database; using DHT.Server.Database;
using DHT.Server.Download; using DHT.Server.Download;
using DHT.Server.Service;
using DHT.Utils.Collections; using DHT.Utils.Collections;
using DHT.Utils.Http; using DHT.Utils.Http;
using Microsoft.AspNetCore.Http; using Microsoft.AspNetCore.Http;
@@ -17,7 +16,10 @@ using Microsoft.AspNetCore.Http;
namespace DHT.Server.Endpoints; namespace DHT.Server.Endpoints;
sealed class TrackMessagesEndpoint : BaseEndpoint { sealed class TrackMessagesEndpoint : BaseEndpoint {
public TrackMessagesEndpoint(IDatabaseFile db, ServerParameters parameters) : base(db, parameters) {} private const string HasNewMessages = "1";
private const string NoNewMessages = "0";
public TrackMessagesEndpoint(IDatabaseFile db) : base(db) {}
protected override async Task<IHttpOutput> Respond(HttpContext ctx) { protected override async Task<IHttpOutput> Respond(HttpContext ctx) {
var root = await ReadJson(ctx); var root = await ReadJson(ctx);
@@ -41,7 +43,7 @@ sealed class TrackMessagesEndpoint : BaseEndpoint {
Db.AddMessages(messages); Db.AddMessages(messages);
return new HttpOutput.Json(anyNewMessages ? 1 : 0); return new HttpOutput.Text(anyNewMessages ? HasNewMessages : NoNewMessages);
} }
private static Message ReadMessage(JsonElement json, string path) => new() { private static Message ReadMessage(JsonElement json, string path) => new() {

View File

@@ -3,14 +3,13 @@ using System.Text.Json;
using System.Threading.Tasks; using System.Threading.Tasks;
using DHT.Server.Data; using DHT.Server.Data;
using DHT.Server.Database; using DHT.Server.Database;
using DHT.Server.Service;
using DHT.Utils.Http; using DHT.Utils.Http;
using Microsoft.AspNetCore.Http; using Microsoft.AspNetCore.Http;
namespace DHT.Server.Endpoints; namespace DHT.Server.Endpoints;
sealed class TrackUsersEndpoint : BaseEndpoint { sealed class TrackUsersEndpoint : BaseEndpoint {
public TrackUsersEndpoint(IDatabaseFile db, ServerParameters parameters) : base(db, parameters) {} public TrackUsersEndpoint(IDatabaseFile db) : base(db) {}
protected override async Task<IHttpOutput> Respond(HttpContext ctx) { protected override async Task<IHttpOutput> Respond(HttpContext ctx) {
var root = await ReadJson(ctx); var root = await ReadJson(ctx);

View File

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

View File

@@ -0,0 +1,44 @@
using System.Net;
using System.Threading.Tasks;
using DHT.Utils.Logging;
using Microsoft.AspNetCore.Http;
using Microsoft.Extensions.Primitives;
namespace DHT.Server.Service.Middlewares;
sealed class ServerAuthorizationMiddleware {
private static readonly Log Log = Log.ForType<ServerAuthorizationMiddleware>();
private readonly RequestDelegate next;
private readonly ServerParameters serverParameters;
public ServerAuthorizationMiddleware(RequestDelegate next, ServerParameters serverParameters) {
this.next = next;
this.serverParameters = serverParameters;
}
public async Task InvokeAsync(HttpContext context) {
var request = context.Request;
bool success = HttpMethods.IsGet(request.Method)
? CheckToken(request.Query["token"])
: CheckToken(request.Headers["X-DHT-Token"]);
if (success) {
await next(context);
}
else {
context.Response.StatusCode = (int) HttpStatusCode.Forbidden;
}
}
private bool CheckToken(StringValues token) {
if (token.Count == 1 && token[0] == serverParameters.Token) {
return true;
}
else {
Log.Error("Invalid token: " + (token.Count == 1 ? token[0] : "<missing>"));
return false;
}
}
}

View File

@@ -0,0 +1,29 @@
using System.Diagnostics;
using System.Threading.Tasks;
using DHT.Utils.Logging;
using Microsoft.AspNetCore.Http;
using Microsoft.AspNetCore.Http.Extensions;
namespace DHT.Server.Service.Middlewares;
sealed class ServerLoggingMiddleware {
private static readonly Log Log = Log.ForType<ServerLoggingMiddleware>();
private readonly RequestDelegate next;
public ServerLoggingMiddleware(RequestDelegate next) {
this.next = next;
}
public async Task InvokeAsync(HttpContext context) {
var stopwatch = Stopwatch.StartNew();
await next(context);
stopwatch.Stop();
var request = context.Request;
var requestLength = request.ContentLength ?? 0L;
var responseStatus = context.Response.StatusCode;
var elapsedMs = stopwatch.ElapsedMilliseconds;
Log.Debug("Request to " + request.GetEncodedPathAndQuery() + " (" + requestLength + " B) returned " + responseStatus + ", took " + elapsedMs + " ms");
}
}

View File

@@ -1,127 +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;
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 = WebHost.CreateDefaultBuilder()
.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.Diagnostics.CodeAnalysis;
using System.Text.Json.Serialization; using System.Text.Json.Serialization;
using DHT.Server.Database; using DHT.Server.Database;
using DHT.Server.Endpoints; using DHT.Server.Endpoints;
using DHT.Server.Service.Middlewares;
using Microsoft.AspNetCore.Builder; using Microsoft.AspNetCore.Builder;
using Microsoft.AspNetCore.Http.Json; using Microsoft.AspNetCore.Http.Json;
using Microsoft.Extensions.DependencyInjection; using Microsoft.Extensions.DependencyInjection;
@@ -27,27 +28,23 @@ sealed class Startup {
builder.WithOrigins(AllowedOrigins).AllowCredentials().AllowAnyMethod().AllowAnyHeader().WithExposedHeaders("X-DHT"); builder.WithOrigins(AllowedOrigins).AllowCredentials().AllowAnyMethod().AllowAnyHeader().WithExposedHeaders("X-DHT");
}); });
}); });
services.AddRoutingCore();
} }
[SuppressMessage("ReSharper", "UnusedMember.Global")] [SuppressMessage("ReSharper", "UnusedMember.Global")]
public void Configure(IApplicationBuilder app, IHostApplicationLifetime lifetime, IDatabaseFile db, ServerParameters parameters) { public void Configure(IApplicationBuilder app, IHostApplicationLifetime lifetime, IDatabaseFile db, ServerParameters parameters) {
app.UseRouting(); app.UseMiddleware<ServerLoggingMiddleware>();
app.UseCors(); app.UseCors();
app.UseMiddleware<ServerAuthorizationMiddleware>();
app.UseRouting();
app.UseEndpoints(endpoints => { app.UseEndpoints(endpoints => {
GetTrackingScriptEndpoint getTrackingScript = new (db, parameters); endpoints.MapGet("/get-tracking-script", new GetTrackingScriptEndpoint(db, parameters).Handle);
endpoints.MapGet("/get-tracking-script", context => getTrackingScript.HandleGet(context)); endpoints.MapGet("/get-attachment/{url}", new GetAttachmentEndpoint(db).Handle);
endpoints.MapPost("/track-channel", new TrackChannelEndpoint(db).Handle);
TrackChannelEndpoint trackChannel = new (db, parameters); endpoints.MapPost("/track-users", new TrackUsersEndpoint(db).Handle);
endpoints.MapPost("/track-channel", context => trackChannel.HandlePost(context)); endpoints.MapPost("/track-messages", new TrackMessagesEndpoint(db).Handle);
TrackUsersEndpoint trackUsers = new (db, parameters);
endpoints.MapPost("/track-users", context => trackUsers.HandlePost(context));
TrackMessagesEndpoint trackMessages = new (db, parameters);
endpoints.MapPost("/track-messages", context => trackMessages.HandlePost(context));
GetAttachmentEndpoint getAttachment = new (db, parameters);
endpoints.MapGet("/get-attachment/{url}", context => getAttachment.HandleGet(context));
}); });
} }
} }

27
app/Server/State.cs Normal file
View File

@@ -0,0 +1,27 @@
using System;
using System.Threading.Tasks;
using DHT.Server.Database;
using DHT.Server.Download;
using DHT.Server.Service;
namespace DHT.Server;
public sealed class State : IAsyncDisposable {
public static State Dummy { get; } = new (DummyDatabaseFile.Instance);
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();
}
}

View File

@@ -1,3 +1,4 @@
using System.Text;
using System.Threading.Tasks; using System.Threading.Tasks;
using Microsoft.AspNetCore.Http; using Microsoft.AspNetCore.Http;
@@ -12,15 +13,15 @@ public static class HttpOutput {
} }
} }
public sealed class Json : IHttpOutput { public sealed class Text : IHttpOutput {
private readonly object? obj; private readonly string text;
public Json(object? obj) { public Text(string text) {
this.obj = obj; this.text = text;
} }
public Task WriteTo(HttpResponse response) { public Task WriteTo(HttpResponse response) {
return response.WriteAsJsonAsync(obj); return response.WriteAsync(text, Encoding.UTF8);
} }
} }

View File

@@ -0,0 +1,8 @@
using System.Text.Json;
using System.Text.Json.Serialization;
namespace DHT.Utils.Http;
[JsonSourceGenerationOptions(PropertyNamingPolicy = JsonKnownNamingPolicy.CamelCase, GenerationMode = JsonSourceGenerationMode.Default)]
[JsonSerializable(typeof(JsonElement))]
public sealed partial class JsonElementContext : JsonSerializerContext {}

View File

@@ -0,0 +1,13 @@
using System.Runtime.InteropServices;
using System.Runtime.Versioning;
namespace DHT.Utils.Logging;
[SupportedOSPlatform("windows")]
public static partial class WindowsConsole {
[LibraryImport("kernel32.dll", SetLastError = true)]
public static partial void AllocConsole();
[LibraryImport("kernel32.dll", SetLastError = true)]
public static partial void FreeConsole();
}

View File

@@ -0,0 +1,12 @@
using System;
using System.Threading.Tasks;
namespace DHT.Utils.Tasks;
public static class TaskExtensions {
public static async Task WaitIgnoringCancellation(this Task task) {
try {
await task;
} catch (OperationCanceledException) {}
}
}

View File

@@ -6,6 +6,10 @@
<PackageId>DiscordHistoryTrackerUtils</PackageId> <PackageId>DiscordHistoryTrackerUtils</PackageId>
</PropertyGroup> </PropertyGroup>
<PropertyGroup>
<AllowUnsafeBlocks>true</AllowUnsafeBlocks>
</PropertyGroup>
<ItemGroup> <ItemGroup>
<FrameworkReference Include="Microsoft.AspNetCore.App" /> <FrameworkReference Include="Microsoft.AspNetCore.App" />
</ItemGroup> </ItemGroup>

View File

@@ -8,5 +8,5 @@ using DHT.Utils;
namespace DHT.Utils; namespace DHT.Utils;
static class Version { static class Version {
public const string Tag = "39.0.0.0"; public const string Tag = "39.1.0.0";
} }

View File

@@ -4,11 +4,11 @@ set list=win-x64 linux-x64 osx-x64
rmdir /S /Q bin rmdir /S /Q bin
(for %%a in (%list%) do ( (for %%a in (%list%) do (
dotnet publish Desktop -c Release -r %%a -o ./bin/%%a -p:PublishSingleFile=true -p:IncludeNativeLibrariesForSelfExtract=true -p:PublishReadyToRun=false --self-contained true dotnet publish Desktop -c Release -r %%a -o ./bin/%%a --self-contained true
powershell "Compress-Archive -Path ./bin/%%a/* -DestinationPath ./bin/%%a.zip -CompressionLevel Optimal" powershell "Compress-Archive -Path ./bin/%%a/* -DestinationPath ./bin/%%a.zip -CompressionLevel Optimal"
)) ))
dotnet publish Desktop -c Release -o ./bin/portable -p:PublishTrimmed=false --self-contained false dotnet publish Desktop -c Release -o ./bin/portable -p:PublishSingleFile=false -p:PublishTrimmed=false --self-contained false
powershell "Compress-Archive -Path ./bin/portable/* -DestinationPath ./bin/portable.zip -CompressionLevel Optimal" powershell "Compress-Archive -Path ./bin/portable/* -DestinationPath ./bin/portable.zip -CompressionLevel Optimal"
echo Done echo Done

View File

@@ -17,9 +17,9 @@ rm -rf "./bin"
configurations=(win-x64 linux-x64 osx-x64) configurations=(win-x64 linux-x64 osx-x64)
for cfg in ${configurations[@]}; do for cfg in ${configurations[@]}; do
dotnet publish Desktop -c Release -r "$cfg" -o "./bin/$cfg" -p:PublishSingleFile=true -p:IncludeNativeLibrariesForSelfExtract=true -p:PublishReadyToRun=false --self-contained true dotnet publish Desktop -c Release -r "$cfg" -o "./bin/$cfg" --self-contained true
makezip "$cfg" makezip "$cfg"
done done
dotnet publish Desktop -c Release -o "./bin/portable" -p:PublishTrimmed=false --self-contained false dotnet publish Desktop -c Release -o "./bin/portable" -p:PublishSingleFile=false -p:PublishTrimmed=false --self-contained false
makezip "portable" makezip "portable"