diff --git a/Dalamud/Game/Chat/SeStringHandling/Payload.cs b/Dalamud/Game/Chat/SeStringHandling/Payload.cs index b7a900b2c..9d3d441f3 100644 --- a/Dalamud/Game/Chat/SeStringHandling/Payload.cs +++ b/Dalamud/Game/Chat/SeStringHandling/Payload.cs @@ -155,6 +155,10 @@ namespace Dalamud.Game.Chat.SeStringHandling payload = new QuestPayload(); break; + case EmbeddedInfoType.DalamudLink: + payload = new DalamudLinkPayload(); + break; + case EmbeddedInfoType.LinkTerminator: // this has no custom handling and so needs to fallthrough to ensure it is captured default: @@ -224,7 +228,7 @@ namespace Dalamud.Game.Chat.SeStringHandling UIGlow = 0x49 } - protected enum EmbeddedInfoType + public enum EmbeddedInfoType { PlayerName = 0x01, ItemLink = 0x03, @@ -232,6 +236,8 @@ namespace Dalamud.Game.Chat.SeStringHandling QuestLink = 0x05, Status = 0x09, + DalamudLink = 0x0F, // Dalamud Custom + LinkTerminator = 0xCF // not clear but seems to always follow a link } diff --git a/Dalamud/Game/Chat/SeStringHandling/PayloadType.cs b/Dalamud/Game/Chat/SeStringHandling/PayloadType.cs index 060d14093..6d8a3de1b 100644 --- a/Dalamud/Game/Chat/SeStringHandling/PayloadType.cs +++ b/Dalamud/Game/Chat/SeStringHandling/PayloadType.cs @@ -51,6 +51,10 @@ namespace Dalamud.Game.Chat.SeStringHandling /// Quest, /// + /// A SeString payload representing a custom clickable link for dalamud plugins + /// + DalamudLink, + /// /// An SeString payload representing any data we don't handle. /// Unknown diff --git a/Dalamud/Game/Chat/SeStringHandling/Payloads/DalamudLinkPayload.cs b/Dalamud/Game/Chat/SeStringHandling/Payloads/DalamudLinkPayload.cs new file mode 100644 index 000000000..88bd15695 --- /dev/null +++ b/Dalamud/Game/Chat/SeStringHandling/Payloads/DalamudLinkPayload.cs @@ -0,0 +1,48 @@ +using System; +using System.Collections.Generic; +using System.IO; +using System.Linq; +using System.Text; +using System.Threading.Tasks; +using JetBrains.Annotations; + +namespace Dalamud.Game.Chat.SeStringHandling.Payloads { + + /// + /// + /// + public class DalamudLinkPayload : Payload { + public override PayloadType Type => PayloadType.DalamudLink; + + public uint CommandId { get; internal set; } = 0; + + [NotNull] + public string Plugin { get; internal set; } = string.Empty; + + protected override byte[] EncodeImpl() { + var pluginBytes = Encoding.UTF8.GetBytes(Plugin); + var commandBytes = MakeInteger(CommandId); + var chunkLen = 3 + pluginBytes.Length + commandBytes.Length; + + if (chunkLen > 255) { + throw new Exception("Chunk is too long. Plugin name exceeds limits for DalamudLinkPayload"); + } + + var bytes = new List {START_BYTE, (byte) SeStringChunkType.Interactable, (byte) chunkLen, (byte) EmbeddedInfoType.DalamudLink}; + bytes.Add((byte) pluginBytes.Length); + bytes.AddRange(pluginBytes); + bytes.AddRange(commandBytes); + bytes.Add(END_BYTE); + return bytes.ToArray(); + } + + protected override void DecodeImpl(BinaryReader reader, long endOfStream) { + Plugin = Encoding.UTF8.GetString(reader.ReadBytes(reader.ReadByte())); + CommandId = GetInteger(reader); + } + + public override string ToString() { + return $"{Type} - Plugin: {Plugin}, Command: {CommandId}"; + } + } +} diff --git a/Dalamud/Game/Chat/SeStringHandling/Payloads/RawPayload.cs b/Dalamud/Game/Chat/SeStringHandling/Payloads/RawPayload.cs index a2438d32a..8184dd477 100644 --- a/Dalamud/Game/Chat/SeStringHandling/Payloads/RawPayload.cs +++ b/Dalamud/Game/Chat/SeStringHandling/Payloads/RawPayload.cs @@ -64,6 +64,14 @@ namespace Dalamud.Game.Chat.SeStringHandling.Payloads return $"{Type} - Data: {BitConverter.ToString(Data).Replace("-", " ")}"; } + public override bool Equals(object obj) { + if (obj is RawPayload rp) { + if (rp.Data.Length != this.Data.Length) return false; + return !Data.Where((t, i) => rp.Data[i] != t).Any(); + } + return false; + } + protected override byte[] EncodeImpl() { var chunkLen = this.data.Length + 1; diff --git a/Dalamud/Game/ChatHandlers.cs b/Dalamud/Game/ChatHandlers.cs index 78df4ae1f..2e4c00060 100644 --- a/Dalamud/Game/ChatHandlers.cs +++ b/Dalamud/Game/ChatHandlers.cs @@ -24,6 +24,8 @@ namespace Dalamud.Game { private readonly Dalamud dalamud; + private DalamudLinkPayload openInstallerWindowLink; + private readonly Dictionary HandledChatTypeColors = new Dictionary { {XivChatType.CrossParty, Color.DodgerBlue}, {XivChatType.Party, Color.DodgerBlue}, @@ -87,6 +89,10 @@ namespace Dalamud.Game { dalamud.Framework.Gui.Chat.OnCheckMessageHandled += OnCheckMessageHandled; dalamud.Framework.Gui.Chat.OnChatMessage += OnChatMessage; + + this.openInstallerWindowLink = this.dalamud.Framework.Gui.Chat.AddChatLinkHandler("Dalamud", 1001, (i, m) => { + this.dalamud.OpenPluginInstaller(); + }); } private void OnCheckMessageHandled(XivChatType type, uint senderid, ref SeString sender, ref SeString message, ref bool isHandled) { @@ -233,7 +239,16 @@ namespace Dalamud.Game { } } else { this.dalamud.Framework.Gui.Chat.PrintChat(new XivChatEntry { - MessageBytes = Encoding.UTF8.GetBytes(Loc.Localize("DalamudPluginUpdateRequired", "One or more of your plugins needs to be updated. Please use the /xlplugins command in-game to update them!")), + MessageBytes = new SeString(new List() { + new TextPayload(Loc.Localize("DalamudPluginUpdateRequired", "One or more of your plugins needs to be updated. Please use the /xlplugins command in-game to update them!")), + new TextPayload(" ["), + new UIForegroundPayload(this.dalamud.Data, 500), + this.openInstallerWindowLink, + new TextPayload(Loc.Localize("DalamudInstallerHelp", "Open the plugin installer")), + RawPayload.LinkTerminator, + new UIForegroundPayload(this.dalamud.Data, 0), + new TextPayload("]"), + }).Encode(), Type = XivChatType.Urgent }); } diff --git a/Dalamud/Game/Internal/Gui/ChatGui.cs b/Dalamud/Game/Internal/Gui/ChatGui.cs index b55c4a040..87e11d8e5 100644 --- a/Dalamud/Game/Internal/Gui/ChatGui.cs +++ b/Dalamud/Game/Internal/Gui/ChatGui.cs @@ -1,9 +1,11 @@ using System; using System.Collections.Generic; +using System.Linq; using System.Runtime.InteropServices; using System.Text; using Dalamud.Game.Chat; using Dalamud.Game.Chat.SeStringHandling; +using Dalamud.Game.Chat.SeStringHandling.Payloads; using Dalamud.Game.Internal.Libc; using Dalamud.Hooking; using Serilog; @@ -42,6 +44,8 @@ namespace Dalamud.Game.Internal.Gui { private readonly Hook populateItemLinkHook; + private readonly Hook interactableLinkClickedHook; + #endregion #region Delegates @@ -55,6 +59,10 @@ namespace Dalamud.Game.Internal.Gui { [UnmanagedFunctionPointer(CallingConvention.ThisCall)] private delegate void PopulateItemLinkDelegate(IntPtr linkObjectPtr, IntPtr itemInfoPtr); + + [UnmanagedFunctionPointer(CallingConvention.ThisCall)] + private delegate void InteractableLinkClickedDelegate(IntPtr managerPtr, IntPtr messagePtr); + #endregion public int LastLinkedItemId { get; private set; } @@ -81,16 +89,22 @@ namespace Dalamud.Game.Internal.Gui { new Hook(Address.PopulateItemLinkObject, new PopulateItemLinkDelegate(HandlePopulateItemLinkDetour), this); + this.interactableLinkClickedHook = + new Hook(Address.InteractableLinkClicked, + new InteractableLinkClickedDelegate(InteractableLinkClickedDetour)); + } public void Enable() { this.printMessageHook.Enable(); this.populateItemLinkHook.Enable(); + this.interactableLinkClickedHook.Enable(); } public void Dispose() { this.printMessageHook.Dispose(); this.populateItemLinkHook.Dispose(); + this.interactableLinkClickedHook.Dispose(); } private void HandlePopulateItemLinkDetour(IntPtr linkObjectPtr, IntPtr itemInfoPtr) { @@ -168,6 +182,76 @@ namespace Dalamud.Game.Internal.Gui { return retVal; } + private readonly Dictionary<(string pluginName, uint commandId), Action> dalamudLinkHandlers = new Dictionary<(string, uint), Action>(); + + /// + /// Create a link handler + /// + /// + /// + /// + /// + internal DalamudLinkPayload AddChatLinkHandler(string pluginName, uint commandId, Action commandAction) { + var payload = new DalamudLinkPayload() {Plugin = pluginName, CommandId = commandId}; + this.dalamudLinkHandlers.Add((pluginName, commandId), commandAction); + return payload; + } + + /// + /// Remove a registered link handler + /// + /// + /// + internal void RemoveChatLinkHandler(string pluginName, uint commandId) { + if (this.dalamudLinkHandlers.ContainsKey((pluginName, commandId))) { + this.dalamudLinkHandlers.Remove((pluginName, commandId)); + } + } + + /// + /// Remove all handlers owned by a plugin. + /// + /// + internal void RemoveChatLinkHandler(string pluginName) { + foreach (var handler in this.dalamudLinkHandlers.Keys.ToList().Where(k => k.pluginName == pluginName)) { + this.dalamudLinkHandlers.Remove(handler); + } + } + + private void InteractableLinkClickedDetour(IntPtr managerPtr, IntPtr messagePtr) { + try { + var interactableType = (Payload.EmbeddedInfoType)(Marshal.ReadByte(messagePtr, 0x1B) + 1); + + if (interactableType != Payload.EmbeddedInfoType.DalamudLink) { + this.interactableLinkClickedHook.Original(managerPtr, messagePtr); + return; + } + + Log.Verbose($"InteractableLinkClicked: {Payload.EmbeddedInfoType.DalamudLink}"); + + var payloadPtr = Marshal.ReadIntPtr(messagePtr, 0x10); + var messageSize = 0; + while (Marshal.ReadByte(payloadPtr, messageSize) != 0) messageSize++; + var payloadBytes = new byte[messageSize]; + Marshal.Copy(payloadPtr, payloadBytes, 0, messageSize); + var seStr = this.dalamud.SeStringManager.Parse(payloadBytes); + var terminatorIndex = seStr.Payloads.IndexOf(RawPayload.LinkTerminator); + var payloads = terminatorIndex >= 0 ? seStr.Payloads.Take(terminatorIndex + 1).ToList() : seStr.Payloads; + if (payloads.Count == 0) return; + var linkPayload = payloads[0]; + if (linkPayload is DalamudLinkPayload link) { + if (this.dalamudLinkHandlers.ContainsKey((link.Plugin, link.CommandId))) { + Log.Verbose($"Sending DalamudLink to {link.Plugin}: {link.CommandId}"); + this.dalamudLinkHandlers[(link.Plugin, link.CommandId)].Invoke(link.CommandId, new SeString(payloads)); + } else { + Log.Debug($"No DalamudLink registered for {link.Plugin} with ID of {link.CommandId}"); + } + } + } catch (Exception ex) { + Log.Error(ex, "Exception on InteractableLinkClicked hook"); + } + } + // Copyright (c) 2008-2013 Hafthor Stefansson // Distributed under the MIT/X11 software license // Ref: http://www.opensource.org/licenses/mit-license.php. diff --git a/Dalamud/Game/Internal/Gui/ChatGuiAddressResolver.cs b/Dalamud/Game/Internal/Gui/ChatGuiAddressResolver.cs index bc4d7b0f9..0620f93b5 100644 --- a/Dalamud/Game/Internal/Gui/ChatGuiAddressResolver.cs +++ b/Dalamud/Game/Internal/Gui/ChatGuiAddressResolver.cs @@ -6,6 +6,7 @@ namespace Dalamud.Game.Internal.Gui { public IntPtr PrintMessage { get; private set; } public IntPtr PopulateItemLinkObject { get; private set; } + public IntPtr InteractableLinkClicked { get; private set; } public ChatGuiAddressResolver(IntPtr baseAddres) { BaseAddress = baseAddres; @@ -89,6 +90,8 @@ namespace Dalamud.Game.Internal.Gui { //PopulateItemLinkObject = sig.ScanText( "48 89 5C 24 08 57 48 83 EC 20 80 7A 06 00 48 8B DA 48 8B F9 74 14 48 8B CA E8 32 03 00 00 48 8B C8 E8 ?? ?? B0 FF 8B C8 EB 1D 0F B6 42 14 8B 4A"); 5.0 PopulateItemLinkObject = sig.ScanText("48 89 5C 24 08 57 48 83 EC 20 80 7A 06 00 48 8B DA 48 8B F9 74 14 48 8B CA E8 32 03 00 00 48 8B C8 E8 ?? ?? ?? FF 8B C8 EB 1D 0F B6 42 14 8B 4A"); + + InteractableLinkClicked = sig.ScanText("E8 ?? ?? ?? ?? E9 ?? ?? ?? ?? 80 BB ?? ?? ?? ?? ?? 0F 85 ?? ?? ?? ?? 80 BB") + 9; } } } diff --git a/Dalamud/Plugin/DalamudPluginInterface.cs b/Dalamud/Plugin/DalamudPluginInterface.cs index 1892a16f0..4a2ae9f41 100644 --- a/Dalamud/Plugin/DalamudPluginInterface.cs +++ b/Dalamud/Plugin/DalamudPluginInterface.cs @@ -10,6 +10,7 @@ using Dalamud.Configuration; using Dalamud.Data; using Dalamud.Game; using Dalamud.Game.Chat.SeStringHandling; +using Dalamud.Game.Chat.SeStringHandling.Payloads; using Dalamud.Game.ClientState; using Dalamud.Game.Command; using Dalamud.Game.Internal; @@ -90,6 +91,7 @@ namespace Dalamud.Plugin /// public void Dispose() { this.UiBuilder.Dispose(); + this.Framework.Gui.Chat.RemoveChatLinkHandler(this.pluginName); } /// @@ -129,6 +131,34 @@ namespace Dalamud.Plugin return this.configs.Load(this.pluginName); } + #region Chat Links + + /// + /// Register a chat link handler. + /// + /// + /// + /// Returns an SeString payload for the link. + public DalamudLinkPayload AddChatLinkHandler(uint commandId, Action commandAction) { + return this.Framework.Gui.Chat.AddChatLinkHandler(this.pluginName, commandId, commandAction); + } + + /// + /// Remove a chat link handler. + /// + /// + public void RemoveChatLinkHandler(uint commandId) { + this.Framework.Gui.Chat.RemoveChatLinkHandler(this.pluginName, commandId); + } + + /// + /// Removes all chat link handlers registered by the plugin. + /// + public void RemoveChatLinkHandler() { + this.Framework.Gui.Chat.RemoveChatLinkHandler(this.pluginName); + } + #endregion + #region IPC internal Action anyPluginIpcAction;