diff --git a/ChatTwo/ChatTwo.csproj b/ChatTwo/ChatTwo.csproj
index fa990e4..88a83fa 100755
--- a/ChatTwo/ChatTwo.csproj
+++ b/ChatTwo/ChatTwo.csproj
@@ -46,14 +46,21 @@
$(DalamudLibPath)\Lumina.Excel.dll
false
+
+ $(DalamudLibPath)\Newtonsoft.Json.dll
+ false
+
+
+
+
@@ -74,4 +81,13 @@
+
+
+
+ Always
+
+
+ Always
+
+
diff --git a/ChatTwo/Configuration.cs b/ChatTwo/Configuration.cs
index 257d81d..eb84b19 100755
--- a/ChatTwo/Configuration.cs
+++ b/ChatTwo/Configuration.cs
@@ -120,6 +120,10 @@ internal class Configuration : IPluginConfiguration
public ConfigKeyBind? ChatTabForward;
public ConfigKeyBind? ChatTabBackward;
+ // Webinterface
+ public bool WebinterfaceEnabled;
+ public string WebinterfacePassword = WebinterfaceUtil.GenerateSimpleAuthCode();
+
internal void UpdateFrom(Configuration other, bool backToOriginal)
{
if (backToOriginal)
@@ -183,6 +187,8 @@ internal class Configuration : IPluginConfiguration
ChosenStyle = other.ChosenStyle;
ChatTabForward = other.ChatTabForward;
ChatTabBackward = other.ChatTabBackward;
+ WebinterfaceEnabled = other.WebinterfaceEnabled;
+ WebinterfacePassword = other.WebinterfacePassword;
}
}
@@ -286,26 +292,26 @@ internal class Tab
///
internal class MessageList
{
- private ReaderWriterLock rwl = new();
+ private readonly SemaphoreSlim LockSlim = new(1, 1);
- private readonly List messages;
- private readonly HashSet trackedMessageIds;
+ private readonly List Messages;
+ private readonly HashSet TrackedMessageIds;
public MessageList()
{
- messages = new();
- trackedMessageIds = new();
+ Messages = [];
+ TrackedMessageIds = [];
}
public MessageList(int initialCapacity)
{
- messages = new(initialCapacity);
- trackedMessageIds = new(initialCapacity);
+ Messages = new List(initialCapacity);
+ TrackedMessageIds = new HashSet(initialCapacity);
}
public void AddPrune(Message message, int max)
{
- rwl.AcquireWriterLock(-1);
+ LockSlim.Wait(-1);
try
{
AddLocked(message);
@@ -313,13 +319,13 @@ internal class Tab
}
finally
{
- rwl.ReleaseWriterLock();
+ LockSlim.Release();
}
}
public void AddSortPrune(IEnumerable messages, int max)
{
- rwl.AcquireWriterLock(-1);
+ LockSlim.Wait(-1);
try
{
foreach (var message in messages)
@@ -330,44 +336,60 @@ internal class Tab
}
finally
{
- rwl.ReleaseWriterLock();
+ LockSlim.Release();
}
}
private void AddLocked(Message message)
{
- if (trackedMessageIds.Contains(message.Id))
+ if (TrackedMessageIds.Contains(message.Id))
return;
- messages.Add(message);
- trackedMessageIds.Add(message.Id);
+ Messages.Add(message);
+ TrackedMessageIds.Add(message.Id);
}
public void Clear()
{
- rwl.AcquireWriterLock(-1);
+ LockSlim.Wait(-1);
try
{
- messages.Clear();
- trackedMessageIds.Clear();
+ Messages.Clear();
+ TrackedMessageIds.Clear();
}
finally
{
- rwl.ReleaseWriterLock();
+ LockSlim.Release();
}
}
private void SortLocked()
{
- messages.Sort((a, b) => a.Date.CompareTo(b.Date));
+ Messages.Sort((a, b) => a.Date.CompareTo(b.Date));
}
private void PruneMaxLocked(int max)
{
- while (messages.Count > max)
+ while (Messages.Count > max)
{
- trackedMessageIds.Remove(messages[0].Id);
- messages.RemoveAt(0);
+ TrackedMessageIds.Remove(Messages[0].Id);
+ Messages.RemoveAt(0);
+ }
+ }
+
+ ///
+ /// Returns an array copy of the message list for usage outside of main thread
+ ///
+ public async Task GetCopy(int millisecondsTimeout = -1)
+ {
+ await LockSlim.WaitAsync(millisecondsTimeout);
+ try
+ {
+ return Messages.ToArray();
+ }
+ finally
+ {
+ LockSlim.Release();
}
}
@@ -377,11 +399,11 @@ internal class Tab
///
public RLockedMessageList GetReadOnly(int millisecondsTimeout = -1)
{
- rwl.AcquireReaderLock(millisecondsTimeout);
- return new RLockedMessageList(rwl, messages);
+ LockSlim.Wait(millisecondsTimeout);
+ return new RLockedMessageList(LockSlim, Messages);
}
- internal class RLockedMessageList(ReaderWriterLock rwl, List messages) : IReadOnlyList, IDisposable
+ internal class RLockedMessageList(SemaphoreSlim lockSlim, List messages) : IReadOnlyList, IDisposable
{
public IEnumerator GetEnumerator()
{
@@ -399,7 +421,7 @@ internal class Tab
public void Dispose()
{
- rwl.ReleaseReaderLock();
+ lockSlim.Release();
}
}
}
diff --git a/ChatTwo/EmoteCache.cs b/ChatTwo/EmoteCache.cs
index aec3bf3..dd01c10 100644
--- a/ChatTwo/EmoteCache.cs
+++ b/ChatTwo/EmoteCache.cs
@@ -148,6 +148,8 @@ public static class EmoteCache
public bool Failed;
public bool IsLoaded;
+ public byte[] RawData = [];
+
protected IDalamudTextureWrap? Texture;
public virtual void Draw(Vector2 size)
@@ -155,39 +157,26 @@ public static class EmoteCache
ImGui.Image(Texture!.ImGuiHandle, size);
}
- internal static async Task LoadAsync(Emote emote)
+ internal async Task LoadAsync(Emote emote)
{
- try
- {
- // TODO: Remove after 01.06.2024
- var oldDir = Path.Join(Plugin.Interface.ConfigDirectory.FullName, "emotes");
- if (Directory.Exists(oldDir))
- Directory.Delete(oldDir, true);
- }
- catch
- {
- // Ignore
- }
-
var dir = Path.Join(Plugin.Interface.ConfigDirectory.FullName, "EmoteCacheV1");
Directory.CreateDirectory(dir);
- byte[] image;
var filePath = Path.Join(dir, $"{emote.Id}.{emote.ImageType}");
if (File.Exists(filePath))
{
- image = await File.ReadAllBytesAsync(filePath);
+ RawData = await File.ReadAllBytesAsync(filePath);
}
else
{
var content = await new HttpClient().GetAsync(EmotePath.Format(emote.Id));
- image = await content.Content.ReadAsByteArrayAsync();
+ RawData = await content.Content.ReadAsByteArrayAsync();
await using var stream = new FileStream(filePath, FileMode.Create, FileAccess.Write, FileShare.Read);
- stream.Write(image, 0, image.Length);
+ stream.Write(RawData, 0, RawData.Length);
}
- return image;
+ return RawData;
}
public abstract void InnerDispose();
diff --git a/ChatTwo/FontManager.cs b/ChatTwo/FontManager.cs
index 1198245..1172220 100644
--- a/ChatTwo/FontManager.cs
+++ b/ChatTwo/FontManager.cs
@@ -16,7 +16,7 @@ public class FontManager
internal IFontHandle FontAwesome { get; private set; }
- private readonly byte[] GameSymFont;
+ internal readonly byte[] GameSymFont;
private ushort[] Ranges;
private ushort[] JpRange;
diff --git a/ChatTwo/Http/MessageProtocol/OutgoingMessage.cs b/ChatTwo/Http/MessageProtocol/OutgoingMessage.cs
new file mode 100644
index 0000000..210dba4
--- /dev/null
+++ b/ChatTwo/Http/MessageProtocol/OutgoingMessage.cs
@@ -0,0 +1,21 @@
+using Newtonsoft.Json;
+
+namespace ChatTwo.Http.MessageProtocol;
+
+public struct MessageResponse()
+{
+ [JsonProperty("timestamp")] public string Timestamp = "";
+ [JsonProperty("messageHTML")] public string Message = "";
+}
+
+public class WebSocketNewMessage(MessageResponse[] messages) : BaseOutboundMessage(MessageName)
+{
+ private const string MessageName = "chat-message";
+
+ [JsonProperty("messages")] public MessageResponse[] Messages { get; set; } = messages;
+}
+
+public class BaseOutboundMessage(string messageType)
+{
+ [JsonProperty("messageType")] public string MessageType { get; set; } = messageType;
+}
\ No newline at end of file
diff --git a/ChatTwo/Http/Processing.cs b/ChatTwo/Http/Processing.cs
new file mode 100644
index 0000000..087564d
--- /dev/null
+++ b/ChatTwo/Http/Processing.cs
@@ -0,0 +1,96 @@
+using System.Globalization;
+using ChatTwo.Code;
+using ChatTwo.Http.MessageProtocol;
+using ChatTwo.Util;
+using Dalamud.Game.Text.SeStringHandling.Payloads;
+using Ganss.Xss;
+
+namespace ChatTwo.Http;
+
+public class Processing
+{
+ private readonly Plugin Plugin;
+ private readonly HtmlSanitizer Sanitizer = new();
+
+ public Processing(Plugin plugin)
+ {
+ Plugin = plugin;
+ }
+
+ public string ReadChannelName()
+ {
+ var messages = new List();
+ foreach (var chunk in Plugin.ChatLogWindow.ReadChannelName(Plugin.ChatLogWindow.CurrentTab))
+ messages.Add(ProcessChunk(chunk, noColor: true));
+
+ return string.Join("", messages);
+ }
+
+ internal async Task> ReadMessageList()
+ {
+ var tabMessages = await Plugin.ChatLogWindow.CurrentTab!.Messages.GetCopy();
+ return tabMessages.Select(ReadMessageContent).ToList();
+ }
+
+ internal MessageResponse ReadMessageContent(Message message)
+ {
+ var response = new MessageResponse
+ {
+ Timestamp = message.Date.ToLocalTime().ToString("t", !Plugin.Config.Use24HourClock ? null : CultureInfo.CreateSpecificCulture("es-ES"))
+ };
+
+ var content = "";
+ if (message.Sender.Count > 0)
+ content = message.Sender.Aggregate(content, (current, chunk) => current + ProcessChunk(chunk));
+
+ content = message.Content.Aggregate(content, (current, chunk) => current + ProcessChunk(chunk));
+ response.Message = content;
+
+ return response;
+ }
+
+ private string ProcessChunk(Chunk chunk, bool noColor = false)
+ {
+ if (chunk is IconChunk { } icon)
+ {
+ return IconUtil.GfdFileView.TryGetEntry((uint) icon.Icon, out _)
+ ? $""
+ : "";
+ }
+
+ if (chunk is TextChunk { } text)
+ {
+ if (chunk.Link is EmotePayload emotePayload && Plugin.Config.ShowEmotes)
+ {
+ var image = EmoteCache.GetEmote(emotePayload.Code);
+ if (image is { Failed: false })
+ return $"
";
+ }
+
+ var colour = text.Foreground;
+ if (colour == null && text.FallbackColour != null)
+ {
+ var type = text.FallbackColour.Value;
+ colour = Plugin.Config.ChatColours.TryGetValue(type, out var col) ? col : type.DefaultColor();
+ }
+
+ var color = ColourUtil.RgbaToComponents(colour ?? 0);
+
+ var userContent = text.Content ?? "";
+ if (Plugin.ChatLogWindow.ScreenshotMode)
+ {
+ if (chunk.Link is PlayerPayload playerPayload)
+ userContent = Plugin.ChatLogWindow.HidePlayerInString(userContent, playerPayload.PlayerName, playerPayload.World.RowId);
+ else if (Plugin.ClientState.LocalPlayer is { } player)
+ userContent = Plugin.ChatLogWindow.HidePlayerInString(userContent, player.Name.TextValue, player.HomeWorld.Id);
+ }
+
+ userContent = Sanitizer.Sanitize(userContent);
+ return noColor
+ ? userContent
+ : $"{userContent}";
+ }
+
+ return string.Empty;
+ }
+}
\ No newline at end of file
diff --git a/ChatTwo/Http/RouteController.cs b/ChatTwo/Http/RouteController.cs
new file mode 100644
index 0000000..32e57a3
--- /dev/null
+++ b/ChatTwo/Http/RouteController.cs
@@ -0,0 +1,153 @@
+using Lumina.Data.Files;
+using WatsonWebserver.Core;
+
+using HttpMethod = WatsonWebserver.Core.HttpMethod;
+
+namespace ChatTwo.Http;
+
+public class RouteController
+{
+ private readonly Plugin Plugin;
+ private readonly ServerCore Core;
+
+ private readonly string AuthTemplate;
+ private readonly string ChatBoxTemplate;
+
+ public RouteController(Plugin plugin, ServerCore core)
+ {
+ Plugin = plugin;
+ Core = core;
+
+ AuthTemplate = File.ReadAllText(Path.Combine(Core.StaticDir, "templates", "auth.html"));
+ ChatBoxTemplate = File.ReadAllText(Path.Combine(Core.StaticDir, "templates", "start.html"));
+
+ // Pre Auth
+ Core.HostContext.Routes.PreAuthentication.Static.Add(HttpMethod.GET, "/", AuthRoute, ExceptionRoute);
+ Core.HostContext.Routes.PreAuthentication.Static.Add(HttpMethod.POST, "/auth", AuthenticateClient, ExceptionRoute);
+ Core.HostContext.Routes.PreAuthentication.Static.Add(HttpMethod.GET, "/files/gfdata.gfd", GetGfdData, ExceptionRoute);
+ Core.HostContext.Routes.PreAuthentication.Static.Add(HttpMethod.GET, "/files/fonticon_ps5.tex", GetTexData, ExceptionRoute);
+ Core.HostContext.Routes.PreAuthentication.Static.Add(HttpMethod.GET, "/files/FFXIV_Lodestone_SSF.ttf", GetLodestoneFont, ExceptionRoute);
+ Core.HostContext.Routes.PreAuthentication.Content.Add("/static", true, ExceptionRoute);
+
+ // Post Auth
+ Core.HostContext.Routes.PostAuthentication.Static.Add(HttpMethod.GET, "/chat", ChatBoxRoute, ExceptionRoute);
+ Core.HostContext.Routes.PostAuthentication.Static.Add(HttpMethod.POST, "/send", ReceiveMessage, ExceptionRoute);
+ Core.HostContext.Routes.PostAuthentication.Parameter.Add(HttpMethod.GET, "/emote/{name}", GetEmote, ExceptionRoute);
+ }
+
+ private async Task ExceptionRoute(HttpContextBase ctx, Exception _)
+ {
+ ctx.Response.StatusCode = 500;
+ await ctx.Response.Send("Internal Server Error, please try again");
+ }
+
+ private async Task AuthRoute(HttpContextBase ctx)
+ {
+ await ctx.Response.Send(AuthTemplate);
+ }
+
+ public void Dispose()
+ {
+
+ }
+
+ #region FileHandlerRoutes
+ private async Task GetTexData(HttpContextBase ctx)
+ {
+ var data = Plugin.DataManager.GetFile("common/font/fonticon_ps5.tex")!.Data;
+ await ctx.Response.Send(data);
+ }
+
+ private async Task GetGfdData(HttpContextBase ctx)
+ {
+ var data = Plugin.DataManager.GetFile("common/font/gfdata.gfd")!.Data;
+ await ctx.Response.Send(data);
+ }
+
+ private async Task GetLodestoneFont(HttpContextBase ctx)
+ {
+ var data = Plugin.FontManager.GameSymFont;
+ await ctx.Response.Send(data);
+ }
+
+ private async Task GetEmote(HttpContextBase ctx)
+ {
+ var name = ctx.Request.Url.Parameters["name"] ?? "";
+ if (name == "" || !EmoteCache.Exists(name))
+ {
+ ctx.Response.StatusCode = 400;
+ await ctx.Response.Send("Malformed emote name.");
+ return;
+ }
+
+
+ var emote = EmoteCache.GetEmote(name);
+ if (emote is not { IsLoaded: true })
+ {
+ ctx.Response.StatusCode = 400;
+ await ctx.Response.Send("Emote not valid.");
+ return;
+ }
+
+ ctx.Response.Headers.Add("Cache-Control", "max-age=86400");
+ await ctx.Response.Send(emote.RawData);
+ }
+ #endregion
+
+ #region PreAuthRoutes
+ private async Task AuthenticateClient(HttpContextBase ctx)
+ {
+ var receivedPassword = ctx.Request.DataAsString ?? "";
+ if (!receivedPassword.StartsWith("authcode="))
+ {
+ ctx.Response.StatusCode = 400;
+ await ctx.Response.Send("Authentication content invalid.");
+ return;
+ }
+
+ receivedPassword = receivedPassword[9..];
+ if (receivedPassword != Plugin.Config.WebinterfacePassword)
+ {
+ ctx.Response.StatusCode = 401;
+ await ctx.Response.Send("Authentication failed.");
+ return;
+ }
+
+ ctx.Response.Headers.Add("Set-Cookie", $"auth={Plugin.Config.WebinterfacePassword}");
+ ctx.Response.Headers.Add("Location", "/chat");
+ ctx.Response.StatusCode = 302;
+ await ctx.Response.Send();
+ }
+ #endregion
+
+ #region PostAuthRoutes
+ private async Task ChatBoxRoute(HttpContextBase ctx)
+ {
+ if (Plugin.ChatLogWindow.CurrentTab == null)
+ {
+ await ctx.Response.Send("No valid chat tab!");
+ return;
+ }
+
+ await ctx.Response.Send(ChatBoxTemplate);
+ }
+
+ private async Task ReceiveMessage(HttpContextBase ctx)
+ {
+ var content = ctx.Request.DataAsString;
+ if (content.Length is > 500 or < 2)
+ {
+ await ctx.Response.Send("Invalid length for a chat message received.");
+ return;
+ }
+
+ await Plugin.Framework.RunOnFrameworkThread(() =>
+ {
+ Plugin.ChatLogWindow.Chat = content;
+ Plugin.ChatLogWindow.SendChatBox(Plugin.ChatLogWindow.CurrentTab);
+ });
+
+ await ctx.Response.Send("Message was send to the channel.");
+ }
+ #endregion
+}
\ No newline at end of file
diff --git a/ChatTwo/Http/ServerCore.cs b/ChatTwo/Http/ServerCore.cs
new file mode 100644
index 0000000..c799950
--- /dev/null
+++ b/ChatTwo/Http/ServerCore.cs
@@ -0,0 +1,134 @@
+using ChatTwo.Http.MessageProtocol;
+using EmbedIO;
+using WatsonWebserver.Core;
+using WatsonWebserver.Lite;
+using ExceptionEventArgs = WatsonWebserver.Core.ExceptionEventArgs;
+
+namespace ChatTwo.Http;
+
+public class ServerCore : IDisposable
+{
+ private readonly Plugin Plugin;
+ private readonly Processing Processing;
+ private readonly RouteController RouteController;
+
+ internal readonly WebserverLite HostContext;
+ private readonly WebSocketServer Websocket;
+ private readonly WebServer Host;
+
+ internal readonly CancellationTokenSource TokenSource = new();
+ internal readonly string StaticDir = Path.Combine(Plugin.Interface.AssemblyLocation.DirectoryName!, "Http");
+
+ public ServerCore(Plugin plugin)
+ {
+ Plugin = plugin;
+ HostContext = new WebserverLite(new WebserverSettings("*", 9000), DefaultRoute);
+
+ Processing = new Processing(plugin);
+ RouteController = new RouteController(plugin, this);
+
+ HostContext.Routes.PreAuthentication.Content.BaseDirectory = StaticDir;
+ HostContext.Routes.AuthenticateRequest = CheckAuthenticationCookie;
+ HostContext.Events.ExceptionEncountered += ExceptionEncountered;
+
+ // Settings
+ HostContext.Settings.Debug.Requests = true;
+ HostContext.Settings.Debug.Routing = true;
+ HostContext.Settings.Debug.Responses = true;
+ HostContext.Settings.Debug.AccessControl = true;
+ HostContext.Events.Logger = logMessage => Plugin.Log.Information(logMessage);
+
+
+ // Websocket
+ Host = new WebServer(o => o
+ .WithUrlPrefixes($"http://*:9001")
+ .WithMode(HttpListenerMode.EmbedIO)
+ );
+
+ Websocket = new WebSocketServer("/ws");
+ Host.WithModule(Websocket);
+
+ Websocket.OnClientConnected += ClientConnected;
+ }
+
+ #region WebsocketFunctions
+ private void ClientConnected(object? sender, EventArgs args)
+ {
+ Task.Run(async () =>
+ {
+ var messages = await WebserverUtil.FrameworkWrapper(Processing.ReadMessageList);
+ Websocket.BroadcastMessage(new WebSocketNewMessage(messages.ToArray()));
+ });
+ }
+
+ internal void SendNewMessage(Message message)
+ {
+ try
+ {
+ Plugin.Framework.RunOnTick(() =>
+ {
+ Websocket.BroadcastMessage(new WebSocketNewMessage([Processing.ReadMessageContent(message)]));
+ });
+ }
+ catch (Exception ex)
+ {
+ Plugin.Log.Error(ex, "Send message to websockets failed.");
+ }
+ }
+ #endregion
+
+ #region GeneralHandlers
+ private static void ExceptionEncountered(object? _, ExceptionEventArgs args)
+ {
+ Plugin.Log.Error(args.Exception, "Webserver threw an exception.");
+ }
+
+ private async Task DefaultRoute(HttpContextBase ctx)
+ {
+ await ctx.Response.Send("Nothing to see here.");
+ }
+ #endregion
+
+ private async Task CheckAuthenticationCookie(HttpContextBase ctx)
+ {
+ var cookie = ctx.Request.Headers.Get("Cookie") ?? "";
+ if (!cookie.StartsWith("auth=") || cookie[5..] != Plugin.Config.WebinterfacePassword)
+ {
+ ctx.Response.StatusCode = 401;
+ await ctx.Response.Send("Your session auth code was invalid");
+ }
+
+ // Do nothing to let auth pass
+ }
+
+ public bool GetStats()
+ {
+ return HostContext.IsListening;
+ }
+
+ public void Start()
+ {
+ try
+ {
+ HostContext.Start(TokenSource.Token);
+ Host.Start(TokenSource.Token);
+ }
+ catch (Exception ex)
+ {
+ Plugin.Log.Error(ex, "Startup failed with an error.");
+ }
+ }
+
+ public void Dispose()
+ {
+ TokenSource.Cancel();
+
+ HostContext.Stop();
+ HostContext.Dispose();
+
+ Websocket.Dispose();
+ Host.Dispose();
+
+ RouteController.Dispose();
+ }
+}
\ No newline at end of file
diff --git a/ChatTwo/Http/Util.cs b/ChatTwo/Http/Util.cs
new file mode 100644
index 0000000..0161262
--- /dev/null
+++ b/ChatTwo/Http/Util.cs
@@ -0,0 +1,37 @@
+namespace ChatTwo.Http;
+
+public static class WebserverUtil
+{
+ public static async Task FrameworkWrapper(Func> func)
+ {
+ return await Plugin.Framework.RunOnTick(func).ConfigureAwait(false);
+ }
+
+ public class DisposableWrapper : IDisposable {
+ private readonly Action Down;
+ private bool Disposed;
+
+ public DisposableWrapper(Action down) {
+ Down = down;
+ }
+
+ public void Dispose() {
+ if (Disposed) return;
+
+ Down();
+ Disposed = true;
+
+ GC.SuppressFinalize(this);
+ }
+ }
+}
+
+public static class AsyncUtils {
+ public static async Task UseWaitAsync(this SemaphoreSlim semaphore, CancellationToken ct = default) {
+ await semaphore.WaitAsync(ct).ConfigureAwait(false);
+
+ return new WebserverUtil.DisposableWrapper(() => {
+ semaphore.Release();
+ });
+ }
+}
\ No newline at end of file
diff --git a/ChatTwo/Http/Websocket.cs b/ChatTwo/Http/Websocket.cs
new file mode 100644
index 0000000..0a94082
--- /dev/null
+++ b/ChatTwo/Http/Websocket.cs
@@ -0,0 +1,48 @@
+using ChatTwo.Http.MessageProtocol;
+using EmbedIO.WebSockets;
+using Newtonsoft.Json;
+
+namespace ChatTwo.Http;
+
+public class WebSocketServer : WebSocketModule {
+ private readonly SemaphoreSlim SendLock = new(1, 1);
+
+ public event EventHandler? OnClientConnected;
+
+ public WebSocketServer(string urlPath) : base(urlPath, true) {
+
+ }
+
+ protected override async Task OnMessageReceivedAsync(IWebSocketContext context, byte[] buffer, IWebSocketReceiveResult result)
+ {
+ // Unused method
+ }
+
+ protected override Task OnClientConnectedAsync(IWebSocketContext context)
+ {
+ Plugin.Log.Information($"Client connected: {context.Id}");
+ OnClientConnected?.Invoke(this, EventArgs.Empty);
+ return base.OnClientConnectedAsync(context);
+ }
+
+ protected override Task OnClientDisconnectedAsync(IWebSocketContext context)
+ {
+ Plugin.Log.Information($"Client disconnected: {context.Id}");
+ return base.OnClientConnectedAsync(context);
+ }
+
+ protected override void Dispose(bool disposing) {
+ base.Dispose(disposing);
+
+ SendLock.Dispose();
+ }
+
+ public void BroadcastMessage(BaseOutboundMessage message) {
+ Task.Run(async () => {
+ using (await SendLock.UseWaitAsync()) {
+ var serializedData = JsonConvert.SerializeObject(message);
+ await BroadcastAsync(serializedData);
+ }
+ });
+ }
+}
\ No newline at end of file
diff --git a/ChatTwo/Http/static/Inter.var.woff2 b/ChatTwo/Http/static/Inter.var.woff2
new file mode 100644
index 0000000..365eedc
Binary files /dev/null and b/ChatTwo/Http/static/Inter.var.woff2 differ
diff --git a/ChatTwo/Http/static/start.css b/ChatTwo/Http/static/start.css
new file mode 100644
index 0000000..abb0e1d
--- /dev/null
+++ b/ChatTwo/Http/static/start.css
@@ -0,0 +1,243 @@
+/* fonts */
+@font-face {
+ font-family: Lodestone;
+ src: url('files/FFXIV_Lodestone_SSF.ttf') format('truetype');
+ unicode-range: U+E020-E0DB;
+}
+
+@font-face {
+ font-family: 'Inter var';
+ font-weight: 100 900;
+ font-style: oblique 0deg 10deg;
+ src: url('static/Inter.var.woff2') format('woff2');
+}
+
+/* variables */
+:root {
+ --fg: white;
+ --fg-faint: #a0a0a0;
+ --fg-scrollbar: #404040;
+ --bg: #101010;
+ --bg-input: #202020;
+ --bg-input-hover: #282828;
+ --focus-color: #4060a0;
+
+ --gradient-clickable: linear-gradient(to bottom, #404040, var(--bg-input) 65%, var(--bg-input));
+ --gradient-clickable-hover: linear-gradient(to bottom, #505050, var(--bg-input-hover) 65%, var(--bg-input-hover));
+
+ --timestamp-width: 70px;
+}
+
+/* reset */
+*, *::before, *::after {
+ box-sizing: border-box;
+ margin: 0;
+ padding: 0;
+}
+
+* {
+ color: var(--fg);
+ font-family: Lodestone, 'Inter var', sans-serif;
+ font-feature-settings: 'tnum';
+}
+
+html {
+ font-size: 16px;
+}
+
+/* layout and global styles */
+body {
+ padding: 50px;
+ height: 100dvh;
+ background-color: var(--bg-input-hover);
+}
+
+body > main {
+ display: flex;
+ flex-direction: column;
+ width: 100%;
+ height: 100%;
+ background-color: var(--bg);
+ border-radius: 20px;
+
+ box-shadow: 0 0 50px 0 rgba(0, 0, 0, 0.5);
+}
+
+/* message list */
+section#messages {
+ position: relative;
+ flex: 1;
+ min-height: 0;
+ padding: 20px;
+ line-height: 1.5;
+
+ .scroll-container {
+ height: 100%;
+ overflow-y: scroll;
+ scrollbar-color: var(--fg-scrollbar) var(--bg);
+
+ &.more-messages::before {
+ content: '';
+ position: absolute;
+ bottom: -20px;
+ left: 100px;
+ width: calc(100% - 200px);
+ height: 200px;
+ background-image: radial-gradient(50% 20% at 50% 100%, #60a0ff40, transparent);
+ }
+ }
+
+ ol {
+ list-style-type: none;
+ }
+
+ li {
+ display: flex;
+ align-items: flex-start;
+ gap: 10px;
+
+ .timestamp {
+ flex: 0 0 var(--timestamp-width);
+ color: var(--fg-faint);
+ text-align: right;
+ }
+ }
+}
+
+#timestamp-width-probe {
+ position: absolute;
+ top: 0;
+ left: 0;
+ pointer-events: none;
+ opacity: 0;
+}
+
+/* input bar, channel selector, ... */
+section#input {
+ flex-grow: 0;
+ padding: 20px;
+
+ form {
+ display: flex;
+ gap: 10px;
+ }
+
+ input, button, select {
+ font-size: 1rem;
+ border: 3px solid transparent;
+ border-radius: 20px;
+ background-color: var(--bg-input);
+
+ &:focus {
+ outline: 2px solid var(--focus-color);
+ }
+ }
+
+ button, select {
+ padding: 5px 15px;
+ border: 3px solid var(--bg-input);
+ background-image: var(--gradient-clickable);
+ cursor: pointer;
+
+ &:hover {
+ border-color: var(--bg-input-hover);
+ background-color: var(--bg-input-hover);
+ background-image: var(--gradient-clickable-hover);
+ }
+ }
+
+ .select-container, button {
+ position: relative;
+ flex-grow: 0;
+ flex-shrink: 0;
+ }
+
+ .select-container {
+ flex-basis: 0;
+
+ &::before {
+ /* "message-circle" icon from https://github.com/feathericons/feather, under MIT license */
+ mask-image: url('data:image/svg+xml,');
+ }
+
+ select {
+ width: 100%;
+ padding-left: 1.5rem;
+ padding-right: 1.5rem;
+ appearance: none;
+ color: transparent;
+ }
+ }
+
+ button {
+ padding-left: calc(20px + 1.5rem);
+
+ &::before {
+ /* "send" icon from https://github.com/feathericons/feather, under MIT license */
+ mask-image: url('data:image/svg+xml,');
+ }
+ }
+
+ .select-container::before, button::before {
+ content: '';
+ position: absolute;
+ left: 15px;
+ top: 50%;
+ transform: translateY(-50%);
+ width: 1.3rem;
+ height: 1.3rem;
+ background-color: var(--fg);
+ mask-size: 100%;
+ pointer-events: none;
+ }
+
+ .select-container::before {
+ left: 50%;
+ transform: translateX(-50%) translateY(-50%);
+ }
+
+ .input-container {
+ flex: 1;
+ position: relative;
+
+ #chat-input {
+ width: 100%;
+ padding: 5px 20px;
+ }
+
+ #channel-hint {
+ position: absolute;
+ top: -1.2em;
+ left: 23px;
+ font-size: 1.1rem;
+ font-weight: 550;
+ pointer-events: none;
+ }
+ }
+}
+
+/*** mobile ***/
+@media ((max-width: 600px) and (orientation: portrait)) or (max-width: 400px) {
+body {
+ padding: 0;
+}
+
+body > main {
+ border-radius: 0;
+ box-shadow: none;
+}
+
+section#input {
+ button {
+ max-width: 0;
+ padding-left: 1.5rem;
+ padding-right: 1.5rem;
+ color: transparent;
+ }
+
+ button::before {
+ left: 50%;
+ transform: translateX(-50%) translateY(-50%);
+ }
+}
+}
diff --git a/ChatTwo/Http/static/start.js b/ChatTwo/Http/static/start.js
new file mode 100644
index 0000000..e39a7a2
--- /dev/null
+++ b/ChatTwo/Http/static/start.js
@@ -0,0 +1,243 @@
+// websocket connection
+class WSConnection {
+ constructor() {
+ this.socket = new WebSocket('ws://192.168.2.106:9001/ws');
+ this.socket.addEventListener('open', this.onWSOpen.bind(this));
+ this.socket.addEventListener('close', this.onWSClose.bind(this));
+ this.socket.addEventListener('message', this.onWSMessage.bind(this));
+ }
+
+ onWSOpen() {
+ // send request for initial data (channels, currently selected channel)
+ }
+
+ onWSClose() {
+ // open new websocket here? for mobile
+ }
+
+ onWSMessage(event) {
+ try {
+ let eventData = JSON.parse(event.data);
+ for (let message of eventData.messages)
+ {
+ addMessage(message);
+ }
+ } catch (error) {
+ // TODO: error handling?
+ return;
+ }
+ }
+
+ send(message) {
+ this.socket.send(message);
+ }
+}
+
+const ws = new WSConnection();
+
+
+// channel switcher
+function updateChannelHint(label) {
+ document.getElementById('channel-hint').innerText = label;
+}
+document.getElementById('channel-select').addEventListener('change', (event) => {
+ updateChannelHint(event.target.value);
+ // TODO: send new channel to "backend"
+ // ws.send(...);
+});
+
+
+// functions for handling the message list
+function scrollMessagesToBottom() {
+ const messagesContainer = document.querySelector('#messages > .scroll-container');
+ messagesContainer.scrollTop = messagesContainer.scrollHeight - messagesContainer.offsetHeight;
+}
+
+function messagesContainerIsScrolledToBottom() {
+ const messagesContainer = document.querySelector('#messages > .scroll-container');
+ return messagesContainer.scrollTop == messagesContainer.scrollHeight - messagesContainer.offsetHeight;
+}
+
+function addMessage(messageData) {
+ const scrolledToBottom = messagesContainerIsScrolledToBottom();
+
+ const liMessage = document.createElement('li');
+ const spanTimestamp = document.createElement('span');
+ spanTimestamp.classList.add('timestamp');
+ const spanMessage = document.createElement('span');
+ spanMessage.classList.add('message');
+
+ // suggestions, update with actual data model
+ spanTimestamp.innerText = messageData.timestamp;
+ spanMessage.innerHTML = messageData.messageHTML; // or build HTML in here
+
+ liMessage.appendChild(spanTimestamp);
+ liMessage.appendChild(spanMessage);
+ document.getElementById('messages-list').appendChild(liMessage);
+
+ if (scrolledToBottom) {
+ scrollMessagesToBottom();
+ }
+}
+
+function clearMessages() {
+ document.getElementById('messages-list').innerHTML = '';
+}
+
+//
+document.querySelector('#messages > .scroll-container').addEventListener('scroll', () => {
+ const messagesContainer = document.querySelector('#messages > .scroll-container');
+ if (!messagesContainerIsScrolledToBottom()) {
+ messagesContainer.classList.add('more-messages');
+ } else {
+ messagesContainer.classList.remove('more-messages');
+ }
+});
+
+
+// handle message sending
+document.querySelector('#input > form').addEventListener('submit', (event) => {
+ event.preventDefault();
+
+ const chatInput = document.getElementById('chat-input');
+ const message = chatInput.value;
+
+ fetch('/send', {
+ method: 'POST',
+ body: message,
+ headers: {
+ 'Content-type': 'application/txt; charset=UTF-8'
+ }
+ });
+
+ chatInput.value = '';
+});
+
+
+// calculate timestamp width
+// to ensure that all timestamps have the same width. some typefaces have the same width across
+// all number glyphs, others do not. the solution below is very rudimentary; at the very least,
+// delaying it to account for font loading might make sense. perhaps there's an even better way?
+window.setTimeout(() => {
+ const widthProbe = document.getElementById('timestamp-width-probe');
+ widthProbe.innerText = '88:88'; // assume 8 to be widest glyph
+ document.body.style.setProperty('--timestamp-width', Math.ceil(widthProbe.clientWidth) + 'px');
+}, 100);
+
+// From kizer
+async function AddGfdStylesheet(gfdPath, texPath) {
+ const texPromise = LoadTexAsBlob(texPath);
+ const gfdPromise = LoadGfd(gfdPath);
+ const texUrl = URL.createObjectURL(await texPromise);
+ const gfd = await gfdPromise;
+
+ let stylesheets = [];
+ for (const entry of gfd) {
+ if (entry.width * entry.height <= 0)
+ continue;
+
+ if (entry.redirect !== 0) {
+ stylesheets[entry.redirect][0].push(entry.id);
+ continue;
+ }
+
+ stylesheets[entry.id] = [
+ [entry.id],
+ [
+ `background-position: -${entry.left}px -${entry.top}px`,
+ `background-image: url('${texUrl}')`,
+ `width: ${entry.width}px`,
+ `height: ${entry.height}px`,
+ `margin-bottom: -20px`
+ ].join(";"),
+ [
+ `background-position: -${entry.left * 2}px -${entry.top * 2 + 341}px`,
+ `background-image: url('${texUrl}')`,
+ `width: ${entry.width * 2}px`,
+ `height: ${entry.height * 2}px`,
+ `margin-bottom: -40px`
+ ].join(";")
+ ];
+ }
+
+ let stylesheet = ".gfd-icon::before { content: ''; display: inline-block; overflow: hidden; vertical-align: top; height:0; }";
+ for (const entry of stylesheets) {
+ if (!entry)
+ continue;
+
+ stylesheet += `\n${entry[0].map(x => `.gfd-icon.gfd-icon-${x}::before`).join(', ')}{${entry[1]};}`;
+ stylesheet += `\n${entry[0].map(x => `.gfd-icon.gfd-icon-hq-${x}::before`).join(', ')}{${entry[2]};}`;
+ }
+
+ const styleNode = document.createElement("style");
+ styleNode.type = "text/css";
+ styleNode.appendChild(document.createTextNode(stylesheet));
+ document.head.appendChild(styleNode);
+}
+
+async function LoadTexAsBlob(path) {
+ const tex = ParseTex(await (await fetch(path)).arrayBuffer());
+ if (tex.format !== 0x1450) // B8G8R8A8
+ throw "Not supported";
+
+ const dataArray = new Uint8ClampedArray(tex.buffer, tex.offsetToSurface[0], tex.width * tex.height * 4);
+ for (let i = 0; i < dataArray.length; i += 4) {
+ const t = dataArray[i];
+ dataArray[i] = dataArray[i + 2];
+ dataArray[i + 2] = t;
+ }
+ const imageData = new ImageData(dataArray, tex.width, tex.height);
+ const bitmap = await createImageBitmap(imageData);
+
+ const canvas = new OffscreenCanvas(tex.width, tex.height);
+ canvas.getContext('bitmaprenderer').transferFromImageBitmap(bitmap);
+ return await canvas.convertToBlob();
+}
+
+async function LoadGfd(path) {
+ const buffer = new DataView(await (await fetch(path)).arrayBuffer());
+ const count = buffer.getInt32(8, true);
+ const entries = new Array(count);
+ for (let i = 0; i < count; i++) {
+ const offset = 0x10 + (i * 0x10);
+ entries[i] = {
+ id: buffer.getInt16(offset, true),
+ left: buffer.getInt16(offset + 2, true),
+ top: buffer.getInt16(offset + 4, true),
+ width: buffer.getInt16(offset + 6, true),
+ height: buffer.getInt16(offset + 8, true),
+ unk0A: buffer.getInt16(offset + 10, true),
+ redirect: buffer.getInt16(offset + 12, true),
+ unk0E: buffer.getInt16(offset + 14, true),
+ };
+ }
+
+ return entries;
+}
+
+function ParseTex(arrayBuffer) {
+ const buffer = new DataView(arrayBuffer);
+ const type = buffer.getInt32(0, true);
+ const format = buffer.getInt32(4, true);
+ const width = buffer.getInt16(8, true);
+ const height = buffer.getInt16(10, true);
+ const depth = buffer.getInt16(12, true);
+ const mipsAndFlag = buffer.getInt8(14, true);
+ const arraySize = buffer.getInt8(15, true);
+ const lodOffsets = [buffer.getInt32(16, true), buffer.getInt32(20, true), buffer.getInt32(24, true)];
+ const offsetToSurface = [buffer.getInt32(28, true), buffer.getInt32(32, true), buffer.getInt32(36, true), buffer.getInt32(40, true), buffer.getInt32(44, true), buffer.getInt32(48, true), buffer.getInt32(52, true), buffer.getInt32(56, true), buffer.getInt32(60, true), buffer.getInt32(64, true), buffer.getInt32(68, true), buffer.getInt32(72, true), buffer.getInt32(76, true)];
+ return {
+ buffer: arrayBuffer,
+ type,
+ format,
+ width,
+ height,
+ depth,
+ mipsAndFlag,
+ arraySize,
+ lodOffsets,
+ offsetToSurface,
+ };
+}
+
+AddGfdStylesheet("/files/gfdata.gfd", "/files/fonticon_ps5.tex");
diff --git a/ChatTwo/Http/templates/auth.html b/ChatTwo/Http/templates/auth.html
new file mode 100644
index 0000000..4a8a1e5
--- /dev/null
+++ b/ChatTwo/Http/templates/auth.html
@@ -0,0 +1,19 @@
+
+
+
+ Authentication
+
+
+
+
+
+
Authcode
+
+
+

