From 1312086a8dbb0a4dbbf3827e2ce53a7fc2d2052b Mon Sep 17 00:00:00 2001 From: Stanley Dimant Date: Sun, 19 Jun 2022 01:57:37 +0200 Subject: [PATCH] add the whole API stuff first iteration --- MareSynchronos.sln | 10 + MareSynchronos/Managers/CharacterManager.cs | 242 ++++++++++++-- MareSynchronos/Managers/IpcManager.cs | 16 +- MareSynchronos/MareSynchronos.csproj | 2 + MareSynchronos/Models/CharacterCache.cs | 12 + MareSynchronos/Models/FileReplacement.cs | 30 +- MareSynchronos/Plugin.cs | 9 +- MareSynchronos/PluginUI.cs | 125 +++++++- MareSynchronos/Utils/Crypto.cs | 6 + MareSynchronos/WebAPI/ApiController.cs | 332 +++++++++++++++++--- 10 files changed, 692 insertions(+), 92 deletions(-) diff --git a/MareSynchronos.sln b/MareSynchronos.sln index 40637a5..f8cd173 100644 --- a/MareSynchronos.sln +++ b/MareSynchronos.sln @@ -9,6 +9,8 @@ Project("{9A19103F-16F7-4668-BE54-9A1E7A4F7556}") = "Penumbra.GameData", "..\..\ EndProject Project("{9A19103F-16F7-4668-BE54-9A1E7A4F7556}") = "Penumbra.PlayerWatch", "..\..\Penumbra\Penumbra.PlayerWatch\Penumbra.PlayerWatch.csproj", "{2F26FC2D-03DF-445F-A87B-8708D621E86C}" EndProject +Project("{9A19103F-16F7-4668-BE54-9A1E7A4F7556}") = "MareSynchronos.API", "..\server\MareSynchronosServer\MareSynchronos.API\MareSynchronos.API.csproj", "{4C92F86D-9C84-4F58-9C1A-671AEBACA256}" +EndProject Global GlobalSection(SolutionConfigurationPlatforms) = preSolution Debug|Any CPU = Debug|Any CPU @@ -41,6 +43,14 @@ Global {2F26FC2D-03DF-445F-A87B-8708D621E86C}.Release|Any CPU.Build.0 = Release|Any CPU {2F26FC2D-03DF-445F-A87B-8708D621E86C}.Release|x64.ActiveCfg = Release|Any CPU {2F26FC2D-03DF-445F-A87B-8708D621E86C}.Release|x64.Build.0 = Release|Any CPU + {4C92F86D-9C84-4F58-9C1A-671AEBACA256}.Debug|Any CPU.ActiveCfg = Debug|Any CPU + {4C92F86D-9C84-4F58-9C1A-671AEBACA256}.Debug|Any CPU.Build.0 = Debug|Any CPU + {4C92F86D-9C84-4F58-9C1A-671AEBACA256}.Debug|x64.ActiveCfg = Debug|Any CPU + {4C92F86D-9C84-4F58-9C1A-671AEBACA256}.Debug|x64.Build.0 = Debug|Any CPU + {4C92F86D-9C84-4F58-9C1A-671AEBACA256}.Release|Any CPU.ActiveCfg = Release|Any CPU + {4C92F86D-9C84-4F58-9C1A-671AEBACA256}.Release|Any CPU.Build.0 = Release|Any CPU + {4C92F86D-9C84-4F58-9C1A-671AEBACA256}.Release|x64.ActiveCfg = Release|Any CPU + {4C92F86D-9C84-4F58-9C1A-671AEBACA256}.Release|x64.Build.0 = Release|Any CPU EndGlobalSection GlobalSection(SolutionProperties) = preSolution HideSolutionNode = FALSE diff --git a/MareSynchronos/Managers/CharacterManager.cs b/MareSynchronos/Managers/CharacterManager.cs index ced5000..b864bea 100644 --- a/MareSynchronos/Managers/CharacterManager.cs +++ b/MareSynchronos/Managers/CharacterManager.cs @@ -17,48 +17,58 @@ using Penumbra.PlayerWatch; using System; using System.Collections.Generic; using System.Diagnostics; +using System.IO; using System.Linq; using System.Runtime.InteropServices; using System.Threading; using System.Threading.Tasks; +using Dalamud.Configuration; +using Dalamud.Game.ClientState.Objects.SubKinds; +using MareSynchronos.API; +using MareSynchronos.FileCacheDB; namespace MareSynchronos.Managers { public class CharacterManager : IDisposable { private readonly ClientState clientState; - private readonly Framework framework; + private readonly Framework _framework; private readonly ApiController apiController; private readonly ObjectTable objectTable; private readonly IpcManager ipcManager; private readonly FileReplacementFactory factory; + private readonly Configuration _pluginConfiguration; private readonly IPlayerWatcher watcher; private Task? playerChangedTask = null; - public CharacterManager(ClientState clientState, Framework framework, ApiController apiController, ObjectTable objectTable, IpcManager ipcManager, FileReplacementFactory factory) + public CharacterManager(ClientState clientState, Framework framework, ApiController apiController, ObjectTable objectTable, IpcManager ipcManager, FileReplacementFactory factory, + Configuration pluginConfiguration) { this.clientState = clientState; - this.framework = framework; + this._framework = framework; this.apiController = apiController; this.objectTable = objectTable; this.ipcManager = ipcManager; this.factory = factory; + _pluginConfiguration = pluginConfiguration; watcher = PlayerWatchFactory.Create(framework, clientState, objectTable); - clientState.TerritoryChanged += ClientState_TerritoryChanged; - framework.Update += Framework_Update; - ipcManager.PenumbraRedrawEvent += IpcManager_PenumbraRedrawEvent; } - private void IpcManager_PenumbraRedrawEvent(object? sender, EventArgs e) + private void IpcManager_PenumbraRedrawEvent(object? objectTableIndex, EventArgs e) { - var actorName = ((string)sender!); - PluginLog.Debug("Penumbra redraw " + actorName); - if (actorName == GetPlayerName()) + var objTableObj = objectTable[(int)objectTableIndex!]; + if (objTableObj!.ObjectKind == Dalamud.Game.ClientState.Objects.Enums.ObjectKind.Player) { - PlayerChanged(actorName); + if (objTableObj.Name.ToString() == GetPlayerName()) + { + PluginLog.Debug("Penumbra Redraw Event"); + PlayerChanged(GetPlayerName()); + } } } + private readonly Dictionary<(string, int), CharacterCacheDto> _characterCache = new(); + Dictionary localPlayers = new(); private DateTime lastCheck = DateTime.Now; @@ -71,13 +81,15 @@ namespace MareSynchronos.Managers if (DateTime.Now < lastCheck.AddSeconds(5)) return; List localPlayersList = new(); - List newPlayers = new(); foreach (var obj in objectTable) { if (obj.ObjectKind != Dalamud.Game.ClientState.Objects.Enums.ObjectKind.Player) continue; string playerName = obj.Name.ToString(); - if (playerName == clientState.LocalPlayer.Name.ToString()) continue; - var hashedName = Crypto.GetHash(playerName); + if (playerName == GetPlayerName()) continue; + var pObj = (PlayerCharacter)obj; + var hashedName = Crypto.GetHash256(pObj.Name.ToString() + pObj.HomeWorld.Id.ToString()); + localPlayersList.Add(hashedName); + localPlayers[hashedName] = pObj.Name.ToString(); } foreach (var item in localPlayers.ToList()) @@ -88,8 +100,6 @@ namespace MareSynchronos.Managers } } - if (newPlayers.Any()) - PluginLog.Debug("New players: " + string.Join(",", newPlayers.Select(p => p + ":" + localPlayers[p]))); lastCheck = DateTime.Now; } catch (Exception ex) @@ -128,7 +138,7 @@ namespace MareSynchronos.Managers PluginLog.Debug("Waiting for charater to be drawn"); while ((obj->RenderFlags & 0b100000000000) == 0b100000000000) // 0b100000000000 is "still rendering" or something { - PluginLog.Debug("Waiting for character to finish drawing"); + //PluginLog.Debug("Waiting for character to finish drawing"); Thread.Sleep(10); } PluginLog.Debug("Character finished drawing"); @@ -142,14 +152,34 @@ namespace MareSynchronos.Managers Thread.Sleep(50); } - _ = apiController.SendCharacterData(cache.Result); + _ = apiController.SendCharacterData(cache.Result.ToCharacterCacheDto(), GetLocalPlayers().Select(d => d.Key).ToList()); }); } + private Dictionary GetLocalPlayers() + { + Dictionary allLocalPlayers = new(); + foreach (var obj in objectTable) + { + if (obj.ObjectKind != Dalamud.Game.ClientState.Objects.Enums.ObjectKind.Player) continue; + string playerName = obj.Name.ToString(); + if (playerName == GetPlayerName()) continue; + var playerObject = (PlayerCharacter)obj; + allLocalPlayers.Add(Crypto.GetHash256(playerObject.Name.ToString() + playerObject.HomeWorld.Id.ToString()), playerObject); + } + + return allLocalPlayers; + } + public unsafe CharacterCache BuildCharacterCache() { var cache = new CharacterCache(); + while (clientState.LocalPlayer == null) + { + PluginLog.Debug("Character is null but it shouldn't be, waiting"); + Thread.Sleep(50); + } var model = (CharacterBase*)((Character*)clientState.LocalPlayer!.Address)->GameObject.GetDrawObject(); for (var idx = 0; idx < model->SlotCount; ++idx) { @@ -213,21 +243,193 @@ namespace MareSynchronos.Managers private void ClientState_TerritoryChanged(object? sender, ushort e) { localPlayers.Clear(); + _ = Task.Run(async () => + { + while (clientState.LocalPlayer == null) + { + await Task.Delay(250); + } + + await AssignLocalPlayersData(); + }); + } + + private async Task AssignLocalPlayersData() + { + PluginLog.Debug("Temp assigning local players from cache"); + var currentLocalPlayers = GetLocalPlayers(); + foreach (var player in _characterCache) + { + if (currentLocalPlayers.ContainsKey(player.Key.Item1)) + { + await Task.Run(() => ApiControllerOnCharacterReceived(null, new CharacterReceivedEventArgs + { + CharacterNameHash = player.Key.Item1, + CharacterData = player.Value + })); + } + } + + PluginLog.Debug("Updating local players from service"); + await apiController.GetCharacterData(currentLocalPlayers + .ToDictionary( + k => k.Key, + k => (int)k.Value.ClassJob.Id)); } public void Dispose() { - framework.Update -= Framework_Update; + ipcManager.PenumbraRedrawEvent -= IpcManager_PenumbraRedrawEvent; + _framework.Update -= Framework_Update; clientState.TerritoryChanged -= ClientState_TerritoryChanged; + apiController.Connected -= ApiController_Connected; + apiController.Disconnected -= ApiController_Disconnected; + apiController.CharacterReceived -= ApiControllerOnCharacterReceived; + apiController.RemovedFromWhitelist -= ApiControllerOnRemovedFromWhitelist; + apiController.AddedToWhitelist -= ApiControllerOnAddedToWhitelist; + watcher.Disable(); watcher.PlayerChanged -= Watcher_PlayerChanged; watcher?.Dispose(); } internal void StartWatchingPlayer() { - watcher.AddPlayerToWatch(clientState.LocalPlayer!.Name.ToString()); + watcher.AddPlayerToWatch(GetPlayerName()); watcher.PlayerChanged += Watcher_PlayerChanged; watcher.Enable(); + apiController.Connected += ApiController_Connected; + apiController.Disconnected += ApiController_Disconnected; + apiController.CharacterReceived += ApiControllerOnCharacterReceived; + apiController.RemovedFromWhitelist += ApiControllerOnRemovedFromWhitelist; + apiController.AddedToWhitelist += ApiControllerOnAddedToWhitelist; + + PluginLog.Debug("Watching Player, ApiController is Connected: " + apiController.IsConnected); + if (apiController.IsConnected) + { + ApiController_Connected(null, EventArgs.Empty); + } + } + + private void ApiControllerOnRemovedFromWhitelist(object? sender, EventArgs e) + { + var characterHash = (string?)sender; + if (string.IsNullOrEmpty(characterHash)) return; + var players = GetLocalPlayers(); + foreach (var entry in _characterCache.Where(c => c.Key.Item1 == characterHash)) + { + _characterCache.Remove(entry.Key); + } + + var playerName = players.SingleOrDefault(p => p.Key == characterHash).Value.Name.ToString() ?? null; + if (playerName != null) + { + PluginLog.Debug("You got removed from whitelist, restoring glamourer state for " + playerName); + ipcManager.GlamourerRevertCharacterCustomization(playerName); + } + } + + private void ApiControllerOnAddedToWhitelist(object? sender, EventArgs e) + { + var characterHash = (string?)sender; + if (string.IsNullOrEmpty(characterHash)) return; + var players = GetLocalPlayers(); + if (players.ContainsKey(characterHash)) + { + PluginLog.Debug("You got added to a whitelist, restoring data for " + characterHash); + _ = apiController.GetCharacterData(new Dictionary { { characterHash, (int)players[characterHash].ClassJob.Id } }); + } + } + + private void ApiControllerOnCharacterReceived(object? sender, CharacterReceivedEventArgs e) + { + PlayerCharacter? playerObject = null; + PluginLog.Debug("Received hash for " + e.CharacterNameHash); + foreach (var obj in objectTable) + { + if (obj.ObjectKind != Dalamud.Game.ClientState.Objects.Enums.ObjectKind.Player) continue; + string playerName = obj.Name.ToString(); + if (playerName == GetPlayerName()) continue; + playerObject = (PlayerCharacter)obj; + var hashedName = Crypto.GetHash256(playerObject.Name.ToString() + playerObject.HomeWorld.Id.ToString()); + if (e.CharacterNameHash == hashedName) + { + break; + } + + playerObject = null; + } + + if (playerObject == null) + { + PluginLog.Debug("Found no suitable hash for " + e.CharacterNameHash); + return; + } + else + { + PluginLog.Debug("Found suitable player for hash: " + playerObject.Name.ToString()); + } + + _characterCache[(e.CharacterNameHash, e.CharacterData.JobId)] = e.CharacterData; + + foreach (var file in e.CharacterData.FileReplacements) + { + var hash = file.Hash; + bool hasLocalFile; + using (var db = new FileCacheContext()) + { + hasLocalFile = db.FileCaches.Any(f => f.Hash == hash); + } + + if (hasLocalFile) continue; + PluginLog.Debug("Downloading file for " + hash); + var task = apiController.DownloadData(hash); + while (!task.IsCompleted) + { + Thread.Sleep(TimeSpan.FromSeconds(0.5)); + } + PluginLog.Debug("Download finished: " + hash); + var extractedFile = LZ4.LZ4Codec.Unwrap(task.Result); + var ext = file.GamePaths.First().Split(".", StringSplitOptions.None).Last(); + var filePath = Path.Combine(_pluginConfiguration.CacheFolder, file.Hash + "." + ext); + File.WriteAllBytes(filePath, extractedFile); + PluginLog.Debug("File written to : " + filePath); + using (var db = new FileCacheContext()) + { + db.Add(new FileCache + { + Filepath = filePath.ToLower(), + Hash = file.Hash, + LastModifiedDate = DateTime.Now.Ticks.ToString(), + }); + db.SaveChanges(); + } + PluginLog.Debug("Added hash to db: " + hash); + } + + PluginLog.Debug("Assigned hash to visible player: " + playerObject.Name.ToString()); + ipcManager.GlamourerApplyCharacterCustomization(e.CharacterData.GlamourerData, playerObject.Name.ToString()); + } + + private void ApiController_Disconnected(object? sender, EventArgs args) + { + PluginLog.Debug(nameof(ApiController_Disconnected)); + _framework.Update -= Framework_Update; + ipcManager.PenumbraRedrawEvent -= IpcManager_PenumbraRedrawEvent; + clientState.TerritoryChanged -= ClientState_TerritoryChanged; + } + + private void ApiController_Connected(object? sender, EventArgs args) + { + PluginLog.Debug(nameof(ApiController_Connected)); + PluginLog.Debug("MyHashedName:" + Crypto.GetHash256(GetPlayerName() + clientState.LocalPlayer!.HomeWorld.Id)); + var apiTask = apiController.SendCharacterName(Crypto.GetHash256(GetPlayerName() + clientState.LocalPlayer!.HomeWorld.Id)); + var assignTask = AssignLocalPlayersData(); + + Task.WaitAll(apiTask, assignTask); + + _framework.Update += Framework_Update; + ipcManager.PenumbraRedrawEvent += IpcManager_PenumbraRedrawEvent; + clientState.TerritoryChanged += ClientState_TerritoryChanged; } public void StopWatchPlayer(string name) diff --git a/MareSynchronos/Managers/IpcManager.cs b/MareSynchronos/Managers/IpcManager.cs index 3d1116d..81d70fd 100644 --- a/MareSynchronos/Managers/IpcManager.cs +++ b/MareSynchronos/Managers/IpcManager.cs @@ -15,9 +15,10 @@ namespace MareSynchronos.Managers private ICallGateSubscriber? glamourerApplyCharacterCustomization; private ICallGateSubscriber penumbraApiVersion; private ICallGateSubscriber glamourerApiVersion; - private ICallGateSubscriber penumbraObjectIsRedrawn; + private ICallGateSubscriber penumbraObjectIsRedrawn; private ICallGateSubscriber? penumbraRedraw; private ICallGateSubscriber? penumbraReverseResolvePath; + private ICallGateSubscriber glamourerRevertCustomization; public bool Initialized { get; private set; } = false; @@ -36,7 +37,8 @@ namespace MareSynchronos.Managers penumbraReverseResolvePath = pluginInterface.GetIpcSubscriber("Penumbra.ReverseResolvePath"); penumbraApiVersion = pluginInterface.GetIpcSubscriber("Penumbra.ApiVersion"); glamourerApiVersion = pluginInterface.GetIpcSubscriber("Glamourer.ApiVersion"); - penumbraObjectIsRedrawn = pluginInterface.GetIpcSubscriber("Penumbra.ObjectIsRedrawn"); + glamourerRevertCustomization = pluginInterface.GetIpcSubscriber("Glamourer.RevertCharacterCustomization"); + penumbraObjectIsRedrawn = pluginInterface.GetIpcSubscriber("Penumbra.GameObjectRedrawn"); penumbraObjectIsRedrawn.Subscribe(RedrawEvent); penumbraInit.Subscribe(RedrawSelf); @@ -67,9 +69,9 @@ namespace MareSynchronos.Managers } } - private void RedrawEvent(string actorName) + private void RedrawEvent(IntPtr objectAddress, int objectTableIndex) { - PenumbraRedrawEvent?.Invoke(actorName, EventArgs.Empty); + PenumbraRedrawEvent?.Invoke(objectTableIndex, EventArgs.Empty); } private void RedrawSelf() @@ -120,6 +122,12 @@ namespace MareSynchronos.Managers glamourerApplyCharacterCustomization!.InvokeAction(customization, characterName); } + public void GlamourerRevertCharacterCustomization(string characterName) + { + if (!CheckGlamourerAPI()) return; + glamourerRevertCustomization!.InvokeAction(characterName); + } + public void PenumbraRedraw(string actorName) { if (!CheckPenumbraAPI()) return; diff --git a/MareSynchronos/MareSynchronos.csproj b/MareSynchronos/MareSynchronos.csproj index 50ac2de..419e3ca 100644 --- a/MareSynchronos/MareSynchronos.csproj +++ b/MareSynchronos/MareSynchronos.csproj @@ -27,6 +27,7 @@ + all runtime; build; native; contentfiles; analyzers; buildtransitive @@ -39,6 +40,7 @@ + diff --git a/MareSynchronos/Models/CharacterCache.cs b/MareSynchronos/Models/CharacterCache.cs index 694c5e7..e28729d 100644 --- a/MareSynchronos/Models/CharacterCache.cs +++ b/MareSynchronos/Models/CharacterCache.cs @@ -5,12 +5,24 @@ using System.Collections.Generic; using System.Linq; using System.Text; using System.Threading.Tasks; +using MareSynchronos.API; namespace MareSynchronos.Models { [JsonObject(MemberSerialization.OptIn)] public class CharacterCache { + public CharacterCacheDto ToCharacterCacheDto() + { + return new CharacterCacheDto() + { + FileReplacements = AllReplacements.Select(f => f.ToFileReplacementDto()).ToList(), + GlamourerData = GlamourerString, + Hash = CacheHash, + JobId = (int)JobId + }; + } + [JsonProperty] public List AllReplacements => FileReplacements.Where(f => f.HasFileReplacement) diff --git a/MareSynchronos/Models/FileReplacement.cs b/MareSynchronos/Models/FileReplacement.cs index 12cb4ce..4f0d809 100644 --- a/MareSynchronos/Models/FileReplacement.cs +++ b/MareSynchronos/Models/FileReplacement.cs @@ -7,6 +7,7 @@ using System.Threading.Tasks; using Newtonsoft.Json; using MareSynchronos.FileCacheDB; using System.IO; +using MareSynchronos.API; using MareSynchronos.Utils; namespace MareSynchronos.Models @@ -14,6 +15,16 @@ namespace MareSynchronos.Models [JsonObject(MemberSerialization.OptIn)] public class FileReplacement { + public FileReplacementDto ToFileReplacementDto() + { + return new FileReplacementDto + { + GamePaths = GamePaths, + Hash = Hash, + ImcData = ImcData + }; + } + private readonly string penumbraDirectory; [JsonProperty] @@ -88,13 +99,20 @@ namespace MareSynchronos.Models var fileAddedDuringCompute = db.FileCaches.SingleOrDefault(f => f.Filepath == fi.FullName.ToLower()); if (fileAddedDuringCompute != null) return fileAddedDuringCompute.Hash; - db.Add(new FileCache() + try { - Hash = hash, - Filepath = fi.FullName.ToLower(), - LastModifiedDate = fi.LastWriteTimeUtc.Ticks.ToString() - }); - db.SaveChanges(); + db.Add(new FileCache() + { + Hash = hash, + Filepath = fi.FullName.ToLower(), + LastModifiedDate = fi.LastWriteTimeUtc.Ticks.ToString() + }); + db.SaveChanges(); + } + catch (Exception ex) + { + PluginLog.Error(ex, "Error adding files to database. Most likely not an issue though."); + } return hash; } diff --git a/MareSynchronos/Plugin.cs b/MareSynchronos/Plugin.cs index 83b1b92..324a79f 100644 --- a/MareSynchronos/Plugin.cs +++ b/MareSynchronos/Plugin.cs @@ -91,7 +91,7 @@ namespace MareSynchronos } characterManager = new CharacterManager( - clientState, framework, apiController, objectTable, ipcManager, new FileReplacementFactory(ipcManager)); + clientState, framework, apiController, objectTable, ipcManager, new FileReplacementFactory(ipcManager), Configuration); characterManager.StartWatchingPlayer(); ipcManager.PenumbraRedraw(clientState.LocalPlayer!.Name.ToString()); }); @@ -131,7 +131,7 @@ namespace MareSynchronos { Stopwatch st = Stopwatch.StartNew(); - File.WriteAllBytes(lc4hcPath, LZ4Codec.Encode(File.ReadAllBytes(fileCache.Filepath), 0, (int)new FileInfo(fileCache.Filepath).Length)); + File.WriteAllBytes(lc4hcPath, LZ4Codec.WrapHC(File.ReadAllBytes(fileCache.Filepath), 0, (int)new FileInfo(fileCache.Filepath).Length)); st.Stop(); PluginLog.Debug("Compressed " + new FileInfo(fileCache.Filepath).Length + " bytes to " + new FileInfo(lc4hcPath).Length + " bytes in " + st.Elapsed); File.Copy(fileCache.Filepath, newFilePath); @@ -226,6 +226,11 @@ namespace MareSynchronos PluginLog.Debug("Mod created to " + modDirectoryPath); }); } + + if (string.IsNullOrEmpty(args)) + { + PluginUi.Toggle(); + } } } } diff --git a/MareSynchronos/PluginUI.cs b/MareSynchronos/PluginUI.cs index 8fcc2af..f92a472 100644 --- a/MareSynchronos/PluginUI.cs +++ b/MareSynchronos/PluginUI.cs @@ -6,7 +6,10 @@ using ImGuiNET; using MareSynchronos.Managers; using MareSynchronos.WebAPI; using System; +using System.Linq; using System.Numerics; +using Dalamud.Configuration; +using MareSynchronos.API; namespace MareSynchronos { @@ -49,7 +52,17 @@ namespace MareSynchronos return; } - if (string.IsNullOrEmpty(apiController.SecretKey)) + + if (apiController.SecretKey != "-" && !apiController.IsConnected && apiController.ServerAlive) + { + if (ImGui.Button("Reset Secret Key")) + { + configuration.ClientSecret.Clear(); + configuration.Save(); + apiController.RestartHeartbeat(); + } + } + else if (apiController.SecretKey == "-") { DrawIntroContent(); } @@ -67,15 +80,95 @@ namespace MareSynchronos ImGui.Separator(); ImGui.Text("Your UID"); ImGui.SameLine(); - ImGui.TextColored(ImGuiColors.ParsedGreen, apiController.UID); - ImGui.SameLine(); - if (ImGui.Button("Copy UID")) + if (apiController.ServerAlive) { - ImGui.SetClipboardText(apiController.UID); + ImGui.TextColored(ImGuiColors.ParsedGreen, apiController.UID); + ImGui.SameLine(); + if (ImGui.Button("Copy UID")) + { + ImGui.SetClipboardText(apiController.UID); + } + ImGui.Text("Share this UID to other Mare users so they can add you to their whitelist."); + ImGui.Separator(); + DrawWhiteListContent(); + ImGui.Separator(); + string cachePath = configuration.CacheFolder; + if (ImGui.InputText("CachePath", ref cachePath, 255)) + { + configuration.CacheFolder = cachePath; + configuration.Save(); + } + } + else + { + ImGui.TextColored(ImGuiColors.DalamudRed, "Service unavailable"); } - ImGui.Text("Share this UID to other Mare users so they can add you to their whitelist."); } + private void DrawWhiteListContent() + { + if (!apiController.ServerAlive) return; + ImGui.Text("Whitelists"); + if (ImGui.BeginTable("WhitelistTable", 5)) + { + ImGui.TableSetupColumn("Pause"); + ImGui.TableSetupColumn("UID"); + ImGui.TableSetupColumn("Sync"); + ImGui.TableSetupColumn("Paused (you/other)"); + ImGui.TableSetupColumn(""); + ImGui.TableHeadersRow(); + foreach (var item in apiController.WhitelistEntries.ToList()) + { + ImGui.TableNextColumn(); + bool isPaused = item.IsPaused; + if (ImGui.Checkbox("Paused##" + item.OtherUID, ref isPaused)) + { + _ = apiController.SendWhitelistPauseChange(item.OtherUID, isPaused); + } + + ImGui.TableNextColumn(); + ImGui.TextColored(GetBoolColor(item.IsSynced && !item.IsPausedFromOthers && !item.IsPaused), + item.OtherUID); + ImGui.TableNextColumn(); + ImGui.TextColored(GetBoolColor(item.IsSynced), !item.IsSynced ? "Has not added you" : "On both whitelists"); + ImGui.TableNextColumn(); + ImGui.TextColored(GetBoolColor((!item.IsPausedFromOthers && !item.IsPaused)), item.IsPaused + " / " + item.IsPausedFromOthers); + ImGui.TableNextColumn(); + if (ImGui.Button("Delete##" + item.OtherUID)) + { + _ = apiController.SendWhitelistRemoval(item.OtherUID); + apiController.WhitelistEntries.Remove(item); + } + ImGui.TableNextRow(); + } + ImGui.EndTable(); + } + + var whitelistEntry = tempDto.OtherUID; + if (ImGui.InputText("Add new whitelist entry", ref whitelistEntry, 20)) + { + tempDto.OtherUID = whitelistEntry; + } + + ImGui.SameLine(); + if (ImGui.Button("Add")) + { + if (apiController.WhitelistEntries.All(w => w.OtherUID != tempDto.OtherUID)) + { + apiController.WhitelistEntries.Add(new WhitelistDto() + { + OtherUID = tempDto.OtherUID + }); + + _ = apiController.SendWhitelistAddition(tempDto.OtherUID); + + tempDto.OtherUID = string.Empty; + } + } + } + + private WhitelistDto tempDto = new WhitelistDto() { OtherUID = string.Empty }; + private int serverSelectionIndex = 0; private async void DrawIntroContent() @@ -141,13 +234,19 @@ namespace MareSynchronos if (apiController.UseCustomService) { string serviceAddress = configuration.ApiUri; - ImGui.InputText("Service address", ref serviceAddress, 255); - configuration.ApiUri = serviceAddress; - configuration.Save(); + if (ImGui.InputText("Service address", ref serviceAddress, 255)) + { + if (configuration.ApiUri != serviceAddress) + { + configuration.ApiUri = serviceAddress; + apiController.RestartHeartbeat(); + configuration.Save(); + } + } } PrintServerState(); - if (apiController.IsConnected) + if (apiController.ServerAlive) { if (ImGui.Button("Register")) { @@ -159,6 +258,8 @@ namespace MareSynchronos } } + private Vector4 GetBoolColor(bool input) => input ? ImGuiColors.ParsedGreen : ImGuiColors.DalamudRed; + private bool OtherPluginStateOk() { var penumbraExists = ipcManager.CheckPenumbraAPI(); @@ -186,8 +287,8 @@ namespace MareSynchronos { ImGui.Text("Service status of " + (string.IsNullOrEmpty(configuration.ApiUri) ? mainServer : configuration.ApiUri)); ImGui.SameLine(); - var color = apiController.IsConnected ? ImGuiColors.ParsedGreen : ImGuiColors.DalamudRed; - ImGui.TextColored(color, apiController.IsConnected ? "Available" : "Unavailable"); + var color = apiController.ServerAlive ? ImGuiColors.ParsedGreen : ImGuiColors.DalamudRed; + ImGui.TextColored(color, apiController.ServerAlive ? "Available" : "Unavailable"); } } } diff --git a/MareSynchronos/Utils/Crypto.cs b/MareSynchronos/Utils/Crypto.cs index 231ac4d..835daca 100644 --- a/MareSynchronos/Utils/Crypto.cs +++ b/MareSynchronos/Utils/Crypto.cs @@ -18,5 +18,11 @@ namespace MareSynchronos.Utils using SHA1CryptoServiceProvider cryptoProvider = new(); return BitConverter.ToString(cryptoProvider.ComputeHash(Encoding.UTF8.GetBytes(stringToHash))).Replace("-", ""); } + + public static string GetHash256(string stringToHash) + { + using SHA256CryptoServiceProvider cryptoProvider = new(); + return BitConverter.ToString(cryptoProvider.ComputeHash(Encoding.UTF8.GetBytes(stringToHash))).Replace("-", ""); + } } } diff --git a/MareSynchronos/WebAPI/ApiController.cs b/MareSynchronos/WebAPI/ApiController.cs index e44c0ff..80c6183 100644 --- a/MareSynchronos/WebAPI/ApiController.cs +++ b/MareSynchronos/WebAPI/ApiController.cs @@ -2,20 +2,38 @@ using Dalamud.Logging; using MareSynchronos.Models; using System; +using System.Buffers.Text; +using System.Collections; using System.Collections.Generic; +using System.Collections.Immutable; +using System.IO; using System.Linq; +using System.Net.Http; +using System.Runtime.InteropServices.ComTypes; +using System.Security.Cryptography; using System.Text; using System.Threading; using System.Threading.Tasks; +using Dalamud.Game.ClientState.Objects.Types; +using LZ4; +using MareSynchronos.API; +using MareSynchronos.FileCacheDB; +using Microsoft.AspNetCore.SignalR.Client; namespace MareSynchronos.WebAPI { + public class CharacterReceivedEventArgs : EventArgs + { + public CharacterCacheDto CharacterData { get; set; } + public string CharacterNameHash { get; set; } + } + public class ApiController : IDisposable { private readonly Configuration pluginConfiguration; - private const string mainService = "https://localhost:6591"; + private const string MainService = "https://darkarchon.internet-box.ch:5001"; public string UID { get; private set; } = string.Empty; - public string SecretKey => pluginConfiguration.ClientSecret.ContainsKey(ApiUri) ? pluginConfiguration.ClientSecret[ApiUri] : string.Empty; + public string SecretKey => pluginConfiguration.ClientSecret.ContainsKey(ApiUri) ? pluginConfiguration.ClientSecret[ApiUri] : "-"; private string CacheFolder => pluginConfiguration.CacheFolder; public bool UseCustomService { @@ -23,89 +41,295 @@ namespace MareSynchronos.WebAPI set { pluginConfiguration.UseCustomService = value; - _ = Heartbeat(); pluginConfiguration.Save(); } } - private string ApiUri => UseCustomService ? pluginConfiguration.ApiUri : mainService; + private string ApiUri => UseCustomService ? pluginConfiguration.ApiUri : MainService; - public bool IsConnected { get; set; } + public bool ServerAlive => + (_heartbeatHub?.State ?? HubConnectionState.Disconnected) == HubConnectionState.Connected; + public bool IsConnected => !string.IsNullOrEmpty(UID); - Task heartbeatTask; - CancellationTokenSource cts; + public event EventHandler? Connected; + public event EventHandler? Disconnected; + public event EventHandler? CharacterReceived; + public event EventHandler? RemovedFromWhitelist; + public event EventHandler? AddedToWhitelist; + + public List WhitelistEntries { get; set; } = new List(); + + readonly CancellationTokenSource cts; + private HubConnection? _heartbeatHub; + private IDisposable? _fileUploadRequest; + private HubConnection? _fileHub; + private HubConnection? _userHub; public ApiController(Configuration pluginConfiguration) { this.pluginConfiguration = pluginConfiguration; cts = new CancellationTokenSource(); - heartbeatTask = Task.Run(async () => - { - PluginLog.Debug("Starting heartbeat to " + ApiUri); - while (true && !cts.IsCancellationRequested) - { - await Heartbeat(); - await Task.Delay(TimeSpan.FromSeconds(15), cts.Token); - } - PluginLog.Debug("Stopping heartbeat"); - }, cts.Token); + _ = Heartbeat(); } public async Task Heartbeat() { - try + while (!ServerAlive && !cts.Token.IsCancellationRequested) { - PluginLog.Debug("Sending heartbeat to " + ApiUri); - if (ApiUri != mainService) throw new Exception(); - IsConnected = true; + try + { + PluginLog.Debug("Attempting to establish heartbeat connection to " + ApiUri); + _heartbeatHub = new HubConnectionBuilder() + .WithUrl(ApiUri + "/heartbeat", options => + { + if (!string.IsNullOrEmpty(SecretKey)) + { + options.Headers.Add("Authorization", SecretKey); + } + +#if DEBUG + options.HttpMessageHandlerFactory = (message) => + { + if (message is HttpClientHandler clientHandler) + clientHandler.ServerCertificateCustomValidationCallback += + (sender, certificate, chain, sslPolicyErrors) => true; + return message; + }; +#endif + }).Build(); + PluginLog.Debug("Heartbeat service built to: " + ApiUri); + + await _heartbeatHub.StartAsync(cts.Token); + UID = await _heartbeatHub!.InvokeAsync("Heartbeat"); + PluginLog.Debug("Heartbeat started: " + ApiUri); + try + { + await InitializeHubConnections(); + await LoadInitialData(); + Connected?.Invoke(this, EventArgs.Empty); + } + catch (Exception ex) + { + PluginLog.Error(ex, "Error during Heartbeat initialization"); + } + + _heartbeatHub.Closed += OnHeartbeatHubOnClosed; + _heartbeatHub.Reconnected += OnHeartbeatHubOnReconnected; + PluginLog.Debug("Heartbeat established to: " + ApiUri); + } + catch (Exception ex) + { + PluginLog.Error(ex, "Creating heartbeat failure"); + } } - catch + } + + private async Task LoadInitialData() + { + var whiteList = await _userHub!.InvokeAsync>("GetWhitelist"); + WhitelistEntries = whiteList.ToList(); + } + + public void RestartHeartbeat() + { + PluginLog.Debug("Restarting heartbeat"); + + _heartbeatHub!.Closed -= OnHeartbeatHubOnClosed; + _heartbeatHub!.Reconnected -= OnHeartbeatHubOnReconnected; + Task.Run(async () => { - IsConnected = false; + await _heartbeatHub.StopAsync(cts.Token); + await _heartbeatHub.DisposeAsync(); + _heartbeatHub = null!; + _ = Heartbeat(); + }); + } + + private async Task OnHeartbeatHubOnReconnected(string? s) + { + PluginLog.Debug("Reconnected: " + ApiUri); + UID = await _heartbeatHub!.InvokeAsync("Heartbeat"); + } + + private Task OnHeartbeatHubOnClosed(Exception? exception) + { + PluginLog.Debug("Connection closed: " + ApiUri); + Disconnected?.Invoke(null, EventArgs.Empty); + RestartHeartbeat(); + return Task.CompletedTask; + } + + private async Task DisposeHubConnections() + { + if (_fileHub != null) + { + PluginLog.Debug("Disposing File Hub"); + _fileUploadRequest?.Dispose(); + await _fileHub!.StopAsync(); + await _fileHub!.DisposeAsync(); } + + if (_userHub != null) + { + PluginLog.Debug("Disposing User Hub"); + await _userHub.StopAsync(); + await _userHub.DisposeAsync(); + } + } + + private async Task InitializeHubConnections() + { + await DisposeHubConnections(); + + PluginLog.Debug("Creating User Hub"); + _userHub = new HubConnectionBuilder() + .WithUrl(ApiUri + "/user", options => + { + options.Headers.Add("Authorization", SecretKey); +#if DEBUG + options.HttpMessageHandlerFactory = (message) => + { + if (message is HttpClientHandler clientHandler) + clientHandler.ServerCertificateCustomValidationCallback += + (sender, certificate, chain, sslPolicyErrors) => true; + return message; + }; +#endif + }) + .Build(); + await _userHub.StartAsync(); + _userHub.On("UpdateWhitelist", UpdateLocalWhitelist); + _userHub.On("ReceiveCharacterData", ReceiveCharacterData); + + PluginLog.Debug("Creating File Hub"); + _fileHub = new HubConnectionBuilder() + .WithUrl(ApiUri + "/files", options => + { + options.Headers.Add("Authorization", SecretKey); +#if DEBUG + options.HttpMessageHandlerFactory = (message) => + { + if (message is HttpClientHandler clientHandler) + clientHandler.ServerCertificateCustomValidationCallback += + (sender, certificate, chain, sslPolicyErrors) => true; + return message; + }; +#endif + }) + .Build(); + await _fileHub.StartAsync(cts.Token); + + _fileUploadRequest = _fileHub!.On("FileRequest", UploadFile); + } + + private void UpdateLocalWhitelist(WhitelistDto dto, string characterIdentifier) + { + var entry = WhitelistEntries.SingleOrDefault(e => e.OtherUID == dto.OtherUID); + if (entry == null) + { + RemovedFromWhitelist?.Invoke(characterIdentifier, EventArgs.Empty); + return; + } + + if ((entry.IsPausedFromOthers != dto.IsPausedFromOthers || entry.IsSynced != dto.IsSynced || entry.IsPaused != dto.IsPaused) + && !dto.IsPaused && dto.IsSynced && !dto.IsPausedFromOthers) + { + AddedToWhitelist?.Invoke(characterIdentifier, EventArgs.Empty); + } + + entry.IsPaused = dto.IsPaused; + entry.IsPausedFromOthers = dto.IsPausedFromOthers; + entry.IsSynced = dto.IsSynced; + + if (dto.IsPaused || dto.IsPausedFromOthers || !dto.IsSynced) + { + RemovedFromWhitelist?.Invoke(characterIdentifier, EventArgs.Empty); + } + } + + private async Task UploadFile(string fileHash) + { + PluginLog.Debug("Requested fileHash: " + fileHash); + + await using var db = new FileCacheContext(); + var fileCache = db.FileCaches.First(f => f.Hash == fileHash); + var compressedFile = LZ4Codec.WrapHC(await File.ReadAllBytesAsync(fileCache.Filepath), 0, + (int)new FileInfo(fileCache.Filepath).Length); + var response = await _fileHub!.InvokeAsync("UploadFile", fileHash, compressedFile, cts.Token); + PluginLog.Debug("Success: " + response); } public async Task Register() { + if (!ServerAlive) return; PluginLog.Debug("Registering at service " + ApiUri); - var response = ("RandomSecretKey", "RandomUID"); - pluginConfiguration.ClientSecret[ApiUri] = response.Item1; - UID = response.Item2; - PluginLog.Debug(pluginConfiguration.ClientSecret[ApiUri]); - // pluginConfiguration.Save(); + var response = await _userHub!.InvokeAsync("Register"); + pluginConfiguration.ClientSecret[ApiUri] = response; + pluginConfiguration.Save(); + RestartHeartbeat(); } - public async Task UploadFile(string filePath) - { - PluginLog.Debug("Uploading file " + filePath + " to " + ApiUri); - } - - public async Task DownloadFile(string hash) - { - PluginLog.Debug("Downloading file from service " + ApiUri); - - return Array.Empty(); - } - - public async Task> SendCharacterData(CharacterCache character) + public async Task SendCharacterData(CharacterCacheDto character, List visibleCharacterIds) { + if (!IsConnected || SecretKey == "-") return; PluginLog.Debug("Sending Character data to service " + ApiUri); - List list = new(); - return list; + await _fileHub!.InvokeAsync("SendFiles", character.FileReplacements, cts.Token); + + while (await _fileHub!.InvokeAsync("IsUploadFinished")) + { + await Task.Delay(TimeSpan.FromSeconds(0.5)); + PluginLog.Debug("Waiting for uploads to finish"); + } + + await _userHub!.InvokeAsync("PushCharacterData", character, visibleCharacterIds); } - public async Task GetCharacterData(string uid) + public Task ReceiveCharacterData(CharacterCacheDto character, string characterHash) { - PluginLog.Debug("Getting character data for " + uid + " from service " + ApiUri); + PluginLog.Debug("Received DTO for " + characterHash); + CharacterReceived?.Invoke(null, new CharacterReceivedEventArgs() + { + CharacterData = character, + CharacterNameHash = characterHash + }); + return Task.CompletedTask; + } - CharacterCache characterCache = new(); - return characterCache; + public async Task DownloadData(string hash) + { + return await _fileHub!.InvokeAsync("DownloadFile", hash); + } + + public async Task GetCharacterData(Dictionary hashedCharacterNames) + { + await _userHub!.InvokeAsync("GetCharacterData", + hashedCharacterNames); + } + + public async Task SendWhitelistPauseChange(string uid, bool paused) + { + if (!IsConnected || SecretKey == "-") return; + await _userHub!.SendAsync("SendWhitelistPauseChange", uid, paused); + } + + public async Task SendWhitelistAddition(string uid) + { + if (!IsConnected || SecretKey == "-") return; + await _userHub!.SendAsync("SendWhitelistAddition", uid); + } + + public async Task SendWhitelistRemoval(string uid) + { + if (!IsConnected || SecretKey == "-") return; + await _userHub!.SendAsync("SendWhitelistRemoval", uid); } public async Task SendWhitelist() { - PluginLog.Debug("Sending whitelist to service " + ApiUri); + if (!IsConnected || SecretKey == "-") return; + await _userHub!.SendAsync("SendWhitelist", WhitelistEntries.ToList()); + WhitelistEntries = (await _userHub!.InvokeAsync>("GetWhitelist")).ToList(); } public async Task> GetWhitelist() @@ -119,6 +343,18 @@ namespace MareSynchronos.WebAPI public void Dispose() { cts?.Cancel(); + _ = DisposeHubConnections(); + } + + public async Task SendCharacterName(string hashedName) + { + await _userHub!.SendAsync("SendCharacterNameHash", hashedName); + } + + public async Task SendVisibilityData(List visibilities) + { + if (!IsConnected) return; + await _userHub!.SendAsync("SendVisibilityList", visibilities); } } }