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" + } } } }