+
+
+
\ No newline at end of file
diff --git a/ChatTwo/Http/templates/start.html b/ChatTwo/Http/templates/start.html
new file mode 100644
index 0000000..12359ba
Binary files /dev/null and b/ChatTwo/Http/templates/start.html differ
diff --git a/ChatTwo/MessageManager.cs b/ChatTwo/MessageManager.cs
index a8261f0..f84624f 100644
--- a/ChatTwo/MessageManager.cs
+++ b/ChatTwo/MessageManager.cs
@@ -255,13 +255,20 @@ internal class MessageManager : IAsyncDisposable
if (Plugin.Config.DatabaseBattleMessages || !message.Code.IsBattle())
Store.UpsertMessage(message);
+ var currentTabId = Plugin.ChatLogWindow.CurrentTab?.Identifier ?? Guid.Empty;
var currentMatches = Plugin.ChatLogWindow.CurrentTab?.Matches(message) ?? false;
foreach (var tab in Plugin.Config.Tabs)
{
var unread = !(tab.UnreadMode == UnreadMode.Unseen && Plugin.ChatLogWindow.CurrentTab != tab && currentMatches);
if (tab.Matches(message))
+ {
tab.AddMessage(message, unread);
+
+ if (tab.Identifier == currentTabId)
+ Plugin.ServerCore.SendNewMessage(message);
+ }
+
}
}
diff --git a/ChatTwo/Plugin.cs b/ChatTwo/Plugin.cs
index 4e9a600..b974ce3 100755
--- a/ChatTwo/Plugin.cs
+++ b/ChatTwo/Plugin.cs
@@ -2,6 +2,7 @@ using System.Diagnostics;
using System.Diagnostics.CodeAnalysis;
using System.Globalization;
using ChatTwo.GameFunctions;
+using ChatTwo.Http;
using ChatTwo.Ipc;
using ChatTwo.Resources;
using ChatTwo.Ui;
@@ -59,6 +60,8 @@ public sealed class Plugin : IDalamudPlugin
internal ExtraChat ExtraChat { get; }
internal FontManager FontManager { get; }
+ internal ServerCore ServerCore { get; }
+
internal int DeferredSaveFrames = -1;
internal DateTime GameStarted { get; }
@@ -126,9 +129,13 @@ public sealed class Plugin : IDalamudPlugin
// profiling difficult.
AutoTranslate.PreloadCache();
#endif
+
+ ServerCore = new ServerCore(this);
+ Task.Run(() => ServerCore.Start());
}
- catch
+ catch (Exception ex)
{
+ Log.Error(ex, "Plugin load threw an error, turning off plugin");
Dispose();
// Re-throw the exception to fail the plugin load.
throw;
@@ -160,6 +167,7 @@ public sealed class Plugin : IDalamudPlugin
Commands?.Dispose();
EmoteCache.Dispose();
+ ServerCore.Dispose();
}
private void Draw()
diff --git a/ChatTwo/Resources/Language.Designer.cs b/ChatTwo/Resources/Language.Designer.cs
index b97b620..4ce28a0 100755
--- a/ChatTwo/Resources/Language.Designer.cs
+++ b/ChatTwo/Resources/Language.Designer.cs
@@ -3407,6 +3407,33 @@ namespace ChatTwo.Resources {
}
}
+ ///
+ /// Looks up a localized string similar to Webinterface.
+ ///
+ internal static string Options_Webinterface_Tab {
+ get {
+ return ResourceManager.GetString("Options_Webinterface_Tab", resourceCulture);
+ }
+ }
+
+ ///
+ /// Looks up a localized string similar to Enables the webinterface that can be accessed with a browser..
+ ///
+ internal static string Options_WebinterfaceEnable_Description {
+ get {
+ return ResourceManager.GetString("Options_WebinterfaceEnable_Description", resourceCulture);
+ }
+ }
+
+ ///
+ /// Looks up a localized string similar to Enable.
+ ///
+ internal static string Options_WebinterfaceEnable_Name {
+ get {
+ return ResourceManager.GetString("Options_WebinterfaceEnable_Name", resourceCulture);
+ }
+ }
+
///
/// Looks up a localized string similar to Window opacity.
///
@@ -3541,5 +3568,41 @@ namespace ChatTwo.Resources {
return ResourceManager.GetString("UnreadMode_Unseen_Tooltip", resourceCulture);
}
}
+
+ ///
+ /// Looks up a localized string similar to Your current password to access the webinterface:.
+ ///
+ internal static string Webinterface_CurrentPassword {
+ get {
+ return ResourceManager.GetString("Webinterface_CurrentPassword", resourceCulture);
+ }
+ }
+
+ ///
+ /// Looks up a localized string similar to Reset your password and invalidate all session tokens..
+ ///
+ internal static string Webinterface_PasswordReset_Tooltip {
+ get {
+ return ResourceManager.GetString("Webinterface_PasswordReset_Tooltip", resourceCulture);
+ }
+ }
+
+ ///
+ /// Looks up a localized string similar to Webinterface Status:.
+ ///
+ internal static string Webinterface_Status {
+ get {
+ return ResourceManager.GetString("Webinterface_Status", resourceCulture);
+ }
+ }
+
+ ///
+ /// Looks up a localized string similar to Active:.
+ ///
+ internal static string Webinterface_Status_Active {
+ get {
+ return ResourceManager.GetString("Webinterface_Status_Active", resourceCulture);
+ }
+ }
}
}
diff --git a/ChatTwo/Resources/Language.resx b/ChatTwo/Resources/Language.resx
index d5daa0a..492fa46 100644
--- a/ChatTwo/Resources/Language.resx
+++ b/ChatTwo/Resources/Language.resx
@@ -463,6 +463,9 @@
Miscellaneous
+
+ Webinterface
+
Use Dalamud's default language
@@ -547,6 +550,12 @@
If this is enabled, the Auto Translate list will be sorted alphabetically.
+
+ Enable
+
+
+ Enables the webinterface that can be accessed with a browser.
+
Override Style
@@ -559,6 +568,18 @@
Cycle chat tab backwards keybind
+
+ Your current password to access the webinterface:
+
+
+ Reset your password and invalidate all session tokens.
+
+
+ Webinterface Status:
+
+
+ Active:
+
none set
diff --git a/ChatTwo/Ui/ChatLogWindow.cs b/ChatTwo/Ui/ChatLogWindow.cs
index 5c614fa..7826586 100644
--- a/ChatTwo/Ui/ChatLogWindow.cs
+++ b/ChatTwo/Ui/ChatLogWindow.cs
@@ -58,7 +58,7 @@ public sealed class ChatLogWindow : Window
private int InputBacklogIdx = -1;
private int LastTab { get; set; }
private InputChannel? TempChannel;
- private TellTarget? TellTarget;
+ internal TellTarget? TellTarget;
public bool TellSpecial;
private readonly Stopwatch LastResize = new();
private AutoCompleteInfo? AutoCompleteInfo;
@@ -555,84 +555,7 @@ public sealed class ChatLogWindow : Window
using (ImRaii.PushStyle(ImGuiStyleVar.ItemSpacing, Vector2.Zero))
{
- if (TellTarget != null)
- {
- var playerName = TellTarget.Name;
- if (ScreenshotMode)
- // Note: don't use HidePlayerInString here because
- // abbreviation settings do not affect this.
- playerName = HashPlayer(TellTarget.Name, TellTarget.World);
- var world = WorldSheet.GetRow(TellTarget.World)?.Name?.RawString ?? "???";
-
- DrawChunks(new Chunk[]
- {
- new TextChunk(ChunkSource.None, null, "Tell "),
- new TextChunk(ChunkSource.None, null, playerName),
- new IconChunk(ChunkSource.None, null, BitmapFontIcon.CrossWorld),
- new TextChunk(ChunkSource.None, null, world),
- });
- }
- else if (TempChannel != null)
- {
- if (TempChannel.Value.IsLinkshell())
- {
- var idx = (uint) TempChannel.Value - (uint) InputChannel.Linkshell1;
- var lsName = Plugin.Functions.Chat.GetLinkshellName(idx);
- ImGui.TextUnformatted($"LS #{idx + 1}: {lsName}");
- }
- else if (TempChannel.Value.IsCrossLinkshell())
- {
- var idx = (uint) TempChannel.Value - (uint) InputChannel.CrossLinkshell1;
- var cwlsName = Plugin.Functions.Chat.GetCrossLinkshellName(idx);
- ImGui.TextUnformatted($"CWLS [{idx + 1}]: {cwlsName}");
- }
- else
- {
- ImGui.TextUnformatted(TempChannel.Value.ToChatType().Name());
- }
- }
- else if (activeTab is { Channel: { } channel })
- {
- // We cannot lookup ExtraChat channel names from index over
- // IPC so we just don't show the name if it's the tabs
- // channel.
- //
- // We don't call channel.ToChatType().Name() as it has the
- // long name as used in the settings window.
- ImGui.TextUnformatted(channel.IsExtraChatLinkshell() ? $"ECLS [{channel.LinkshellIndex() + 1}]" : channel.ToChatType().Name());
- }
- else if (Plugin.ExtraChat.ChannelOverride is var (overrideName, _))
- {
- ImGui.TextUnformatted(overrideName);
- }
- else if (ScreenshotMode && Plugin.Functions.Chat.Channel is (InputChannel.Tell, _, var tellPlayerName, var tellWorldId))
- {
- if (!string.IsNullOrWhiteSpace(tellPlayerName) && tellWorldId != 0)
- {
- // Note: don't use HidePlayerInString here because
- // abbreviation settings do not affect this.
- var playerName = HashPlayer(tellPlayerName, tellWorldId);
- var world = WorldSheet.GetRow(tellWorldId)?.Name?.RawString ?? "???";
-
- DrawChunks(new Chunk[]
- {
- new TextChunk(ChunkSource.None, null, "Tell "),
- new TextChunk(ChunkSource.None, null, playerName),
- new IconChunk(ChunkSource.None, null, BitmapFontIcon.CrossWorld),
- new TextChunk(ChunkSource.None, null, world),
- });
- }
- else
- {
- // We still need to censor the name if we couldn't read
- // valid data.
- ImGui.TextUnformatted("Tell");
- }
- }
- else
- {
- DrawChunks(Plugin.Functions.Chat.Channel.Name);
- }
+ DrawChannelName(activeTab);
}
var beforeIcon = ImGui.GetCursorPos();
@@ -826,6 +749,105 @@ public sealed class ChatLogWindow : Window
GameFunctions.GameFunctions.ClickNoviceNetworkButton();
}
+ private void DrawChannelName(Tab? activeTab)
+ {
+ DrawChunks(ReadChannelName(activeTab));
+ }
+
+ internal Chunk[] ReadChannelName(Tab? activeTab)
+ {
+ Chunk[] channelNameChunks;
+ if (TellTarget != null)
+ {
+ var playerName = TellTarget.Name;
+ if (ScreenshotMode)
+ // Note: don't use HidePlayerInString here because
+ // abbreviation settings do not affect this.
+ playerName = HashPlayer(TellTarget.Name, TellTarget.World);
+ var world = WorldSheet.GetRow(TellTarget.World)?.Name?.RawString ?? "???";
+
+ channelNameChunks = new Chunk[]
+ {
+ new TextChunk(ChunkSource.None, null, "Tell "),
+ new TextChunk(ChunkSource.None, null, playerName),
+ new IconChunk(ChunkSource.None, null, BitmapFontIcon.CrossWorld),
+ new TextChunk(ChunkSource.None, null, world),
+ };
+ }
+ else if (TempChannel != null)
+ {
+ string name;
+ if (TempChannel.Value.IsLinkshell())
+ {
+ var idx = (uint) TempChannel.Value - (uint) InputChannel.Linkshell1;
+ var lsName = Plugin.Functions.Chat.GetLinkshellName(idx);
+ name = $"LS #{idx + 1}: {lsName}";
+ }
+ else if (TempChannel.Value.IsCrossLinkshell())
+ {
+ var idx = (uint) TempChannel.Value - (uint) InputChannel.CrossLinkshell1;
+ var cwlsName = Plugin.Functions.Chat.GetCrossLinkshellName(idx);
+ name = $"CWLS [{idx + 1}]: {cwlsName}";
+ }
+ else
+ {
+ name = TempChannel.Value.ToChatType().Name();
+ }
+
+ channelNameChunks = [new TextChunk(ChunkSource.None, null, name)];
+ }
+ else if (activeTab is { Channel: { } channel })
+ {
+ // We cannot lookup ExtraChat channel names from index over
+ // IPC so we just don't show the name if it's the tabs
+ // channel.
+ //
+ // We don't call channel.ToChatType().Name() as it has the
+ // long name as used in the settings window.
+ channelNameChunks = new Chunk[]
+ {
+ new TextChunk(ChunkSource.None, null, channel.IsExtraChatLinkshell() ? $"ECLS [{channel.LinkshellIndex() + 1}]" : channel.ToChatType().Name())
+ };
+ }
+ else if (Plugin.ExtraChat.ChannelOverride is var (overrideName, _))
+ {
+ channelNameChunks = new Chunk[]
+ {
+ new TextChunk(ChunkSource.None, null, overrideName)
+ };
+ }
+ else if (ScreenshotMode && Plugin.Functions.Chat.Channel is (InputChannel.Tell, _, var tellPlayerName, var tellWorldId))
+ {
+ if (!string.IsNullOrWhiteSpace(tellPlayerName) && tellWorldId != 0)
+ {
+ // Note: don't use HidePlayerInString here because
+ // abbreviation settings do not affect this.
+ var playerName = HashPlayer(tellPlayerName, tellWorldId);
+ var world = WorldSheet.GetRow(tellWorldId)?.Name?.RawString ?? "???";
+
+ channelNameChunks = new Chunk[]
+ {
+ new TextChunk(ChunkSource.None, null, "Tell "),
+ new TextChunk(ChunkSource.None, null, playerName),
+ new IconChunk(ChunkSource.None, null, BitmapFontIcon.CrossWorld),
+ new TextChunk(ChunkSource.None, null, world),
+ };
+ }
+ else
+ {
+ // We still need to censor the name if we couldn't read
+ // valid data.
+ channelNameChunks = [new TextChunk(ChunkSource.None, null, "Tell")];
+ }
+ }
+ else
+ {
+ channelNameChunks = Plugin.Functions.Chat.Channel.Name.ToArray();
+ }
+
+ return channelNameChunks;
+ }
+
internal void SetChannel(InputChannel? channel)
{
channel ??= InputChannel.Say;
@@ -852,7 +874,7 @@ public sealed class ChatLogWindow : Window
GameFunctions.Chat.SetChannel(channel.Value);
}
- private void SendChatBox(Tab? activeTab)
+ internal void SendChatBox(Tab? activeTab)
{
if (!string.IsNullOrWhiteSpace(Chat))
{
@@ -1727,7 +1749,7 @@ public sealed class ChatLogWindow : Window
}
- private string HidePlayerInString(string str, string playerName, uint worldId)
+ internal string HidePlayerInString(string str, string playerName, uint worldId)
{
var expected = Plugin.Functions.Chat.AbbreviatePlayerName(playerName);
var hash = HashPlayer(playerName, worldId);
diff --git a/ChatTwo/Ui/Settings.cs b/ChatTwo/Ui/Settings.cs
index b9b6c92..9952a57 100755
--- a/ChatTwo/Ui/Settings.cs
+++ b/ChatTwo/Ui/Settings.cs
@@ -37,10 +37,11 @@ public sealed class SettingsWindow : Window
new ChatLog(Plugin, Mutable),
new Emote(Plugin, Mutable),
new Preview(Plugin, Mutable),
- new Ui.SettingsTabs.Fonts(Mutable),
+ new Fonts(Mutable),
new ChatColours(Plugin, Mutable),
new Tabs(Plugin, Mutable),
new Database(Plugin, Mutable),
+ new Webinterface(Plugin, Mutable),
new Miscellaneous(Mutable),
new Changelog(Mutable),
new About(),
diff --git a/ChatTwo/Ui/SettingsTabs/Webinterface.cs b/ChatTwo/Ui/SettingsTabs/Webinterface.cs
new file mode 100644
index 0000000..badb3c5
--- /dev/null
+++ b/ChatTwo/Ui/SettingsTabs/Webinterface.cs
@@ -0,0 +1,70 @@
+using ChatTwo.Resources;
+using ChatTwo.Util;
+using Dalamud.Interface;
+using Dalamud.Interface.Colors;
+using Dalamud.Interface.Utility.Raii;
+using ImGuiNET;
+
+namespace ChatTwo.Ui.SettingsTabs;
+
+internal sealed class Webinterface(Plugin plugin, Configuration mutable) : ISettingsTab
+{
+ private Plugin Plugin { get; } = plugin;
+ private Configuration Mutable { get; } = mutable;
+ public string Name => Language.Options_Webinterface_Tab + "###tabs-Webinterface";
+
+ public void Draw(bool changed)
+ {
+ ImGuiUtil.WrappedTextWithColor(ImGuiColors.DalamudWhite, "On checking 'Enabled' this will enable and load up Chat2's built-in web interface, which will allow devices on your network to access in-game chat. This feature may be used to allow a phone or another computer to see Chat2 activity, switch channels, and send messages as though you were typing in FFXIV itself.");
+ ImGui.Spacing();
+ ImGuiUtil.WrappedTextWithColor(ImGuiColors.HealerGreen, "Note: This will require at least a semi-modern browser in order to function correctly.");
+ ImGui.Spacing();
+ ImGuiUtil.WrappedTextWithColor(ImGuiColors.DalamudOrange, "For reasons of account security, this feature is not intended for use outside of your local network, you have been warned!");
+
+ ImGui.Spacing();
+ ImGui.Spacing();
+ ImGuiUtil.WrappedTextWithColor(ImGuiColors.DalamudViolet, "Do Not:");
+ using (ImRaii.PushIndent(15.0f))
+ {
+ ImGuiUtil.WrappedTextWithColor(ImGuiColors.DalamudViolet, "- Forward the ports used (9000 and 9001)");
+ ImGuiUtil.WrappedTextWithColor(ImGuiColors.DalamudViolet, "- Share your authentication code with anyone else");
+ ImGuiUtil.WrappedTextWithColor(ImGuiColors.DalamudViolet, "- Expect multi-boxing to work with this (only first client is tracked and utilised)");
+ }
+ ImGui.Spacing();
+ ImGuiUtil.WrappedTextWithColor(ImGuiColors.DalamudOrange, "No support will be provided if any of the 'Do Not' clauses aren't respected and adhered to appropriately.");
+
+ ImGui.Spacing();
+ ImGui.Separator();
+ ImGui.Spacing();
+
+ ImGui.Checkbox(Language.Options_WebinterfaceEnable_Name, ref Mutable.WebinterfaceEnabled);
+ ImGuiUtil.HelpText(Language.Options_WebinterfaceEnable_Description);
+ ImGui.Spacing();
+
+ if (!Mutable.WebinterfaceEnabled)
+ return;
+
+ ImGui.Separator();
+ ImGui.Spacing();
+
+ ImGuiUtil.WrappedTextWithColor(ImGuiColors.DalamudOrange, Language.Webinterface_CurrentPassword);
+ ImGui.TextUnformatted(Mutable.WebinterfacePassword);
+ ImGui.SameLine();
+ if (ImGuiUtil.IconButton(FontAwesomeIcon.Recycle, tooltip: Language.Webinterface_PasswordReset_Tooltip))
+ Mutable.WebinterfacePassword = WebinterfaceUtil.GenerateSimpleAuthCode();
+
+ ImGuiUtil.WrappedTextWithColor(ImGuiColors.HealerGreen, Language.Webinterface_Status);
+ using (ImRaii.PushIndent(10.0f))
+ {
+ ImGui.TextUnformatted(Language.Webinterface_Status_Active);
+ ImGui.SameLine();
+
+ var isActive = Plugin.ServerCore.GetStats();
+ using (Plugin.FontManager.FontAwesome.Push())
+ using (ImRaii.PushColor(ImGuiCol.Text, isActive ? ImGuiColors.HealerGreen : ImGuiColors.DalamudOrange))
+ {
+ ImGui.TextUnformatted($"{(isActive ? FontAwesomeIcon.Check.ToIconString() : FontAwesomeIcon.Cross.ToIconString())}");
+ }
+ }
+ }
+}
diff --git a/ChatTwo/Util/ColourUtil.cs b/ChatTwo/Util/ColourUtil.cs
index 0a5154d..7187ff4 100755
--- a/ChatTwo/Util/ColourUtil.cs
+++ b/ChatTwo/Util/ColourUtil.cs
@@ -4,7 +4,7 @@ using System.Numerics;
namespace ChatTwo.Util;
internal static class ColourUtil {
- private static (byte r, byte g, byte b, byte a) RgbaToComponents(uint rgba) {
+ internal static (byte r, byte g, byte b, byte a) RgbaToComponents(uint rgba) {
var r = (byte) ((rgba & 0xFF000000) >> 24);
var g = (byte) ((rgba & 0xFF0000) >> 16);
var b = (byte) ((rgba & 0xFF00) >> 8);
diff --git a/ChatTwo/Util/IconUtil.cs b/ChatTwo/Util/IconUtil.cs
index 11cd426..c7aa2c9 100755
--- a/ChatTwo/Util/IconUtil.cs
+++ b/ChatTwo/Util/IconUtil.cs
@@ -1,5 +1,7 @@
using System.Runtime.CompilerServices;
using System.Runtime.InteropServices;
+using SixLabors.ImageSharp;
+using SixLabors.ImageSharp.PixelFormats;
namespace ChatTwo.Util;
@@ -145,4 +147,11 @@ internal static class IconUtil {
return new GfdFileView(new ReadOnlySpan(Unsafe.AsPointer(ref GfdFile[0]), GfdFile.Length));
}
}
+
+ public static byte[] ImageToRaw(this Image image)
+ {
+ var data = new byte[4 * image.Width * image.Height];
+ image.CopyPixelDataTo(data);
+ return data;
+ }
}
diff --git a/ChatTwo/Util/ImGuiUtil.cs b/ChatTwo/Util/ImGuiUtil.cs
index 30a261c..52843da 100755
--- a/ChatTwo/Util/ImGuiUtil.cs
+++ b/ChatTwo/Util/ImGuiUtil.cs
@@ -668,6 +668,14 @@ internal static class ImGuiUtil
}
}
+ public static void WrappedTextWithColor(Vector4 color, string text)
+ {
+ using (ImRaii.PushColor(ImGuiCol.Text, color))
+ {
+ ImGui.TextWrapped(text);
+ }
+ }
+
// Used to avoid pops if condition is false for Push.
private static void Nop() { }
}
diff --git a/ChatTwo/Util/WebinterfaceUtil.cs b/ChatTwo/Util/WebinterfaceUtil.cs
new file mode 100644
index 0000000..3ae93ca
--- /dev/null
+++ b/ChatTwo/Util/WebinterfaceUtil.cs
@@ -0,0 +1,11 @@
+namespace ChatTwo.Util;
+
+public class WebinterfaceUtil
+{
+ private static readonly Random Rng = new();
+
+ public static string GenerateSimpleAuthCode()
+ {
+ return (100000 + Rng.Next() % 100000).ToString()[1..];
+ }
+}
\ No newline at end of file
diff --git a/ChatTwo/packages.lock.json b/ChatTwo/packages.lock.json
index 4befe7c..1025f7b 100644
--- a/ChatTwo/packages.lock.json
+++ b/ChatTwo/packages.lock.json
@@ -8,6 +8,26 @@
"resolved": "2.1.13",
"contentHash": "rMN1omGe8536f4xLMvx9NwfvpAc9YFFfeXJ1t4P4PE6Gu8WCIoFliR1sh07hM+bfODmesk/dvMbji7vNI+B/pQ=="
},
+ "EmbedIO": {
+ "type": "Direct",
+ "requested": "[3.5.2, )",
+ "resolved": "3.5.2",
+ "contentHash": "YU4j+3XvuO8/VPkNf7KWOF1TpMhnyVhXnPsG1mvnDhTJ9D5BZOFXVDvCpE/SkQ1AJ0Aa+dXOVSW3ntgmLL7aJg==",
+ "dependencies": {
+ "Unosquare.Swan.Lite": "3.1.0"
+ }
+ },
+ "HtmlSanitizer": {
+ "type": "Direct",
+ "requested": "[8.1.870, )",
+ "resolved": "8.1.870",
+ "contentHash": "bQWYaKg8PrlgnhM9sPALl0UorpjWQkPTQiSTVyvm8imqF9lCLqBmtC0adUDi8xUYcdg6SJC+aHCw1MOjcg+Wnw==",
+ "dependencies": {
+ "AngleSharp": "[0.17.1]",
+ "AngleSharp.Css": "[0.17.0]",
+ "System.Collections.Immutable": "8.0.0"
+ }
+ },
"MessagePack": {
"type": "Direct",
"requested": "[2.5.140, )",
@@ -41,6 +61,43 @@
"resolved": "3.1.5",
"contentHash": "lNtlq7dSI/QEbYey+A0xn48z5w4XHSffF8222cC4F4YwTXfEImuiBavQcWjr49LThT/pRmtWJRcqA/PlL+eJ6g=="
},
+ "Watson.Lite": {
+ "type": "Direct",
+ "requested": "[6.2.2, )",
+ "resolved": "6.2.2",
+ "contentHash": "yRYcaRFSnqwy/rrPd+WIFLyvNfgTANo447KiK7j1mKpgUIYPNDAKYxqj9wkDVfvGIHxzt4z3CFK6eWOGWUYsuQ==",
+ "dependencies": {
+ "CavemanTcp": "2.0.2",
+ "Watson.Core": "6.2.2"
+ }
+ },
+ "AngleSharp": {
+ "type": "Transitive",
+ "resolved": "0.17.1",
+ "contentHash": "5MPI4bbixlwxb0W/smOMeIR+QlxMy5/5jD+WnIAw4pBC+7AhLPe5bS3cLgQMJyvd6q0A48sG+uYOt/ep406GLA==",
+ "dependencies": {
+ "System.Buffers": "4.5.1",
+ "System.Text.Encoding.CodePages": "6.0.0"
+ }
+ },
+ "AngleSharp.Css": {
+ "type": "Transitive",
+ "resolved": "0.17.0",
+ "contentHash": "bg0AcugmX6BFEi/DHG61QrwRU8iuiX4H8LZehdIzYdqOM/dgb3BsCTzNIcc1XADn4+xfQEdVwJYTSwUxroL4vg==",
+ "dependencies": {
+ "AngleSharp": "[0.17.0, 0.18.0)"
+ }
+ },
+ "CavemanTcp": {
+ "type": "Transitive",
+ "resolved": "2.0.2",
+ "contentHash": "T161WxkgNTMC5SQPPDfLCGCD2j3YTvF0ZUGy/1pcGaYi6y7mcSzPymgVzG/6mOjAxJNGotK8hf1iOD8eT3bbhQ=="
+ },
+ "IpMatcher": {
+ "type": "Transitive",
+ "resolved": "1.0.4.4",
+ "contentHash": "93zP6KaDdRttUI3fFet6mYQGed1gTVK2amIOxpq4tDl3NRY+2tP/iA30hvRrs+ZY8u8KGF/J7kX6l7jrhKHfZA=="
+ },
"MessagePack.Annotations": {
"type": "Transitive",
"resolved": "2.5.140",
@@ -59,6 +116,11 @@
"resolved": "17.6.3",
"contentHash": "N0ZIanl1QCgvUumEL1laasU0a7sOE5ZwLZVTn0pAePnfhq8P7SvTjF8Axq+CnavuQkmdQpGNXQ1efZtu5kDFbA=="
},
+ "RegexMatcher": {
+ "type": "Transitive",
+ "resolved": "1.0.8",
+ "contentHash": "h3wa6sKJnyojCH2wJpvHdQRGWn+eKiSYGKPInIzvdl+SaKhUN9Qpr0CK4A3aqrYFBTEJKFN7pzYlNR4S5gZnaw=="
+ },
"SQLitePCLRaw.bundle_e_sqlite3": {
"type": "Transitive",
"resolved": "2.1.6",
@@ -89,6 +151,16 @@
"SQLitePCLRaw.core": "2.1.6"
}
},
+ "System.Buffers": {
+ "type": "Transitive",
+ "resolved": "4.5.1",
+ "contentHash": "Rw7ijyl1qqRS0YQD/WycNst8hUUMgrMH4FCn1nNm27M4VxchZ1js3fVjQaANHO5f3sN4isvP4a+Met9Y4YomAg=="
+ },
+ "System.Collections.Immutable": {
+ "type": "Transitive",
+ "resolved": "8.0.0",
+ "contentHash": "AurL6Y5BA1WotzlEvVaIDpqzpIPvYnnldxru8oXJU2yFxFUy3+pNXjXd1ymO+RA0rq0+590Q8gaz2l3Sr7fmqg=="
+ },
"System.Memory": {
"type": "Transitive",
"resolved": "4.5.3",
@@ -98,6 +170,62 @@
"type": "Transitive",
"resolved": "6.0.0",
"contentHash": "/iUeP3tq1S0XdNNoMz5C9twLSrM/TH+qElHkXWaPvuNOt+99G75NrV0OS2EqHx5wMN7popYjpc8oTjC1y16DLg=="
+ },
+ "System.Text.Encoding.CodePages": {
+ "type": "Transitive",
+ "resolved": "6.0.0",
+ "contentHash": "ZFCILZuOvtKPauZ/j/swhvw68ZRi9ATCfvGbk1QfydmcXBkIWecWKn/250UH7rahZ5OoDBaiAudJtPvLwzw85A==",
+ "dependencies": {
+ "System.Runtime.CompilerServices.Unsafe": "6.0.0"
+ }
+ },
+ "System.Text.Encodings.Web": {
+ "type": "Transitive",
+ "resolved": "8.0.0",
+ "contentHash": "yev/k9GHAEGx2Rg3/tU6MQh4HGBXJs70y7j1LaM1i/ER9po+6nnQ6RRqTJn1E7Xu0fbIFK80Nh5EoODxrbxwBQ=="
+ },
+ "System.Text.Json": {
+ "type": "Transitive",
+ "resolved": "8.0.4",
+ "contentHash": "bAkhgDJ88XTsqczoxEMliSrpijKZHhbJQldhAmObj/RbrN3sU5dcokuXmWJWsdQAhiMJ9bTayWsL1C9fbbCRhw==",
+ "dependencies": {
+ "System.Text.Encodings.Web": "8.0.0"
+ }
+ },
+ "System.ValueTuple": {
+ "type": "Transitive",
+ "resolved": "4.5.0",
+ "contentHash": "okurQJO6NRE/apDIP23ajJ0hpiNmJ+f0BwOlB/cSqTLQlw5upkf+5+96+iG2Jw40G1fCVCyPz/FhIABUjMR+RQ=="
+ },
+ "Timestamps": {
+ "type": "Transitive",
+ "resolved": "1.0.9",
+ "contentHash": "xdCVp+T4VFXOT+Ube2Cz527fnFXFUxQK24uPMGcCmICIpIcGCXPtI7vKLnWsJ6Nfc1B92tNBK9e/I8NDq2ee6g=="
+ },
+ "Unosquare.Swan.Lite": {
+ "type": "Transitive",
+ "resolved": "3.1.0",
+ "contentHash": "X3s5QE/KMj3WAPFqFve7St+Ds10BB50u8kW8PmKIn7FVkn7yEXe9Yxr2htt1WV85DRqfFR0MN/BUNHkGHtL4OQ==",
+ "dependencies": {
+ "System.ValueTuple": "4.5.0"
+ }
+ },
+ "UrlMatcher": {
+ "type": "Transitive",
+ "resolved": "3.0.0",
+ "contentHash": "IavFDKxhcg5ehi3baGEejxnZoj4bqfkDnoMxTV+6Y4WHVxW8wgxTzdSl9q5WSMi366LG0amDfOxaUJxuHawNLw=="
+ },
+ "Watson.Core": {
+ "type": "Transitive",
+ "resolved": "6.2.2",
+ "contentHash": "lqdT+foblghBKLXuvXBFLJmO9J0TkPCBiQFH4xg4txFM7osm1jvF2IzYjor6w1NL+kNDhvlCRkBNaPSSxfJO/A==",
+ "dependencies": {
+ "IpMatcher": "1.0.4.4",
+ "RegexMatcher": "1.0.8",
+ "System.Text.Json": "8.0.4",
+ "Timestamps": "1.0.9",
+ "UrlMatcher": "3.0.0"
+ }
}
}
}