diff --git a/app/Desktop/App.axaml b/app/Desktop/App.axaml
index f235c7f..dac0b78 100644
--- a/app/Desktop/App.axaml
+++ b/app/Desktop/App.axaml
@@ -51,6 +51,7 @@
diff --git a/app/Desktop/Main/Controls/ServerConfigurationPanel.axaml b/app/Desktop/Main/Controls/ServerConfigurationPanel.axaml
index 14f5b66..e77f273 100644
--- a/app/Desktop/Main/Controls/ServerConfigurationPanel.axaml
+++ b/app/Desktop/Main/Controls/ServerConfigurationPanel.axaml
@@ -29,7 +29,7 @@
- The following settings determine how the tracking script communicates with this application. If you change them, you will have to copy/paste the tracking script again.
+ The following settings determine how the tracking script communicates with this application. If you change them, you will have to copy/paste the tracking script or connection code again.
diff --git a/app/Desktop/Main/Pages/TrackingPage.axaml b/app/Desktop/Main/Pages/TrackingPage.axaml
index c2b3000..1c57bba 100644
--- a/app/Desktop/Main/Pages/TrackingPage.axaml
+++ b/app/Desktop/Main/Pages/TrackingPage.axaml
@@ -11,25 +11,47 @@
-
-
-
-
-
-
-
-
-
- Copy Tracking Script
-
-
-
-
-
-
-
-
-
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+ Copy Tracking Script
+
+ By default, the Discord app blocks the Dev Tools shortcut. The button below changes a hidden setting to unblock the shortcut.
+
+
+
+
+
+
+
+ Requires a userscript manager in your browser. The userscript adds a DHT icon next to the Help icon on Discord.
+ If the icon does not appear, update this app and reinstall the userscript.
+
+
+ Copy the Connection Code, click the DHT icon, and paste the code into the prompt.
+
+
+ Install or Update Userscript
+ Copy Connection Code
+
+
+
diff --git a/app/Desktop/Main/Pages/TrackingPage.axaml.cs b/app/Desktop/Main/Pages/TrackingPage.axaml.cs
index 0d04dd2..e61b6c4 100644
--- a/app/Desktop/Main/Pages/TrackingPage.axaml.cs
+++ b/app/Desktop/Main/Pages/TrackingPage.axaml.cs
@@ -1,4 +1,5 @@
using System;
+using System.Collections.Generic;
using System.Diagnostics.CodeAnalysis;
using System.Threading.Tasks;
using Avalonia.Controls;
@@ -8,24 +9,36 @@ namespace DHT.Desktop.Main.Pages;
[SuppressMessage("ReSharper", "MemberCanBeInternal")]
public sealed partial class TrackingPage : UserControl {
- private bool isCopyingScript;
+ private readonly HashSet copyingButtons = new (ReferenceEqualityComparer.Instance);
public TrackingPage() {
InitializeComponent();
}
public async void CopyTrackingScriptButton_OnClick(object? sender, RoutedEventArgs e) {
+ await HandleCopyButton(CopyTrackingScript, "Script Copied!", static model => model.OnClickCopyTrackingScript());
+ }
+
+ public async void CopyConnectionScriptButton_OnClick(object? sender, RoutedEventArgs e) {
+ await HandleCopyButton(CopyConnectionCode, "Code Copied!", static model => model.OnClickCopyConnectionCode());
+ }
+
+ private async Task HandleCopyButton(Button button, string copiedText, Func> onClick) {
if (DataContext is TrackingPageModel model) {
- object? originalText = CopyTrackingScript.Content;
- CopyTrackingScript.MinWidth = CopyTrackingScript.Bounds.Width;
+ object? originalText = button.Content;
+ button.MinWidth = button.Bounds.Width;
- if (await model.OnClickCopyTrackingScript() && !isCopyingScript) {
- isCopyingScript = true;
- CopyTrackingScript.Content = "Script Copied!";
+ if (await onClick(model) && copyingButtons.Add(button)) {
+ button.IsEnabled = false;
+ button.Content = copiedText;
- await Task.Delay(TimeSpan.FromSeconds(2));
- CopyTrackingScript.Content = originalText;
- isCopyingScript = false;
+ try {
+ await Task.Delay(TimeSpan.FromSeconds(2));
+ } finally {
+ copyingButtons.Remove(button);
+ button.IsEnabled = true;
+ button.Content = originalText;
+ }
}
}
}
diff --git a/app/Desktop/Main/Pages/TrackingPageModel.cs b/app/Desktop/Main/Pages/TrackingPageModel.cs
index b575fbc..8757917 100644
--- a/app/Desktop/Main/Pages/TrackingPageModel.cs
+++ b/app/Desktop/Main/Pages/TrackingPageModel.cs
@@ -1,20 +1,22 @@
using System;
+using System.Text.RegularExpressions;
using System.Threading;
using System.Threading.Tasks;
using System.Web;
using Avalonia.Controls;
using Avalonia.Input.Platform;
+using DHT.Desktop.Common;
using DHT.Desktop.Dialogs.Message;
using DHT.Desktop.Discord;
using DHT.Desktop.Server;
+using DHT.Utils.Logging;
using PropertyChanged.SourceGenerator;
using static DHT.Desktop.Program;
namespace DHT.Desktop.Main.Pages;
sealed partial class TrackingPageModel {
- [Notify(Setter.Private)]
- private bool isCopyTrackingScriptButtonEnabled = true;
+ private static readonly Log Log = Log.ForType();
[Notify(Setter.Private)]
private bool? areDevToolsEnabled = null;
@@ -51,32 +53,9 @@ sealed partial class TrackingPageModel {
}
public async Task OnClickCopyTrackingScript() {
- IsCopyTrackingScriptButtonEnabled = false;
-
- try {
- return await CopyTrackingScript();
- } finally {
- IsCopyTrackingScriptButtonEnabled = true;
- }
- }
-
- private async Task CopyTrackingScript() {
- string url = $"http://127.0.0.1:{ServerConfiguration.Port}/get-tracking-script?token={HttpUtility.UrlEncode(ServerConfiguration.Token)}";
+ string url = ServerConfiguration.HttpHost + $"/get-tracking-script?token={HttpUtility.UrlEncode(ServerConfiguration.Token)}";
string script = (await Resources.ReadTextAsync("tracker-loader.js")).Trim().Replace("{url}", url);
-
- IClipboard? clipboard = window.Clipboard;
- if (clipboard == null) {
- await Dialog.ShowOk(window, "Copy Tracking Script", "Clipboard is not available on this system.");
- return false;
- }
-
- try {
- await clipboard.SetTextAsync(script);
- return true;
- } catch {
- await Dialog.ShowOk(window, "Copy Tracking Script", "An error occurred while copying to clipboard.");
- return false;
- }
+ return await TryCopy(script, "Copy Tracking Script");
}
private async Task InitializeDevToolsToggle() {
@@ -132,4 +111,44 @@ sealed partial class TrackingPageModel {
throw new ArgumentOutOfRangeException();
}
}
+
+ public async Task OnClickInstallOrUpdateUserscript() {
+ try {
+ SystemUtils.OpenUrl(ServerConfiguration.HttpHost + "/get-userscript/dht.user.js");
+ } catch (Exception e) {
+ await Dialog.ShowOk(window, "Install or Update Userscript", "Could not open the browser: " + e.Message);
+ }
+ }
+
+ [GeneratedRegex("^[a-zA-Z0-9]{1,100}$")]
+ private static partial Regex ConnectionCodeTokenRegex();
+
+ public async Task OnClickCopyConnectionCode() {
+ const string Title = "Copy Connection Code";
+
+ if (ConnectionCodeTokenRegex().IsMatch(ServerConfiguration.Token)) {
+ return await TryCopy(ServerConfiguration.Port + ":" + ServerConfiguration.Token, Title);
+ }
+ else {
+ await Dialog.ShowOk(window, Title, "The internal server token cannot be used to create a connection code.\n\nCheck the 'Advanced' tab and ensure the token is 1-100 characters long, and only contains plain letters and numbers.");
+ return false;
+ }
+ }
+
+ private async Task TryCopy(string script, string errorDialogTitle) {
+ IClipboard? clipboard = window.Clipboard;
+ if (clipboard == null) {
+ await Dialog.ShowOk(window, errorDialogTitle, "Clipboard is not available on this system.");
+ return false;
+ }
+
+ try {
+ await clipboard.SetTextAsync(script);
+ return true;
+ } catch (Exception e) {
+ Log.Error(e);
+ await Dialog.ShowOk(window, errorDialogTitle, "An error occurred while copying to clipboard.");
+ return false;
+ }
+ }
}
diff --git a/app/Desktop/Main/Pages/ViewerPageModel.cs b/app/Desktop/Main/Pages/ViewerPageModel.cs
index aa270ca..dc7c3fb 100644
--- a/app/Desktop/Main/Pages/ViewerPageModel.cs
+++ b/app/Desktop/Main/Pages/ViewerPageModel.cs
@@ -51,10 +51,9 @@ sealed partial class ViewerPageModel : IDisposable {
public async void OnClickOpenViewer() {
try {
- string serverUrl = "http://127.0.0.1:" + ServerConfiguration.Port;
string serverToken = ServerConfiguration.Token;
string sessionId = state.ViewerSessions.Register(new ViewerSession(FilterModel.CreateFilter())).ToString();
- SystemUtils.OpenUrl(serverUrl + "/viewer/?token=" + HttpUtility.UrlEncode(serverToken) + "&session=" + HttpUtility.UrlEncode(sessionId));
+ SystemUtils.OpenUrl(ServerConfiguration.HttpHost + "/viewer/?token=" + HttpUtility.UrlEncode(serverToken) + "&session=" + HttpUtility.UrlEncode(sessionId));
} catch (Exception e) {
await Dialog.ShowOk(window, "Open Viewer", "Could not open viewer: " + e.Message);
}
diff --git a/app/Desktop/Server/ServerConfiguration.cs b/app/Desktop/Server/ServerConfiguration.cs
index 5232b99..8080919 100644
--- a/app/Desktop/Server/ServerConfiguration.cs
+++ b/app/Desktop/Server/ServerConfiguration.cs
@@ -5,4 +5,6 @@ namespace DHT.Desktop.Server;
static class ServerConfiguration {
public static ushort Port { get; set; } = ServerUtils.FindAvailablePort(min: 50000, max: 60000);
public static string Token { get; set; } = ServerUtils.GenerateRandomToken(20);
+
+ public static string HttpHost => "http://127.0.0.1:" + Port;
}
diff --git a/app/Resources/Tracker/loader/dht.user.js b/app/Resources/Tracker/loader/dht.user.js
new file mode 100644
index 0000000..4eeeee9
--- /dev/null
+++ b/app/Resources/Tracker/loader/dht.user.js
@@ -0,0 +1,273 @@
+// ==UserScript==
+// @name Discord History Tracker
+// @license MIT
+// @namespace https://chylex.com
+// @homepageURL https://dht.chylex.com/
+// @supportURL https://github.com/chylex/Discord-History-Tracker/issues
+// @include https://discord.com/*
+// @run-at document-idle
+// @grant none
+// @noframes
+// ==/UserScript==
+
+function startMutationObserver(callback) {
+ let hasDebounceStarted = false;
+
+ function handleMutations(mutations, observer) {
+ if (hasDebounceStarted) {
+ return;
+ }
+
+ hasDebounceStarted = true;
+ window.setTimeout(() => {
+ hasDebounceStarted = false;
+ callback(observer);
+ }, 20);
+ }
+
+ new MutationObserver(handleMutations).observe(document.body, { childList: true, subtree: true });
+}
+
+startMutationObserver(observer => {
+ const triggerButton = installConnectDialogButton();
+ if (triggerButton === null) {
+ return;
+ }
+
+ observer.disconnect();
+
+ startMutationObserver(() => {
+ if (!triggerButton.isConnected) {
+ getHelpIcon()?.parentElement.parentElement.insertAdjacentElement("afterend", triggerButton);
+ }
+ });
+});
+
+function installConnectDialogButton() {
+ const helpIcon = getHelpIcon();
+ if (!helpIcon) {
+ return null;
+ }
+
+ const helpIconWrapper = helpIcon.parentElement;
+ const helpIconLink = helpIconWrapper.parentElement;
+
+ helpIconLink.insertAdjacentHTML("afterend", `
+
+`);
+
+ const button = document.getElementById("dht-connector-show-dialog");
+ button.addEventListener("click", showConnectDialog);
+ return button;
+}
+
+function getHelpIcon() {
+ return document.querySelector("div[class*='bar_'] a[href$='://support.discord.com'] > div[class*='clickable'] > svg");
+}
+
+function showConnectDialog() {
+ if (window.DHT_LOADED) {
+ alert("Discord History Tracker is already loaded.");
+ return;
+ }
+
+ const dialogElement = document.createElement("dialog");
+ dialogElement.id = "dht-connector-dialog";
+ dialogElement.innerHTML = `
+
+
+ `;
+
+ document.body.insertAdjacentElement("beforeend", dialogElement);
+
+ const formElement = document.getElementById("dht-connector-dialog-form");
+ const codeErrorElement = document.getElementById("dht-connector-dialog-input-error");
+ const codeInputElement = document.getElementById("dht-connector-dialog-input-code");
+ const connectButtonElement = document.getElementById("dht-connector-dialog-button-connect");
+ const closeButtonElement = document.getElementById("dht-connector-dialog-button-close");
+
+ dialogElement.addEventListener("close", function() {
+ dialogElement.remove();
+ });
+
+ closeButtonElement.addEventListener("click", function() {
+ dialogElement.close();
+ });
+
+ codeInputElement.addEventListener("paste", function() {
+ setTimeout(async function() {
+ const code = parseConnectionCode(codeInputElement.value);
+ if (code !== null) {
+ await onSubmit(code);
+ }
+ }, 0);
+ });
+
+ formElement.addEventListener("submit", async function(e) {
+ e.preventDefault();
+
+ const code = parseConnectionCode(codeInputElement.value);
+ if (code === null) {
+ codeErrorElement.innerText = "Code is not valid.";
+ }
+ else {
+ await onSubmit(code);
+ }
+ });
+
+ let isSubmitting = false;
+
+ async function onSubmit(code) {
+ if (isSubmitting) {
+ return;
+ }
+
+ isSubmitting = true;
+ connectButtonElement.setAttribute("disabled", "");
+ try {
+ await loadTrackingScript(code);
+ } catch (e) {
+ onError("Could not load tracking script.", e);
+ } finally {
+ isSubmitting = false;
+ connectButtonElement.removeAttribute("disabled");
+ }
+ }
+
+ async function loadTrackingScript(code) {
+ const trackingScript = await getTrackingScript(code.port, code.token);
+
+ if (dialogElement.isConnected) {
+ // noinspection DynamicallyGeneratedCodeJS
+ eval(trackingScript);
+ dialogElement.close();
+ }
+ }
+
+ function onError(message, e) {
+ console.error(message, e);
+ codeErrorElement.innerText = message;
+ }
+
+ dialogElement.showModal();
+}
+
+/**
+ * @param {string} code
+ * @return {?{port: number, token: string}}
+ */
+function parseConnectionCode(code) {
+ if (code.length > 5 + 1 + 100) {
+ return null;
+ }
+
+ const match = code.match(/^(\d{1,5}):([a-zA-Z0-9]{1,100})$/);
+ if (!match) {
+ return null;
+ }
+
+ const port = Number(match[1]);
+ if (port < 0 || port > 65535) {
+ return null;
+ }
+
+ return { port, token: match[2] };
+}
+
+async function getTrackingScript(port, token) {
+ const url = "http://127.0.0.1:" + port + "/get-tracking-script?token=" + encodeURIComponent(token);
+ const response = await fetch(url, {
+ credentials: "omit",
+ signal: AbortSignal.timeout(2000),
+ });
+
+ if (!response.ok) {
+ throw response.status + " " + response.statusText;
+ }
+
+ if (response.headers.get("X-DHT") !== "1") {
+ throw "Invalid response";
+ }
+
+ return await response.text();
+}
diff --git a/app/Server/Endpoints/GetUserscriptEndpoint.cs b/app/Server/Endpoints/GetUserscriptEndpoint.cs
new file mode 100644
index 0000000..e755b04
--- /dev/null
+++ b/app/Server/Endpoints/GetUserscriptEndpoint.cs
@@ -0,0 +1,18 @@
+using System.Threading;
+using System.Threading.Tasks;
+using DHT.Server.Service.Middlewares;
+using DHT.Utils.Resources;
+using Microsoft.AspNetCore.Http;
+
+namespace DHT.Server.Endpoints;
+
+[ServerAuthorizationMiddleware.NoAuthorization]
+sealed class GetUserscriptEndpoint(ResourceLoader resources) : BaseEndpoint {
+ protected override async Task Respond(HttpRequest request, HttpResponse response, CancellationToken cancellationToken) {
+ const string FileName = "dht.user.js";
+ const string ResourcePath = "Tracker/loader/" + FileName;
+
+ byte[]? resourceBytes = await resources.ReadBytesAsyncIfExists(ResourcePath);
+ await WriteFileIfFound(response, FileName, resourceBytes, cancellationToken);
+ }
+}
diff --git a/app/Server/Server.csproj b/app/Server/Server.csproj
index 5b97358..37d902f 100644
--- a/app/Server/Server.csproj
+++ b/app/Server/Server.csproj
@@ -29,6 +29,11 @@
Resources/Tracker/%(RecursiveDir)%(Filename)%(Extension)
false
+
+ Tracker\loader\%(RecursiveDir)%(Filename)%(Extension)
+ Resources/Tracker/loader/%(RecursiveDir)%(Filename)%(Extension)
+ false
+
Tracker\scripts\%(RecursiveDir)%(Filename)%(Extension)
Resources/Tracker/scripts/%(RecursiveDir)%(Filename)%(Extension)
diff --git a/app/Server/Service/ServerStartup.cs b/app/Server/Service/ServerStartup.cs
index f3e085e..99b7a04 100644
--- a/app/Server/Service/ServerStartup.cs
+++ b/app/Server/Service/ServerStartup.cs
@@ -43,6 +43,7 @@ sealed class Startup {
app.UseEndpoints(endpoints => {
endpoints.MapGet("/get-downloaded-file/{url}", new GetDownloadedFileEndpoint(db).Handle);
endpoints.MapGet("/get-tracking-script", new GetTrackingScriptEndpoint(parameters, resources).Handle);
+ endpoints.MapGet("/get-userscript/{**ignored}", new GetUserscriptEndpoint(resources).Handle);
endpoints.MapGet("/get-viewer-messages", new GetViewerMessagesEndpoint(db, viewerSessions).Handle);
endpoints.MapGet("/get-viewer-metadata", new GetViewerMetadataEndpoint(db, viewerSessions).Handle);
endpoints.MapGet("/viewer/{**path}", new ViewerEndpoint(resources).Handle);