Add pre-testing version of the webinterface

This commit is contained in:
Infi
2024-08-24 03:05:33 +02:00
parent 117d9fc45c
commit 5e93732183
27 changed files with 1498 additions and 129 deletions
+16
View File
@@ -46,14 +46,21 @@
<HintPath>$(DalamudLibPath)\Lumina.Excel.dll</HintPath> <HintPath>$(DalamudLibPath)\Lumina.Excel.dll</HintPath>
<Private>false</Private> <Private>false</Private>
</Reference> </Reference>
<Reference Include="Newtonsoft.Json">
<HintPath>$(DalamudLibPath)\Newtonsoft.Json.dll</HintPath>
<Private>false</Private>
</Reference>
</ItemGroup> </ItemGroup>
<ItemGroup> <ItemGroup>
<PackageReference Include="DalamudPackager" Version="2.1.13" /> <PackageReference Include="DalamudPackager" Version="2.1.13" />
<PackageReference Include="EmbedIO" Version="3.5.2" />
<PackageReference Include="HtmlSanitizer" Version="8.1.870" />
<PackageReference Include="MessagePack" Version="2.5.140" /> <PackageReference Include="MessagePack" Version="2.5.140" />
<PackageReference Include="Microsoft.Data.Sqlite" Version="8.0.4" /> <PackageReference Include="Microsoft.Data.Sqlite" Version="8.0.4" />
<PackageReference Include="Pidgin" Version="3.2.3" /> <PackageReference Include="Pidgin" Version="3.2.3" />
<PackageReference Include="SixLabors.ImageSharp" Version="3.1.5" /> <PackageReference Include="SixLabors.ImageSharp" Version="3.1.5" />
<PackageReference Include="Watson.Lite" Version="6.2.2" />
</ItemGroup> </ItemGroup>
<ItemGroup> <ItemGroup>
@@ -74,4 +81,13 @@
<ItemGroup> <ItemGroup>
<Folder Include="images\" /> <Folder Include="images\" />
</ItemGroup> </ItemGroup>
<ItemGroup>
<Content Include="Http\static\*.*">
<CopyToOutputDirectory>Always</CopyToOutputDirectory>
</Content>
<Content Include="Http\templates\*.*">
<CopyToOutputDirectory>Always</CopyToOutputDirectory>
</Content>
</ItemGroup>
</Project> </Project>
+48 -26
View File
@@ -120,6 +120,10 @@ internal class Configuration : IPluginConfiguration
public ConfigKeyBind? ChatTabForward; public ConfigKeyBind? ChatTabForward;
public ConfigKeyBind? ChatTabBackward; public ConfigKeyBind? ChatTabBackward;
// Webinterface
public bool WebinterfaceEnabled;
public string WebinterfacePassword = WebinterfaceUtil.GenerateSimpleAuthCode();
internal void UpdateFrom(Configuration other, bool backToOriginal) internal void UpdateFrom(Configuration other, bool backToOriginal)
{ {
if (backToOriginal) if (backToOriginal)
@@ -183,6 +187,8 @@ internal class Configuration : IPluginConfiguration
ChosenStyle = other.ChosenStyle; ChosenStyle = other.ChosenStyle;
ChatTabForward = other.ChatTabForward; ChatTabForward = other.ChatTabForward;
ChatTabBackward = other.ChatTabBackward; ChatTabBackward = other.ChatTabBackward;
WebinterfaceEnabled = other.WebinterfaceEnabled;
WebinterfacePassword = other.WebinterfacePassword;
} }
} }
@@ -286,26 +292,26 @@ internal class Tab
/// </summary> /// </summary>
internal class MessageList internal class MessageList
{ {
private ReaderWriterLock rwl = new(); private readonly SemaphoreSlim LockSlim = new(1, 1);
private readonly List<Message> messages; private readonly List<Message> Messages;
private readonly HashSet<Guid> trackedMessageIds; private readonly HashSet<Guid> TrackedMessageIds;
public MessageList() public MessageList()
{ {
messages = new(); Messages = [];
trackedMessageIds = new(); TrackedMessageIds = [];
} }
public MessageList(int initialCapacity) public MessageList(int initialCapacity)
{ {
messages = new(initialCapacity); Messages = new List<Message>(initialCapacity);
trackedMessageIds = new(initialCapacity); TrackedMessageIds = new HashSet<Guid>(initialCapacity);
} }
public void AddPrune(Message message, int max) public void AddPrune(Message message, int max)
{ {
rwl.AcquireWriterLock(-1); LockSlim.Wait(-1);
try try
{ {
AddLocked(message); AddLocked(message);
@@ -313,13 +319,13 @@ internal class Tab
} }
finally finally
{ {
rwl.ReleaseWriterLock(); LockSlim.Release();
} }
} }
public void AddSortPrune(IEnumerable<Message> messages, int max) public void AddSortPrune(IEnumerable<Message> messages, int max)
{ {
rwl.AcquireWriterLock(-1); LockSlim.Wait(-1);
try try
{ {
foreach (var message in messages) foreach (var message in messages)
@@ -330,44 +336,60 @@ internal class Tab
} }
finally finally
{ {
rwl.ReleaseWriterLock(); LockSlim.Release();
} }
} }
private void AddLocked(Message message) private void AddLocked(Message message)
{ {
if (trackedMessageIds.Contains(message.Id)) if (TrackedMessageIds.Contains(message.Id))
return; return;
messages.Add(message); Messages.Add(message);
trackedMessageIds.Add(message.Id); TrackedMessageIds.Add(message.Id);
} }
public void Clear() public void Clear()
{ {
rwl.AcquireWriterLock(-1); LockSlim.Wait(-1);
try try
{ {
messages.Clear(); Messages.Clear();
trackedMessageIds.Clear(); TrackedMessageIds.Clear();
} }
finally finally
{ {
rwl.ReleaseWriterLock(); LockSlim.Release();
} }
} }
private void SortLocked() 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) private void PruneMaxLocked(int max)
{ {
while (messages.Count > max) while (Messages.Count > max)
{ {
trackedMessageIds.Remove(messages[0].Id); TrackedMessageIds.Remove(Messages[0].Id);
messages.RemoveAt(0); Messages.RemoveAt(0);
}
}
/// <summary>
/// Returns an array copy of the message list for usage outside of main thread
/// </summary>
public async Task<Message[]> GetCopy(int millisecondsTimeout = -1)
{
await LockSlim.WaitAsync(millisecondsTimeout);
try
{
return Messages.ToArray();
}
finally
{
LockSlim.Release();
} }
} }
@@ -377,11 +399,11 @@ internal class Tab
/// </summary> /// </summary>
public RLockedMessageList GetReadOnly(int millisecondsTimeout = -1) public RLockedMessageList GetReadOnly(int millisecondsTimeout = -1)
{ {
rwl.AcquireReaderLock(millisecondsTimeout); LockSlim.Wait(millisecondsTimeout);
return new RLockedMessageList(rwl, messages); return new RLockedMessageList(LockSlim, Messages);
} }
internal class RLockedMessageList(ReaderWriterLock rwl, List<Message> messages) : IReadOnlyList<Message>, IDisposable internal class RLockedMessageList(SemaphoreSlim lockSlim, List<Message> messages) : IReadOnlyList<Message>, IDisposable
{ {
public IEnumerator<Message> GetEnumerator() public IEnumerator<Message> GetEnumerator()
{ {
@@ -399,7 +421,7 @@ internal class Tab
public void Dispose() public void Dispose()
{ {
rwl.ReleaseReaderLock(); lockSlim.Release();
} }
} }
} }
+7 -18
View File
@@ -148,6 +148,8 @@ public static class EmoteCache
public bool Failed; public bool Failed;
public bool IsLoaded; public bool IsLoaded;
public byte[] RawData = [];
protected IDalamudTextureWrap? Texture; protected IDalamudTextureWrap? Texture;
public virtual void Draw(Vector2 size) public virtual void Draw(Vector2 size)
@@ -155,39 +157,26 @@ public static class EmoteCache
ImGui.Image(Texture!.ImGuiHandle, size); ImGui.Image(Texture!.ImGuiHandle, size);
} }
internal static async Task<byte[]> LoadAsync(Emote emote) internal async Task<byte[]> 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"); var dir = Path.Join(Plugin.Interface.ConfigDirectory.FullName, "EmoteCacheV1");
Directory.CreateDirectory(dir); Directory.CreateDirectory(dir);
byte[] image;
var filePath = Path.Join(dir, $"{emote.Id}.{emote.ImageType}"); var filePath = Path.Join(dir, $"{emote.Id}.{emote.ImageType}");
if (File.Exists(filePath)) if (File.Exists(filePath))
{ {
image = await File.ReadAllBytesAsync(filePath); RawData = await File.ReadAllBytesAsync(filePath);
} }
else else
{ {
var content = await new HttpClient().GetAsync(EmotePath.Format(emote.Id)); 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); 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(); public abstract void InnerDispose();
+1 -1
View File
@@ -16,7 +16,7 @@ public class FontManager
internal IFontHandle FontAwesome { get; private set; } internal IFontHandle FontAwesome { get; private set; }
private readonly byte[] GameSymFont; internal readonly byte[] GameSymFont;
private ushort[] Ranges; private ushort[] Ranges;
private ushort[] JpRange; private ushort[] JpRange;
@@ -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;
}
+96
View File
@@ -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<string>();
foreach (var chunk in Plugin.ChatLogWindow.ReadChannelName(Plugin.ChatLogWindow.CurrentTab))
messages.Add(ProcessChunk(chunk, noColor: true));
return string.Join("", messages);
}
internal async Task<List<MessageResponse>> 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 _)
? $"<span class=\"gfd-icon gfd-icon-hq-{(uint)icon.Icon}\" style=\"zoom:calc(16 * 4 / 3 / 40 * 1.4)\"></span>"
: "";
}
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 $"<span style\"height: 1em\"><img src=\"/emote/{emotePayload.Code}\"></span>";
}
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
: $"<span style=\"color:rgba({color.r}, {color.g}, {color.b}, {color.a})\">{userContent}</span>";
}
return string.Empty;
}
}
+153
View File
@@ -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<TexFile>("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
}
+134
View File
@@ -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();
}
}
+37
View File
@@ -0,0 +1,37 @@
namespace ChatTwo.Http;
public static class WebserverUtil
{
public static async Task<T> FrameworkWrapper<T>(Func<Task<T>> 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<IDisposable> UseWaitAsync(this SemaphoreSlim semaphore, CancellationToken ct = default) {
await semaphore.WaitAsync(ct).ConfigureAwait(false);
return new WebserverUtil.DisposableWrapper(() => {
semaphore.Release();
});
}
}
+48
View File
@@ -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);
}
});
}
}
Binary file not shown.
+243
View File
@@ -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,<svg xmlns="http://www.w3.org/2000/svg" width="24" height="24" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><path d="M21 11.5a8.38 8.38 0 0 1-.9 3.8 8.5 8.5 0 0 1-7.6 4.7 8.38 8.38 0 0 1-3.8-.9L3 21l1.9-5.7a8.38 8.38 0 0 1-.9-3.8 8.5 8.5 0 0 1 4.7-7.6 8.38 8.38 0 0 1 3.8-.9h.5a8.48 8.48 0 0 1 8 8v.5z"/></svg>');
}
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,<svg xmlns="http://www.w3.org/2000/svg" width="24" height="24" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><line x1="22" y1="2" x2="11" y2="13"/><polygon points="22 2 15 22 11 13 2 9 22 2"/></svg>');
}
}
.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%);
}
}
}
+243
View File
@@ -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");
+19
View File
@@ -0,0 +1,19 @@
<!DOCTYPE html>
<html lang="en">
<head>
<title>Authentication</title>
<link rel="stylesheet" href="/static/start.css">
</head>
<body>
<div style="text-align: center;">
<h3 style="color: #fff">Authcode</h3>
<form action="/auth" method="POST">
<input style="color: #fff; background: #121212" type="password" id="authcode" name="authcode">
<input style="color: #fff; background: #121212" type="submit" value="Submit">
</form>
<img src="http://192.168.2.106:9000/emote/Sure">
</div>
</body>
</html>
Binary file not shown.
+7
View File
@@ -255,13 +255,20 @@ internal class MessageManager : IAsyncDisposable
if (Plugin.Config.DatabaseBattleMessages || !message.Code.IsBattle()) if (Plugin.Config.DatabaseBattleMessages || !message.Code.IsBattle())
Store.UpsertMessage(message); Store.UpsertMessage(message);
var currentTabId = Plugin.ChatLogWindow.CurrentTab?.Identifier ?? Guid.Empty;
var currentMatches = Plugin.ChatLogWindow.CurrentTab?.Matches(message) ?? false; var currentMatches = Plugin.ChatLogWindow.CurrentTab?.Matches(message) ?? false;
foreach (var tab in Plugin.Config.Tabs) foreach (var tab in Plugin.Config.Tabs)
{ {
var unread = !(tab.UnreadMode == UnreadMode.Unseen && Plugin.ChatLogWindow.CurrentTab != tab && currentMatches); var unread = !(tab.UnreadMode == UnreadMode.Unseen && Plugin.ChatLogWindow.CurrentTab != tab && currentMatches);
if (tab.Matches(message)) if (tab.Matches(message))
{
tab.AddMessage(message, unread); tab.AddMessage(message, unread);
if (tab.Identifier == currentTabId)
Plugin.ServerCore.SendNewMessage(message);
}
} }
} }
+9 -1
View File
@@ -2,6 +2,7 @@ using System.Diagnostics;
using System.Diagnostics.CodeAnalysis; using System.Diagnostics.CodeAnalysis;
using System.Globalization; using System.Globalization;
using ChatTwo.GameFunctions; using ChatTwo.GameFunctions;
using ChatTwo.Http;
using ChatTwo.Ipc; using ChatTwo.Ipc;
using ChatTwo.Resources; using ChatTwo.Resources;
using ChatTwo.Ui; using ChatTwo.Ui;
@@ -59,6 +60,8 @@ public sealed class Plugin : IDalamudPlugin
internal ExtraChat ExtraChat { get; } internal ExtraChat ExtraChat { get; }
internal FontManager FontManager { get; } internal FontManager FontManager { get; }
internal ServerCore ServerCore { get; }
internal int DeferredSaveFrames = -1; internal int DeferredSaveFrames = -1;
internal DateTime GameStarted { get; } internal DateTime GameStarted { get; }
@@ -126,9 +129,13 @@ public sealed class Plugin : IDalamudPlugin
// profiling difficult. // profiling difficult.
AutoTranslate.PreloadCache(); AutoTranslate.PreloadCache();
#endif #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(); Dispose();
// Re-throw the exception to fail the plugin load. // Re-throw the exception to fail the plugin load.
throw; throw;
@@ -160,6 +167,7 @@ public sealed class Plugin : IDalamudPlugin
Commands?.Dispose(); Commands?.Dispose();
EmoteCache.Dispose(); EmoteCache.Dispose();
ServerCore.Dispose();
} }
private void Draw() private void Draw()
+63
View File
@@ -3407,6 +3407,33 @@ namespace ChatTwo.Resources {
} }
} }
/// <summary>
/// Looks up a localized string similar to Webinterface.
/// </summary>
internal static string Options_Webinterface_Tab {
get {
return ResourceManager.GetString("Options_Webinterface_Tab", resourceCulture);
}
}
/// <summary>
/// Looks up a localized string similar to Enables the webinterface that can be accessed with a browser..
/// </summary>
internal static string Options_WebinterfaceEnable_Description {
get {
return ResourceManager.GetString("Options_WebinterfaceEnable_Description", resourceCulture);
}
}
/// <summary>
/// Looks up a localized string similar to Enable.
/// </summary>
internal static string Options_WebinterfaceEnable_Name {
get {
return ResourceManager.GetString("Options_WebinterfaceEnable_Name", resourceCulture);
}
}
/// <summary> /// <summary>
/// Looks up a localized string similar to Window opacity. /// Looks up a localized string similar to Window opacity.
/// </summary> /// </summary>
@@ -3541,5 +3568,41 @@ namespace ChatTwo.Resources {
return ResourceManager.GetString("UnreadMode_Unseen_Tooltip", resourceCulture); return ResourceManager.GetString("UnreadMode_Unseen_Tooltip", resourceCulture);
} }
} }
/// <summary>
/// Looks up a localized string similar to Your current password to access the webinterface:.
/// </summary>
internal static string Webinterface_CurrentPassword {
get {
return ResourceManager.GetString("Webinterface_CurrentPassword", resourceCulture);
}
}
/// <summary>
/// Looks up a localized string similar to Reset your password and invalidate all session tokens..
/// </summary>
internal static string Webinterface_PasswordReset_Tooltip {
get {
return ResourceManager.GetString("Webinterface_PasswordReset_Tooltip", resourceCulture);
}
}
/// <summary>
/// Looks up a localized string similar to Webinterface Status:.
/// </summary>
internal static string Webinterface_Status {
get {
return ResourceManager.GetString("Webinterface_Status", resourceCulture);
}
}
/// <summary>
/// Looks up a localized string similar to Active:.
/// </summary>
internal static string Webinterface_Status_Active {
get {
return ResourceManager.GetString("Webinterface_Status_Active", resourceCulture);
}
}
} }
} }
+21
View File
@@ -463,6 +463,9 @@
<data name="Options_Miscellaneous_Tab"> <data name="Options_Miscellaneous_Tab">
<value>Miscellaneous</value> <value>Miscellaneous</value>
</data> </data>
<data name="Options_Webinterface_Tab">
<value>Webinterface</value>
</data>
<data name="LanguageOverride_None"> <data name="LanguageOverride_None">
<value>Use Dalamud's default language</value> <value>Use Dalamud's default language</value>
</data> </data>
@@ -547,6 +550,12 @@
<data name="Options_SortAutoTranslate_Description"> <data name="Options_SortAutoTranslate_Description">
<value>If this is enabled, the Auto Translate list will be sorted alphabetically.</value> <value>If this is enabled, the Auto Translate list will be sorted alphabetically.</value>
</data> </data>
<data name="Options_WebinterfaceEnable_Name">
<value>Enable</value>
</data>
<data name="Options_WebinterfaceEnable_Description">
<value>Enables the webinterface that can be accessed with a browser.</value>
</data>
<data name="Options_OverrideStyle_Name"> <data name="Options_OverrideStyle_Name">
<value>Override Style</value> <value>Override Style</value>
</data> </data>
@@ -559,6 +568,18 @@
<data name="Options_ChatTabBackwardKeybind_Name"> <data name="Options_ChatTabBackwardKeybind_Name">
<value>Cycle chat tab backwards keybind</value> <value>Cycle chat tab backwards keybind</value>
</data> </data>
<data name="Webinterface_CurrentPassword">
<value>Your current password to access the webinterface:</value>
</data>
<data name="Webinterface_PasswordReset_Tooltip">
<value>Reset your password and invalidate all session tokens.</value>
</data>
<data name="Webinterface_Status">
<value>Webinterface Status:</value>
</data>
<data name="Webinterface_Status_Active">
<value>Active:</value>
</data>
<data name="Keybind_None"> <data name="Keybind_None">
<value>none set</value> <value>none set</value>
</data> </data>
+103 -81
View File
@@ -58,7 +58,7 @@ public sealed class ChatLogWindow : Window
private int InputBacklogIdx = -1; private int InputBacklogIdx = -1;
private int LastTab { get; set; } private int LastTab { get; set; }
private InputChannel? TempChannel; private InputChannel? TempChannel;
private TellTarget? TellTarget; internal TellTarget? TellTarget;
public bool TellSpecial; public bool TellSpecial;
private readonly Stopwatch LastResize = new(); private readonly Stopwatch LastResize = new();
private AutoCompleteInfo? AutoCompleteInfo; private AutoCompleteInfo? AutoCompleteInfo;
@@ -555,84 +555,7 @@ public sealed class ChatLogWindow : Window
using (ImRaii.PushStyle(ImGuiStyleVar.ItemSpacing, Vector2.Zero)) using (ImRaii.PushStyle(ImGuiStyleVar.ItemSpacing, Vector2.Zero))
{ {
if (TellTarget != null) DrawChannelName(activeTab);
{
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);
}
} }
var beforeIcon = ImGui.GetCursorPos(); var beforeIcon = ImGui.GetCursorPos();
@@ -826,6 +749,105 @@ public sealed class ChatLogWindow : Window
GameFunctions.GameFunctions.ClickNoviceNetworkButton(); 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) internal void SetChannel(InputChannel? channel)
{ {
channel ??= InputChannel.Say; channel ??= InputChannel.Say;
@@ -852,7 +874,7 @@ public sealed class ChatLogWindow : Window
GameFunctions.Chat.SetChannel(channel.Value); GameFunctions.Chat.SetChannel(channel.Value);
} }
private void SendChatBox(Tab? activeTab) internal void SendChatBox(Tab? activeTab)
{ {
if (!string.IsNullOrWhiteSpace(Chat)) 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 expected = Plugin.Functions.Chat.AbbreviatePlayerName(playerName);
var hash = HashPlayer(playerName, worldId); var hash = HashPlayer(playerName, worldId);
+2 -1
View File
@@ -37,10 +37,11 @@ public sealed class SettingsWindow : Window
new ChatLog(Plugin, Mutable), new ChatLog(Plugin, Mutable),
new Emote(Plugin, Mutable), new Emote(Plugin, Mutable),
new Preview(Plugin, Mutable), new Preview(Plugin, Mutable),
new Ui.SettingsTabs.Fonts(Mutable), new Fonts(Mutable),
new ChatColours(Plugin, Mutable), new ChatColours(Plugin, Mutable),
new Tabs(Plugin, Mutable), new Tabs(Plugin, Mutable),
new Database(Plugin, Mutable), new Database(Plugin, Mutable),
new Webinterface(Plugin, Mutable),
new Miscellaneous(Mutable), new Miscellaneous(Mutable),
new Changelog(Mutable), new Changelog(Mutable),
new About(), new About(),
+70
View File
@@ -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())}");
}
}
}
}
+1 -1
View File
@@ -4,7 +4,7 @@ using System.Numerics;
namespace ChatTwo.Util; namespace ChatTwo.Util;
internal static class ColourUtil { 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 r = (byte) ((rgba & 0xFF000000) >> 24);
var g = (byte) ((rgba & 0xFF0000) >> 16); var g = (byte) ((rgba & 0xFF0000) >> 16);
var b = (byte) ((rgba & 0xFF00) >> 8); var b = (byte) ((rgba & 0xFF00) >> 8);
+9
View File
@@ -1,5 +1,7 @@
using System.Runtime.CompilerServices; using System.Runtime.CompilerServices;
using System.Runtime.InteropServices; using System.Runtime.InteropServices;
using SixLabors.ImageSharp;
using SixLabors.ImageSharp.PixelFormats;
namespace ChatTwo.Util; namespace ChatTwo.Util;
@@ -145,4 +147,11 @@ internal static class IconUtil {
return new GfdFileView(new ReadOnlySpan<byte>(Unsafe.AsPointer(ref GfdFile[0]), GfdFile.Length)); return new GfdFileView(new ReadOnlySpan<byte>(Unsafe.AsPointer(ref GfdFile[0]), GfdFile.Length));
} }
} }
public static byte[] ImageToRaw(this Image<Rgba32> image)
{
var data = new byte[4 * image.Width * image.Height];
image.CopyPixelDataTo(data);
return data;
}
} }
+8
View File
@@ -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. // Used to avoid pops if condition is false for Push.
private static void Nop() { } private static void Nop() { }
} }
+11
View File
@@ -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..];
}
}
+128
View File
@@ -8,6 +8,26 @@
"resolved": "2.1.13", "resolved": "2.1.13",
"contentHash": "rMN1omGe8536f4xLMvx9NwfvpAc9YFFfeXJ1t4P4PE6Gu8WCIoFliR1sh07hM+bfODmesk/dvMbji7vNI+B/pQ==" "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": { "MessagePack": {
"type": "Direct", "type": "Direct",
"requested": "[2.5.140, )", "requested": "[2.5.140, )",
@@ -41,6 +61,43 @@
"resolved": "3.1.5", "resolved": "3.1.5",
"contentHash": "lNtlq7dSI/QEbYey+A0xn48z5w4XHSffF8222cC4F4YwTXfEImuiBavQcWjr49LThT/pRmtWJRcqA/PlL+eJ6g==" "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": { "MessagePack.Annotations": {
"type": "Transitive", "type": "Transitive",
"resolved": "2.5.140", "resolved": "2.5.140",
@@ -59,6 +116,11 @@
"resolved": "17.6.3", "resolved": "17.6.3",
"contentHash": "N0ZIanl1QCgvUumEL1laasU0a7sOE5ZwLZVTn0pAePnfhq8P7SvTjF8Axq+CnavuQkmdQpGNXQ1efZtu5kDFbA==" "contentHash": "N0ZIanl1QCgvUumEL1laasU0a7sOE5ZwLZVTn0pAePnfhq8P7SvTjF8Axq+CnavuQkmdQpGNXQ1efZtu5kDFbA=="
}, },
"RegexMatcher": {
"type": "Transitive",
"resolved": "1.0.8",
"contentHash": "h3wa6sKJnyojCH2wJpvHdQRGWn+eKiSYGKPInIzvdl+SaKhUN9Qpr0CK4A3aqrYFBTEJKFN7pzYlNR4S5gZnaw=="
},
"SQLitePCLRaw.bundle_e_sqlite3": { "SQLitePCLRaw.bundle_e_sqlite3": {
"type": "Transitive", "type": "Transitive",
"resolved": "2.1.6", "resolved": "2.1.6",
@@ -89,6 +151,16 @@
"SQLitePCLRaw.core": "2.1.6" "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": { "System.Memory": {
"type": "Transitive", "type": "Transitive",
"resolved": "4.5.3", "resolved": "4.5.3",
@@ -98,6 +170,62 @@
"type": "Transitive", "type": "Transitive",
"resolved": "6.0.0", "resolved": "6.0.0",
"contentHash": "/iUeP3tq1S0XdNNoMz5C9twLSrM/TH+qElHkXWaPvuNOt+99G75NrV0OS2EqHx5wMN7popYjpc8oTjC1y16DLg==" "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"
}
} }
} }
} }