Compare commits

41 Commits

Author SHA1 Message Date
d777dc599f Fix MCDF & Masquer sa propre bulle + Bulles uniquement en groupe 2025-11-05 21:22:19 +01:00
e3d9300ca3 Ajout de la gestion avancée de l'indicateur de frappe et des paramètres associés 2025-11-02 17:21:25 +01:00
699678f641 Ajout du système de notifications : service, configuration et persistance des notifications introduits. Mise à jour de l'UI pour afficher et gérer les notifications Syncshell. 2025-11-02 13:48:11 +01:00
7a391e6253 "Amélioration de l'UI AutoDetect Syncshell : ajout de paramètres récurrents, plages horaires et gestion des fuseaux horaires. Refactorisation des méthodes de dessin et introduction de boutons centrés pour une meilleure ergonomie. Mise à jour des fichiers de configuration et du projet avec des optimisations diverses." 2025-11-02 00:26:11 +01:00
620ebf9195 Update UI & Syncshell Public & MCDF Share 2025-11-01 19:57:54 +01:00
8cc4f34c55 Update UI & Syncshell Public & MCDF Share 2025-11-01 19:55:49 +01:00
513845b811 UI Update & Fix Nearby 2025-11-01 01:09:06 +01:00
84586cac3d UI Update 2025-10-31 23:58:11 +01:00
b4108c7803 Fix warning 2025-10-30 22:13:38 +01:00
d891dceb28 UI Update 2025-10-19 21:56:19 +02:00
89fa1a999f Fix bubble party + Download queue + Allow pause user in syncshell + add visual feature + clean log info 2025-10-19 01:29:57 +02:00
1f6e86ec2d Woops 2025-10-12 13:20:23 +02:00
d225a3844a Fix UI + Amélioration AutoDetect & Self Analyse + Update Penumbra API 2025-10-12 12:42:31 +02:00
d4a46910f9 Fix UI & BubbleType position 2025-10-12 01:00:08 +02:00
b59a579f56 Update 0.1.9.2 - Fix BubleChat 2025-10-04 19:15:27 +02:00
7706ef1fa7 Hotfix 0.1.9.1 - Update API & Double notification & Update Penumbra API Ref 2025-09-29 02:10:03 +02:00
fca730557e Update 0.1.9 - Correctif UI + Default Synchronisation settings + Detect TypeChat 2025-09-29 00:19:45 +02:00
6572fdcc27 Update 0.1.8.2 - Correctif UI, Correctif Nearby CompactUI, Add changelog 2025-09-27 10:02:43 +02:00
bf770f19d9 Update 0.1.8.1 - Changement UI & Ajout "vue sous" (cf.Suggestion Discord) 2025-09-23 23:41:45 +02:00
78089a9fc7 Update 0.1.8 - Fix interface & ajout syncshell perma/temp 2025-09-20 12:39:18 +02:00
3c81e1f243 Fix - Enforcing Unique groupe aliases 2025-09-19 23:58:31 +02:00
0808266887 Update 0.1.7 - Allow naming Syncshell 2025-09-19 23:18:09 +02:00
a2071b9c05 Update MareAPI submodule and config 2025-09-19 22:52:09 +02:00
612e7c88a2 Fix UI & Rotate Salt 2025-09-19 22:33:40 +02:00
1755b5cb54 Clean code 2025-09-14 00:21:30 +02:00
4a388dcfa9 Update 0.1.6 - UI change 2025-09-13 22:42:08 +02:00
a0957715a5 Update 0.1.6 - Fix UI settings & Delay Detection 2025-09-13 20:08:24 +02:00
04a8ee3186 Update 0.1.6 - Deploy AutoDetect, last debug and optimization 2025-09-13 13:41:00 +02:00
b79a51748f Update 0.1.4 - AutoDetect WIP Debug & Fix UI & Optimization 2025-09-11 22:37:29 +02:00
95d9f65068 Update 0.1.2 - AutoDetect Debug before release 2025-09-11 15:42:41 +02:00
a70968d30c Nearby (AutoDetect) Phase 1 — settings, window, compact section; UmbraSync theme; minor fixes 2025-09-11 11:30:09 +02:00
6ebb73040b earby (AutoDetect) Phase 1 — settings, window, compact section; UmbraSync theme; minor fixes 2025-09-11 11:28:48 +02:00
46f2443824 FINALY !!! 2025-09-05 21:34:17 +02:00
eeab8354b6 remove old ref + update gitsubmodule + update 0.1.0.0 + add NoSnapService + pimpmymod + Licence AGPLv3 2025-09-05 15:03:41 +02:00
b5d8f288f9 remove old ref + update gitsubmodule + update 0.1.0.0 + add NoSnapService + pimpmymod 2025-09-05 15:02:52 +02:00
3c2dab4d21 add submodules (MareAPI, Penumbra.Api, Glamourer.Api) 2025-09-05 13:39:35 +02:00
edb49f710a remove UmbraCrypt gitlink 2025-09-05 13:35:47 +02:00
9ff21dc341 purge UmbraCrypt submodule 2025-09-05 13:34:29 +02:00
17962a37b3 Uodate 0.0.6 - Change reference and clean 2025-09-04 22:54:55 +02:00
4495177f02 Update 0.0.5 - Change repo & services 2025-09-04 21:22:39 +02:00
14bb5c14f7 Update 0.0.5 - Change repo & services 2025-09-04 21:22:24 +02:00
271 changed files with 9846 additions and 18064 deletions

9
.gitignore vendored
View File

@@ -2,15 +2,20 @@
## files generated by popular Visual Studio add-ons.
##
## Get latest from https://github.com/github/gitignore/blob/master/VisualStudio.gitignore
.idea
qodana.yaml
# User-specific files
*.rsuser
*.suo
*.user
*.userosscache
*.sln.docstates
*.bak
.DS_Store
MareSynchronos/.DS_Store
*.zip
UmbraServer_extracted/
NuGet.config
Directory.Build.props
# User-specific files (MonoDevelop/Xamarin Studio)
*.userprefs

23
.gitmodules vendored
View File

@@ -1,15 +1,12 @@
[submodule "UmbraAPI"]
path = UmbraAPI
url = https://git.umbra-sync.net/SirConstance/UmbraAPI.git
[submodule "MareAPI"]
path = MareAPI
url = ssh://git@git.umbra-sync.net:1222/Keda/UmbraAPI.git
branch = main
[submodule "Penumbra.Api"]
path = Penumbra.Api
url = https://github.com/Ottermandias/Penumbra.Api.git
path = Penumbra.Api
url = https://github.com/Ottermandias/Penumbra.Api.git
branch = main
[submodule "Glamourer.Api"]
path = Glamourer.Api
url = https://github.com/Ottermandias/Glamourer.Api
[submodule "UmbraCrypt"]
path = UmbraCrypt
url = https://git.umbra-sync.net/SirConstance/UmbraCrypt.git
path = Glamourer.Api
url = https://github.com/Ottermandias/Glamourer.Api.git
branch = main

1
Glamourer.Api Submodule

Submodule Glamourer.Api added at 59a7ab5fa9

File diff suppressed because it is too large Load Diff

View File

@@ -1,3 +0,0 @@
bin/
obj/
.vs/

View File

@@ -1,14 +0,0 @@
namespace Glamourer.Api.Api;
/// <summary> The full API available. </summary>
public interface IGlamourerApi : IGlamourerApiBase
{
/// <inheritdoc cref="IGlamourerApiDesigns"/>
public IGlamourerApiDesigns Designs { get; }
/// <inheritdoc cref="IGlamourerApiItems"/>
public IGlamourerApiItems Items { get; }
/// <inheritdoc cref="IGlamourerApiState"/>
public IGlamourerApiState State { get; }
}

View File

@@ -1,11 +0,0 @@
namespace Glamourer.Api.Api;
/// <summary> Basic API functions. </summary>
public interface IGlamourerApiBase
{
/// <summary>
/// Get the current API version of the Glamourer available in this installation.
/// Major version changes indicate incompatibilities, minor version changes are backward-compatible additions.
/// </summary>
public (int Major, int Minor) ApiVersion { get; }
}

View File

@@ -1,33 +0,0 @@
using Glamourer.Api.Enums;
namespace Glamourer.Api.Api;
/// <summary> All functions related to Glamourer designs. </summary>
public interface IGlamourerApiDesigns
{
/// <summary> Obtain a list of all available designs. </summary>
/// <returns> A dictionary of all designs from their GUID to their current display name. </returns>
public Dictionary<Guid, string> GetDesignList();
/// <summary> Apply an existing design to an actor. </summary>
/// <param name="designId"> The GUID of the design to apply. </param>
/// <param name="objectIndex"> The game object index of the actor to be manipulated. </param>
/// <param name="key"> A key to unlock or lock the state if necessary. </param>
/// <param name="flags"> The flags used for the reversion. Respects Once, Equipment, Customization, Lock (see <see cref="ApplyFlag"/>.)</param>
/// <returns> DesignNotFound, ActorNotFound, InvalidKey, Success. </returns>
public GlamourerApiEc ApplyDesign(Guid designId, int objectIndex, uint key, ApplyFlag flags);
/// <summary> Apply an existing design to an actor. </summary>
/// <param name="designId"> The GUID of the design to apply. </param>
/// <param name="playerName"> The name of the players to be manipulated. </param>
/// <param name="key"> A key to unlock or lock the state if necessary. </param>
/// <param name="flags"> The flags used for the reversion. Respects Once, Equipment, Customization, Lock (see <see cref="ApplyFlag"/>.)</param>
/// <returns> DesignNotFound, ActorNotFound, InvalidKey, Success. </returns>
/// /// <remarks>
/// The player does not have to be currently available as long as he has a persisted Glamourer state.<br/>
/// Only players are checked for name equality, no NPCs.<br/>
/// If multiple players of the same name are found, all of them are reverted.<br/>
/// Prefer to use the index-based function unless you need to get the state of someone currently unavailable.
/// </remarks>
public GlamourerApiEc ApplyDesignName(Guid designId, string playerName, uint key, ApplyFlag flags);
}

View File

@@ -1,80 +0,0 @@
using Glamourer.Api.Enums;
namespace Glamourer.Api.Api;
/// <summary> All functions related to items. </summary>
public interface IGlamourerApiItems
{
/// <summary> Set a single item on an actor. </summary>
/// <param name="objectIndex"> The game object index of the actor to be manipulated. </param>
/// <param name="slot"> The slot to apply the item to. </param>
/// <param name="itemId"> The (Custom) ID of the item to apply. </param>
/// <param name="stains"> The IDs of the stains to apply to the item. </param>
/// <param name="key"> A key to unlock or lock the state if necessary. </param>
/// <param name="flags"> The flags used for the reversion. Respects Once (see <see cref="ApplyFlag"/>.)</param>
/// <returns> ItemInvalid, ActorNotFound, ActorNotHuman, InvalidKey, Success. </returns>
/// <remarks> The item ID can be a custom item ID in Glamourer's format for models without an associated item, or a normal game item ID. </remarks>
public GlamourerApiEc SetItem(int objectIndex, ApiEquipSlot slot, ulong itemId, IReadOnlyList<byte> stains, uint key, ApplyFlag flags);
/// <summary> Set a single item on players. </summary>
/// <param name="playerName"> The name of the players to be manipulated. </param>
/// <param name="slot"> The slot to apply the item to. </param>
/// <param name="itemId"> The (Custom) ID of the item to apply. </param>
/// <param name="stains"> The IDs of the stains to apply to the item. </param>
/// <param name="key"> A key to unlock or lock the state if necessary. </param>
/// <param name="flags"> The flags used for the reversion. Respects Once (see <see cref="ApplyFlag"/>.)</param>
/// <returns> ItemInvalid, ActorNotFound, ActorNotHuman, InvalidKey, Success. </returns>
/// <remarks>
/// The item ID can be a custom item ID in Glamourer's format for models without an associated item, or a normal game item ID.<br/>
/// The player does not have to be currently available as long as he has a persisted Glamourer state.<br/>
/// Only players are checked for name equality, no NPCs.<br/>
/// If multiple players of the same name are found, all of them are modified.<br/>
/// Prefer to use the index-based function unless you need to get the state of someone currently unavailable.
/// </remarks>
public GlamourerApiEc SetItemName(string playerName, ApiEquipSlot slot, ulong itemId, IReadOnlyList<byte> stains, uint key,
ApplyFlag flags);
/// <summary> Set a single bonus item on an actor. </summary>
/// <param name="objectIndex"> The game object index of the actor to be manipulated. </param>
/// <param name="slot"> The bonus slot to apply the item to. </param>
/// <param name="bonusItemId"> The bonus item sheet ID of the item to apply (including stain). </param>
/// <param name="key"> A key to unlock or lock the state if necessary. </param>
/// <param name="flags"> The flags used for the reversion. Respects Once (see <see cref="ApplyFlag"/>.)</param>
/// <returns> ItemInvalid, ActorNotFound, ActorNotHuman, InvalidKey, Success. </returns>
/// <remarks> The bonus item ID can currently not be a custom item ID in Glamourer's format for models without an associated item. Use 0 to remove the bonus item. </remarks>
public GlamourerApiEc SetBonusItem(int objectIndex, ApiBonusSlot slot, ulong bonusItemId, uint key, ApplyFlag flags);
/// <summary> Set a single bonus item on an actor. </summary>
/// <param name="playerName"> The game object index of the actor to be manipulated. </param>
/// <param name="slot"> The bonus slot to apply the item to. </param>
/// <param name="bonusItemId"> The bonus item sheet ID of the item to apply (including stain). </param>
/// <param name="key"> A key to unlock or lock the state if necessary. </param>
/// <param name="flags"> The flags used for the reversion. Respects Once (see <see cref="ApplyFlag"/>.)</param>
/// <returns> ItemInvalid, ActorNotFound, ActorNotHuman, InvalidKey, Success. </returns>
/// <remarks>
/// The bonus item ID can currently not be a custom item ID in Glamourer's format for models without an associated item. Use 0 to remove the bonus item. <br/>
/// The player does not have to be currently available as long as he has a persisted Glamourer state.<br/>
/// Only players are checked for name equality, no NPCs.<br/>
/// If multiple players of the same name are found, all of them are modified.<br/>
/// Prefer to use the index-based function unless you need to get the state of someone currently unavailable.
/// </remarks>
public GlamourerApiEc SetBonusItemName(string playerName, ApiBonusSlot slot, ulong bonusItemId, uint key, ApplyFlag flags);
/// <summary> Set the defined Meta State flags to the active or inactive state on actor. </summary>
/// <param name="objectIndex"> The game object index of the actor to be manipulated. </param>
/// <param name="types"> The flags defining which meta states to update to the new value. This can be multiple at once. </param>
/// <param name="newValue"> The new value to update to. </param>
/// <param name="key"> A key to unlock or lock the state if necessary. </param>
/// <param name="flags"> The flags used for the reversion. Respects Once (see <see cref="ApplyFlag.Once"/>.)</param>
/// <returns> ItemInvalid, ActorNotFound, ActorNotHuman, InvalidKey, Success. </returns>
public GlamourerApiEc SetMetaState(int objectIndex, MetaFlag types, bool newValue, uint key, ApplyFlag flags);
/// <summary> Set the defined Meta State flags to the active or inactive state on actor (by name) </summary>
/// <param name="playerName"> The name of the players to be manipulated. </param>
/// <param name="types"> The flags defining which meta states to update to the new value. This can be multiple at once. </param>
/// <param name="newValue"> The new value to update to. </param>
/// <param name="key"> A key to unlock or lock the state if necessary. </param>
/// <param name="flags"> The flags used for the reversion. Respects Once (see <see cref="ApplyFlag.Once"/>.)</param>
/// <returns> ItemInvalid, ActorNotFound, ActorNotHuman, InvalidKey, Success. </returns>
public GlamourerApiEc SetMetaStateName(string playerName, MetaFlag types, bool newValue, uint key, ApplyFlag flags);
}

View File

@@ -1,124 +0,0 @@
using Glamourer.Api.Enums;
using Newtonsoft.Json.Linq;
namespace Glamourer.Api.Api;
/// <summary> Any functions related to Glamourer's state tracking. </summary>
public interface IGlamourerApiState
{
/// <summary> Get the current Glamourer state of an actor. </summary>
/// <param name="objectIndex"> The game object index of the desired actor. </param>
/// <param name="key"> A key to unlock the state if necessary. </param>
/// <returns> ActorNotFound, InvalidKey or Success, and the state on success. </returns>
/// <remarks> The actor does not need to have a prior Glamourer state as long as it can be found. </remarks>
public (GlamourerApiEc, JObject?) GetState(int objectIndex, uint key);
/// <summary> Get the current Glamourer state of a player character. </summary>
/// <param name="playerName"> The name of the desired player. </param>
/// <param name="key"> A key to unlock the state if necessary. </param>
/// <returns> ActorNotFound, InvalidKey or Success, and the state on success. </returns>
/// <remarks>
/// The player does not have to be currently available as long as he has a persisted Glamourer state.
/// Only players are checked for name equality, no NPCs.
/// If multiple players of the same name are found, the first is returned.
/// Prefer to use the index-based function unless you need to get the state of someone currently unavailable.
/// </remarks>
public (GlamourerApiEc, JObject?) GetStateName(string playerName, uint key);
/// <inheritdoc cref="GetState"/>
public (GlamourerApiEc, string?) GetStateBase64(int objectIndex, uint key);
/// <inheritdoc cref="GetStateName"/>
public (GlamourerApiEc, string?) GetStateBase64Name(string objectName, uint key);
/// <summary> Apply a supplied state to an actor. </summary>
/// <param name="applyState"> The state, which can be either a Glamourer-supplied JObject or a Base64 string. </param>
/// <param name="objectIndex"> The game object index of the actor to be manipulated. </param>
/// <param name="key"> A key to unlock or lock the state if necessary. </param>
/// <param name="flags"> The flags used for the application. Respects Once, Equipment, Customization and Lock (see <see cref="ApplyFlag"/>.) </param>
/// <returns> ActorNotFound, InvalidKey, ActorNotHuman, Success. </returns>
public GlamourerApiEc ApplyState(object applyState, int objectIndex, uint key, ApplyFlag flags);
/// <summary> Apply a supplied state to players. </summary>
/// <param name="applyState"> The state, which can be either a Glamourer-supplied JObject or a Base64 string. </param>
/// <param name="playerName"> The name of the player to be manipulated. </param>
/// <param name="key"> A key to unlock or lock the state if necessary. </param>
/// <param name="flags"> The flags used for the application. Respects Once, Equipment, Customization and Lock (see <see cref="ApplyFlag"/>.) </param>
/// <returns> ActorNotFound, InvalidKey, ActorNotHuman, Success. </returns>
/// <remarks>
/// The player does not have to be currently available as long as he has a persisted Glamourer state.<br/>
/// Only players are checked for name equality, no NPCs.<br/>
/// If multiple players of the same name are found, all of them are manipulated.<br/>
/// Prefer to use the index-based function unless you need to get the state of someone currently unavailable.
/// </remarks>
public GlamourerApiEc ApplyStateName(object applyState, string playerName, uint key, ApplyFlag flags);
/// <summary> Revert the Glamourer state of an actor to Game state. </summary>
/// <param name="objectIndex"> The game object index of the actor to be manipulated. </param>
/// <param name="key"> A key to unlock the state if necessary. </param>
/// <param name="flags"> The flags used for the reversion. Respects Equipment and Customization (see <see cref="ApplyFlag"/>.) </param>
/// <returns> ActorNotFound, InvalidKey, Success, NothingDone. </returns>
public GlamourerApiEc RevertState(int objectIndex, uint key, ApplyFlag flags);
/// <summary> Revert the Glamourer state of players to game state. </summary>
/// <param name="playerName"> The name of the players to be reverted. </param>
/// <param name="key"> A key to unlock the state if necessary. </param>
/// <param name="flags"> The flags used for the reversion. Respects Equipment and Customization (see <see cref="ApplyFlag"/>.) </param>
/// <returns> ActorNotFound, InvalidKey, Success, NothingDone. </returns>
/// /// <remarks>
/// The player does not have to be currently available as long as he has a persisted Glamourer state.<br/>
/// Only players are checked for name equality, no NPCs.<br/>
/// If multiple players of the same name are found, all of them are reverted.<br/>
/// Prefer to use the index-based function unless you need to get the state of someone currently unavailable.
/// </remarks>
public GlamourerApiEc RevertStateName(string playerName, uint key, ApplyFlag flags);
/// <summary> Unlock the Glamourer state of an actor with a key. </summary>
/// <param name="objectIndex"> The game object index of the actor to be manipulated. </param>
/// <param name="key"> A key to unlock the state. </param>
/// <returns> ActorNotFound, InvalidKey, Success, NothingDone. </returns>
public GlamourerApiEc UnlockState(int objectIndex, uint key);
/// <summary> Unlock the Glamourer state of players with a key. </summary>
/// <param name="playerName"> The name of the players to be unlocked. </param>
/// <param name="key"> A key to unlock the state. </param>
/// <returns> InvalidKey, Success, NothingDone. </returns>
public GlamourerApiEc UnlockStateName(string playerName, uint key);
/// <summary> Unlock all active glamourer states with a key. </summary>
/// <param name="key"> The key to unlock states with. </param>
/// <returns> The number of unlocked states. </returns>
public int UnlockAll(uint key);
/// <summary> Revert the Glamourer state of an actor to automation state. </summary>
/// <param name="objectIndex"> The game object index of the actor to be manipulated. </param>
/// <param name="key"> A key to unlock the state if necessary. </param>
/// <param name="flags"> The flags used for the reversion. Respects Once and Lock (see <see cref="ApplyFlag"/>.) </param>
/// <returns> ActorNotFound, InvalidKey, Success, NothingDone. </returns>
public GlamourerApiEc RevertToAutomation(int objectIndex, uint key, ApplyFlag flags);
/// <summary> Revert the Glamourer state of players to automation state. </summary>
/// <param name="playerName"> The name of the players to be reverted. </param>
/// <param name="key"> A key to unlock the state if necessary. </param>
/// <param name="flags"> The flags used for the reversion. Respects Once and Lock (see <see cref="ApplyFlag"/>.) </param>
/// <returns> ActorNotFound, InvalidKey, Success, NothingDone. </returns>
/// /// <remarks>
/// The player does not have to be currently available as long as he has a persisted Glamourer state.<br/>
/// Only players are checked for name equality, no NPCs.<br/>
/// If multiple players of the same name are found, all of them are reverted.<br/>
/// Prefer to use the index-based function unless you need to get the state of someone currently unavailable.
/// </remarks>
public GlamourerApiEc RevertToAutomationName(string playerName, uint key, ApplyFlag flags);
/// <summary> Invoked with the game object pointer (if available) whenever an actors tracked state changes. </summary>
public event Action<nint> StateChanged;
/// <summary> Invoked with the game object pointer (if available) whenever an actors tracked state changes, with the type of change. </summary>
public event Action<nint, StateChangeType> StateChangedWithType;
/// <summary> Invoked with the game object pointer (if available) whenever an actors tracked state finalizes a grouped change consisting of multiple smaller changes. </summary>
public event Action<nint, StateFinalizationType> StateFinalized;
/// <summary> Invoked when the player enters or leaves GPose (true => entered GPose, false => left GPose). </summary>
public event Action<bool>? GPoseChanged;
}

View File

@@ -1,11 +0,0 @@
namespace Glamourer.Api.Enums;
/// <summary> Bonus item slots restricted to API-relevant slots. </summary>
public enum ApiBonusSlot : byte
{
/// <summary> No slot. </summary>
Unknown = 0,
/// <summary> The Glasses slot. </summary>
Glasses = 1,
}

View File

@@ -1,45 +0,0 @@
namespace Glamourer.Api.Enums;
/// <summary> Equip slots restricted to API-relevant slots, but compatible with GameData.EquipSlots. </summary>
public enum ApiEquipSlot : byte
{
/// <summary> No slot. </summary>
Unknown = 0,
/// <summary> Mainhand, also used for both-handed weapons. </summary>
MainHand = 1,
/// <summary> Offhand, used for shields or if you want to apply the offhand component of certain weapons. </summary>
OffHand = 2,
/// <summary> Head. </summary>
Head = 3,
/// <summary> Body. </summary>
Body = 4,
/// <summary> Hands. </summary>
Hands = 5,
/// <summary> Legs. </summary>
Legs = 7,
/// <summary> Feet. </summary>
Feet = 8,
/// <summary> Ears. </summary>
Ears = 9,
/// <summary> Neck. </summary>
Neck = 10,
/// <summary> Wrists. </summary>
Wrists = 11,
/// <summary> Right Finger. </summary>
RFinger = 12,
/// <summary> Left Finger. </summary>
/// <remarks> Not officially existing, means "weapon could be equipped in either hand" for the game. </remarks>
LFinger = 14,
}

View File

@@ -1,31 +0,0 @@
namespace Glamourer.Api.Enums;
/// <summary> Application flags that can be used in different situations. </summary>
[Flags]
public enum ApplyFlag : ulong
{
/// <summary> Apply the selected manipulation only once, without forcing the state into automation. </summary>
Once = 0x01,
/// <summary> Apply the selected manipulation on the equipment (might be more or less supported). </summary>
Equipment = 0x02,
/// <summary> Apply the selected manipulation on the customizations (might be more or less supported). </summary>
Customization = 0x04,
/// <summary> Lock the state with the given key after applying the selected manipulation </summary>
Lock = 0x08,
}
/// <summary> Extensions for apply flags. </summary>
public static class ApplyFlagEx
{
/// <summary> The default application flags for design-based manipulations. </summary>
public const ApplyFlag DesignDefault = ApplyFlag.Once | ApplyFlag.Equipment | ApplyFlag.Customization;
/// <summary> The default application flags for state-based manipulations. </summary>
public const ApplyFlag StateDefault = ApplyFlag.Equipment | ApplyFlag.Customization | ApplyFlag.Lock;
/// <summary> The default application flags for reverse manipulations. </summary>
public const ApplyFlag RevertDefault = ApplyFlag.Equipment | ApplyFlag.Customization;
}

View File

@@ -1,29 +0,0 @@
namespace Glamourer.Api.Enums;
/// <summary> Return codes for API functions. </summary>
public enum GlamourerApiEc
{
/// <summary> The function succeeded. </summary>
Success = 0,
/// <summary> The function did not encounter a problem, but also did not do anything. </summary>
NothingDone = 1,
/// <summary> The requested actor was not found. </summary>
ActorNotFound = 2,
/// <summary> The requested actor was not human, but should have been. </summary>
ActorNotHuman,
/// <summary> The requested design was not found. </summary>
DesignNotFound,
/// <summary> The requested item was not found or could not be applied to the requested slot. </summary>
ItemInvalid,
/// <summary> The state of an actor could not be manipulated because it was locked and the provided key could not unlock it. </summary>
InvalidKey,
/// <summary> The provided object could not be converted into a valid Glamourer state to apply. </summary>
InvalidState,
}

View File

@@ -1,11 +0,0 @@
namespace Glamourer.Api.Enums;
/// <summary> Application flags for setting the meta state of an actor. </summary>
[Flags]
public enum MetaFlag : ulong
{
Wetness = 0x01,
HatState = 0x02,
VisorState = 0x04,
WeaponState = 0x08,
}

View File

@@ -1,47 +0,0 @@
namespace Glamourer.Api.Enums;
/// <summary> What type of information changed in a state. </summary>
public enum StateChangeType
{
/// <summary> A characters saved state had the model id changed. This means everything may have changed. </summary>
Model = 0,
/// <summary> A characters saved state had multiple customization values changed. </summary>
EntireCustomize = 1,
/// <summary> A characters saved state had a customization value changed. </summary>
Customize = 2,
/// <summary> A characters saved state had an equipment piece changed. </summary>
Equip = 3,
/// <summary> A characters saved state had its weapons changed. </summary>
Weapon = 4,
/// <summary> A characters saved state had a stain changed. </summary>
Stains = 5,
/// <summary> A characters saved state had a crest visibility changed. </summary>
Crest = 6,
/// <summary> A characters saved state had its customize parameter changed. </summary>
Parameter = 7,
/// <summary> A characters saved state had a material color table value changed. </summary>
MaterialValue = 8,
/// <summary> A characters saved state had a design applied. This means everything may have changed. </summary>
Design = 9,
/// <summary> A characters saved state had its state reset to its game values. </summary>
Reset = 10,
/// <summary> A characters saved state had a meta toggle changed. </summary>
Other = 11,
/// <summary> A characters state was reapplied. Data is null. </summary>
Reapply = 12,
/// <summary> A characters saved state had a bonus item changed. </summary>
BonusItem = 13,
}

View File

@@ -1,36 +0,0 @@
namespace Glamourer.Api.Enums;
/// <summary> What type of Glamourer process was performed on the actors state to update it. </summary>
public enum StateFinalizationType
{
/// <summary> A characters saved state had the model id altered. </summary>
ModelChange = 0,
/// <summary> A singular Design was applied to an actors state. </summary>
DesignApplied = 1,
/// <summary> A characters saved state had been reset to game values. </summary>
Revert = 2,
/// <summary> A characters saved state had only its customization data reset to game state. </summary>
RevertCustomize = 3,
/// <summary> A characters saved state had only its equipment data reset to game state. </summary>
RevertEquipment = 4,
/// <summary> A characters saved state had its advanced values reverted to game state. </summary>
RevertAdvanced = 5,
/// <summary> A characters saved state was reverted to automation state on top of their game state </summary>
RevertAutomation = 6,
/// <summary> A characters saved state had a generic reapply as a single operation. </summary>
Reapply = 7,
/// <summary> A characters saved state had their automation state reapplied over their existing state. </summary>
ReapplyAutomation = 8,
/// <summary> A characters save state finished applying all updated slots for game state on gearset change or initial load. </summary>
Gearset = 9,
}

View File

@@ -1,34 +0,0 @@
<Project Sdk="Dalamud.NET.Sdk/13.0.0">
<PropertyGroup>
<AssemblyTitle>Glamourer.Api</AssemblyTitle>
<Product>Glamourer</Product>
<Copyright>Copyright © 2025</Copyright>
<FileVersion>2.4.1.0</FileVersion>
<AssemblyVersion>2.4.1.0</AssemblyVersion>
<PackageVersion>2.4.1</PackageVersion>
<PackageReadmeFile>README.md</PackageReadmeFile>
<OutputPath>bin\$(Configuration)\</OutputPath>
</PropertyGroup>
<PropertyGroup>
<GeneratePackageOnBuild>true</GeneratePackageOnBuild>
<GenerateDocumentationFile>true</GenerateDocumentationFile>
<Title>Glamourer.Api</Title>
<Authors>Ottermandias</Authors>
<RepositoryUrl>https://github.com/Ottermandias/Glamourer</RepositoryUrl>
<Description>Auxiliary functions for Glamourers external API.</Description>
<PackageLicenseExpression>MIT</PackageLicenseExpression>
</PropertyGroup>
<PropertyGroup>
<Use_DalamudPackager>false</Use_DalamudPackager>
</PropertyGroup>
<PropertyGroup>
<NoWarn>1591</NoWarn>
</PropertyGroup>
<ItemGroup>
<None Include="README.md" Pack="true" PackagePath="\" />
</ItemGroup>
</Project>

View File

@@ -1,2 +0,0 @@
<wpf:ResourceDictionary xml:space="preserve" xmlns:x="http://schemas.microsoft.com/winfx/2006/xaml" xmlns:s="clr-namespace:System;assembly=mscorlib" xmlns:ss="urn:shemas-jetbrains-com:settings-storage-xaml" xmlns:wpf="http://schemas.microsoft.com/winfx/2006/xaml/presentation">
<s:Boolean x:Key="/Default/CodeInspection/NamespaceProvider/NamespaceFoldersToSkip/=ipc/@EntryIndexedValue">True</s:Boolean></wpf:ResourceDictionary>

View File

@@ -1,4 +0,0 @@
// Global using directives
global using System;
global using System.Collections.Generic;

View File

@@ -1,114 +0,0 @@
using Dalamud.Plugin;
using Dalamud.Plugin.Ipc;
namespace Glamourer.Api.Helpers;
/// <summary>
/// Specialized subscriber only allowing to invoke actions.
/// </summary>
public class ActionSubscriber
{
private readonly ICallGateSubscriber<object?>? _subscriber;
/// <summary> Whether the subscriber could successfully be created. </summary>
public bool Valid
=> _subscriber != null;
protected ActionSubscriber(IDalamudPluginInterface pi, string label)
{
try
{
_subscriber = pi.GetIpcSubscriber<object?>(label);
}
catch (Exception e)
{
PluginLogHelper.WriteError(pi, $"Error registering IPC Subscriber for {label}\n{e}");
_subscriber = null;
}
}
/// <summary> Invoke the action. See the source of the subscriber for details.</summary>
protected void Invoke()
=> _subscriber?.InvokeAction();
}
/// <inheritdoc cref="ActionSubscriber"/>
public class ActionSubscriber<T1>
{
private readonly ICallGateSubscriber<T1, object?>? _subscriber;
/// <summary> Whether the subscriber could successfully be created. </summary>
public bool Valid
=> _subscriber != null;
protected ActionSubscriber(IDalamudPluginInterface pi, string label)
{
try
{
_subscriber = pi.GetIpcSubscriber<T1, object?>(label);
}
catch (Exception e)
{
PluginLogHelper.WriteError(pi, $"Error registering IPC Subscriber for {label}\n{e}");
_subscriber = null;
}
}
/// <summary> Invoke the action. See the source of the subscriber for details.</summary>
protected void Invoke(T1 a)
=> _subscriber?.InvokeAction(a);
}
/// <inheritdoc cref="ActionSubscriber"/>
public class ActionSubscriber<T1, T2>
{
private readonly ICallGateSubscriber<T1, T2, object?>? _subscriber;
/// <inheritdoc cref="ActionSubscriber{T1}.Valid"/>
public bool Valid
=> _subscriber != null;
protected ActionSubscriber(IDalamudPluginInterface pi, string label)
{
try
{
_subscriber = pi.GetIpcSubscriber<T1, T2, object?>(label);
}
catch (Exception e)
{
PluginLogHelper.WriteError(pi, $"Error registering IPC Subscriber for {label}\n{e}");
_subscriber = null;
}
}
/// <inheritdoc cref="ActionSubscriber.Invoke"/>
protected void Invoke(T1 a, T2 b)
=> _subscriber?.InvokeAction(a, b);
}
/// <inheritdoc cref="ActionSubscriber"/>
public class ActionSubscriber<T1, T2, T3>
{
private readonly ICallGateSubscriber<T1, T2, T3, object?>? _subscriber;
/// <inheritdoc cref="ActionSubscriber{T1}.Valid"/>
public bool Valid
=> _subscriber != null;
protected ActionSubscriber(IDalamudPluginInterface pi, string label)
{
try
{
_subscriber = pi.GetIpcSubscriber<T1, T2, T3, object?>(label);
}
catch (Exception e)
{
PluginLogHelper.WriteError(pi, $"Error registering IPC Subscriber for {label}\n{e}");
_subscriber = null;
}
}
/// <inheritdoc cref="ActionSubscriber.Invoke"/>
protected void Invoke(T1 a, T2 b, T3 c)
=> _subscriber?.InvokeAction(a, b, c);
}

View File

@@ -1,234 +0,0 @@
using Dalamud.Plugin;
using Dalamud.Plugin.Ipc;
using Dalamud.Plugin.Services;
namespace Glamourer.Api.Helpers;
/// <summary>
/// Specialized disposable Provider for Events.<para />
/// Will execute the unsubscriber action on dispose if any is provided.<para />
/// Can only be invoked and disposed.
/// </summary>
public sealed class EventProvider : IDisposable
{
private readonly IPluginLog _log;
private ICallGateProvider<object?>? _provider;
private Delegate? _unsubscriber;
public EventProvider(IDalamudPluginInterface pi, string label, (Action<Action> Add, Action<Action> Del)? subscribe = null)
{
_unsubscriber = null;
_log = PluginLogHelper.GetLog(pi);
try
{
_provider = pi.GetIpcProvider<object?>(label);
subscribe?.Add(Invoke);
_unsubscriber = subscribe?.Del;
}
catch (Exception e)
{
_log.Error($"Error registering IPC Provider for {label}\n{e}");
_provider = null;
}
}
public EventProvider(IDalamudPluginInterface pi, string label, Action<EventProvider> add, Action<EventProvider> del)
{
_unsubscriber = null;
_log = PluginLogHelper.GetLog(pi);
try
{
_provider = pi.GetIpcProvider<object?>(label);
add(this);
_unsubscriber = del;
}
catch (Exception e)
{
_log.Error($"Error registering IPC Provider for {label}\n{e}");
_provider = null;
}
}
/// <summary> Invoke the event.</summary>
public void Invoke()
{
try
{
_provider?.SendMessage();
}
catch (Exception e)
{
_log.Error($"Exception thrown on IPC event:\n{e}");
}
}
public void Dispose()
{
switch (_unsubscriber)
{
case Action<Action> a:
a(Invoke);
break;
case Action<EventProvider> b:
b(this);
break;
}
_unsubscriber = null;
_provider = null;
GC.SuppressFinalize(this);
}
~EventProvider()
=> Dispose();
}
/// <inheritdoc cref="EventProvider"/>
public sealed class EventProvider<T1> : IDisposable
{
private readonly IPluginLog _log;
private ICallGateProvider<T1, object?>? _provider;
private Delegate? _unsubscriber;
public EventProvider(IDalamudPluginInterface pi, string label, (Action<Action<T1>> Add, Action<Action<T1>> Del)? subscribe = null)
{
_unsubscriber = null;
_log = PluginLogHelper.GetLog(pi);
try
{
_provider = pi.GetIpcProvider<T1, object?>(label);
subscribe?.Add(Invoke);
_unsubscriber = subscribe?.Del;
}
catch (Exception e)
{
_log.Error($"Error registering IPC Provider for {label}\n{e}");
_provider = null;
}
}
public EventProvider(IDalamudPluginInterface pi, string label, Action<EventProvider<T1>> add, Action<EventProvider<T1>> del)
{
_unsubscriber = null;
_log = PluginLogHelper.GetLog(pi);
try
{
_provider = pi.GetIpcProvider<T1, object?>(label);
add(this);
_unsubscriber = del;
}
catch (Exception e)
{
_log.Error($"Error registering IPC Provider for {label}\n{e}");
_provider = null;
}
}
/// <inheritdoc cref="EventProvider.Invoke"/>
public void Invoke(T1 a)
{
try
{
_provider?.SendMessage(a);
}
catch (Exception e)
{
_log.Error($"Exception thrown on IPC event:\n{e}");
}
}
public void Dispose()
{
switch (_unsubscriber)
{
case Action<Action<T1>> a:
a(Invoke);
break;
case Action<EventProvider<T1>> b:
b(this);
break;
}
_unsubscriber = null;
_provider = null;
GC.SuppressFinalize(this);
}
~EventProvider()
=> Dispose();
}
/// <inheritdoc cref="EventProvider"/>
public sealed class EventProvider<T1, T2> : IDisposable
{
private readonly IPluginLog _log;
private ICallGateProvider<T1, T2, object?>? _provider;
private Delegate? _unsubscriber;
public EventProvider(IDalamudPluginInterface pi, string label, (Action<Action<T1, T2>> Add, Action<Action<T1, T2>> Del)? subscribe = null)
{
_unsubscriber = null;
_log = PluginLogHelper.GetLog(pi);
try
{
_provider = pi.GetIpcProvider<T1, T2, object?>(label);
subscribe?.Add(Invoke);
_unsubscriber = subscribe?.Del;
}
catch (Exception e)
{
_log.Error($"Error registering IPC Provider for {label}\n{e}");
_provider = null;
}
}
public EventProvider(IDalamudPluginInterface pi, string label, Action<EventProvider<T1, T2>> add, Action<EventProvider<T1, T2>> del)
{
_unsubscriber = null;
_log = PluginLogHelper.GetLog(pi);
try
{
_provider = pi.GetIpcProvider<T1, T2, object?>(label);
add(this);
_unsubscriber = del;
}
catch (Exception e)
{
_log.Error($"Error registering IPC Provider for {label}\n{e}");
_provider = null;
}
}
/// <inheritdoc cref="EventProvider.Invoke"/>
public void Invoke(T1 a, T2 b)
{
try
{
_provider?.SendMessage(a, b);
}
catch (Exception e)
{
_log.Error($"Exception thrown on IPC event:\n{e}");
}
}
public void Dispose()
{
switch (_unsubscriber)
{
case Action<Action<T1, T2>> a:
a(Invoke);
break;
case Action<EventProvider<T1, T2>> b:
b(this);
break;
}
_unsubscriber = null;
_provider = null;
GC.SuppressFinalize(this);
}
~EventProvider()
=> Dispose();
}

View File

@@ -1,394 +0,0 @@
using Dalamud.Plugin;
using Dalamud.Plugin.Ipc;
using Dalamud.Plugin.Services;
namespace Glamourer.Api.Helpers;
/// <summary>
/// Specialized disposable Subscriber for Events.<para />
/// Subscriptions are wrapped to be individually exception-safe.<para/>
/// Can be enabled and disabled.<para/>
/// </summary>
public sealed class EventSubscriber : IDisposable
{
private readonly string _label;
private readonly IPluginLog _log;
private readonly Dictionary<Action, Action> _delegates = new();
private ICallGateSubscriber<object?>? _subscriber;
private bool _disabled;
public EventSubscriber(IDalamudPluginInterface pi, string label, params Action[] actions)
{
_label = label;
_log = PluginLogHelper.GetLog(pi);
try
{
_subscriber = pi.GetIpcSubscriber<object?>(label);
foreach (var action in actions)
Event += action;
_disabled = false;
}
catch (Exception e)
{
_log.Error($"Error registering IPC Subscriber for {label}\n{e}");
_subscriber = null;
}
}
/// <summary>
/// Enable all currently subscribed actions registered with this EventSubscriber.
/// Does nothing if it is already enabled.
/// </summary>
public void Enable()
{
if (_disabled && _subscriber != null)
{
foreach (var action in _delegates.Values)
_subscriber.Subscribe(action);
_disabled = false;
}
}
/// <summary>
/// Disable all subscribed actions registered with this EventSubscriber.
/// Does nothing if it is already disabled.
/// Does not forget the actions, only disables them.
/// </summary>
public void Disable()
{
if (!_disabled)
{
if (_subscriber != null)
foreach (var action in _delegates.Values)
_subscriber.Unsubscribe(action);
_disabled = true;
}
}
/// <summary>
/// Add or remove an action to the IPC event, if it is valid.
/// </summary>
public event Action Event
{
add
{
if (_subscriber != null && !_delegates.ContainsKey(value))
{
void Action()
{
try
{
value();
}
catch (Exception e)
{
_log.Error($"Exception invoking IPC event {_label}:\n{e}");
}
}
if (_delegates.TryAdd(value, Action) && !_disabled)
_subscriber.Subscribe(Action);
}
}
remove
{
if (_subscriber != null && _delegates.Remove(value, out var action))
_subscriber.Unsubscribe(action);
}
}
public void Dispose()
{
Disable();
_subscriber = null;
_delegates.Clear();
}
~EventSubscriber()
=> Dispose();
}
/// <summary><inheritdoc cref="EventSubscriber"/> </summary>
public sealed class EventSubscriber<T1> : IDisposable
{
private readonly string _label;
private readonly IPluginLog _log;
private readonly Dictionary<Action<T1>, Action<T1>> _delegates = new();
private ICallGateSubscriber<T1, object?>? _subscriber;
private bool _disabled;
public EventSubscriber(IDalamudPluginInterface pi, string label, params Action<T1>[] actions)
{
_label = label;
_log = PluginLogHelper.GetLog(pi);
try
{
_subscriber = pi.GetIpcSubscriber<T1, object?>(label);
foreach (var action in actions)
Event += action;
_disabled = false;
}
catch (Exception e)
{
_log.Error($"Error registering IPC Subscriber for {label}\n{e}");
_subscriber = null;
}
}
/// <summary><inheritdoc cref="EventSubscriber.Enable"/> </summary>
public void Enable()
{
if (_disabled && _subscriber != null)
{
foreach (var action in _delegates.Values)
_subscriber.Subscribe(action);
_disabled = false;
}
}
/// <summary><inheritdoc cref="EventSubscriber.Disable"/> </summary>
public void Disable()
{
if (!_disabled)
{
if (_subscriber != null)
foreach (var action in _delegates.Values)
_subscriber.Unsubscribe(action);
_disabled = true;
}
}
/// <summary><inheritdoc cref="EventSubscriber.Event"/> </summary>
public event Action<T1> Event
{
add
{
if (_subscriber != null && !_delegates.ContainsKey(value))
{
void Action(T1 a)
{
try
{
value(a);
}
catch (Exception e)
{
_log.Error($"Exception invoking IPC event {_label}:\n{e}");
}
}
if (_delegates.TryAdd(value, Action) && !_disabled)
_subscriber.Subscribe(Action);
}
}
remove
{
if (_subscriber != null && _delegates.Remove(value, out var action))
_subscriber.Unsubscribe(action);
}
}
public void Dispose()
{
Disable();
_subscriber = null;
_delegates.Clear();
}
~EventSubscriber()
=> Dispose();
}
/// <summary><inheritdoc cref="EventSubscriber"/> </summary>
public sealed class EventSubscriber<T1, T2> : IDisposable
{
private readonly string _label;
private readonly IPluginLog _log;
private readonly Dictionary<Action<T1, T2>, Action<T1, T2>> _delegates = new();
private ICallGateSubscriber<T1, T2, object?>? _subscriber;
private bool _disabled;
public EventSubscriber(IDalamudPluginInterface pi, string label, params Action<T1, T2>[] actions)
{
_label = label;
_log = PluginLogHelper.GetLog(pi);
try
{
_subscriber = pi.GetIpcSubscriber<T1, T2, object?>(label);
foreach (var action in actions)
Event += action;
_disabled = false;
}
catch (Exception e)
{
_log.Error($"Error registering IPC Subscriber for {label}\n{e}");
_subscriber = null;
}
}
/// <summary><inheritdoc cref="EventSubscriber.Enable"/> </summary>
public void Enable()
{
if (_disabled && _subscriber != null)
{
foreach (var action in _delegates.Values)
_subscriber.Subscribe(action);
_disabled = false;
}
}
/// <summary><inheritdoc cref="EventSubscriber.Disable"/> </summary>
public void Disable()
{
if (!_disabled)
{
if (_subscriber != null)
foreach (var action in _delegates.Values)
_subscriber.Unsubscribe(action);
_disabled = true;
}
}
/// <summary><inheritdoc cref="EventSubscriber.Event"/> </summary>
public event Action<T1, T2> Event
{
add
{
if (_subscriber != null && !_delegates.ContainsKey(value))
{
void Action(T1 a, T2 b)
{
try
{
value(a, b);
}
catch (Exception e)
{
_log.Error($"Exception invoking IPC event {_label}:\n{e}");
}
}
if (_delegates.TryAdd(value, Action) && !_disabled)
_subscriber.Subscribe(Action);
}
}
remove
{
if (_subscriber != null && _delegates.Remove(value, out var action))
_subscriber.Unsubscribe(action);
}
}
public void Dispose()
{
Disable();
_subscriber = null;
_delegates.Clear();
}
~EventSubscriber()
=> Dispose();
}
/// <summary><inheritdoc cref="EventSubscriber"/> </summary>
public sealed class EventSubscriber<T1, T2, T3> : IDisposable
{
private readonly string _label;
private readonly IPluginLog _log;
private readonly Dictionary<Action<T1, T2, T3>, Action<T1, T2, T3>> _delegates = new();
private ICallGateSubscriber<T1, T2, T3, object?>? _subscriber;
private bool _disabled;
public EventSubscriber(IDalamudPluginInterface pi, string label, params Action<T1, T2, T3>[] actions)
{
_label = label;
_log = PluginLogHelper.GetLog(pi);
try
{
_subscriber = pi.GetIpcSubscriber<T1, T2, T3, object?>(label);
foreach (var action in actions)
Event += action;
_disabled = false;
}
catch (Exception e)
{
_log.Error($"Error registering IPC Subscriber for {label}\n{e}");
_subscriber = null;
}
}
/// <summary><inheritdoc cref="EventSubscriber.Enable"/> </summary>
public void Enable()
{
if (_disabled && _subscriber != null)
{
foreach (var action in _delegates.Values)
_subscriber.Subscribe(action);
_disabled = false;
}
}
/// <summary><inheritdoc cref="EventSubscriber.Disable"/> </summary>
public void Disable()
{
if (!_disabled)
{
if (_subscriber != null)
foreach (var action in _delegates.Values)
_subscriber.Unsubscribe(action);
_disabled = true;
}
}
/// <summary><inheritdoc cref="EventSubscriber.Event"/> </summary>
public event Action<T1, T2, T3> Event
{
add
{
if (_subscriber != null && !_delegates.ContainsKey(value))
{
void Action(T1 a, T2 b, T3 c)
{
try
{
value(a, b, c);
}
catch (Exception e)
{
_log.Error($"Exception invoking IPC event {_label}:\n{e}");
}
}
if (_delegates.TryAdd(value, Action) && !_disabled)
_subscriber.Subscribe(Action);
}
}
remove
{
if (_subscriber != null && _delegates.Remove(value, out var action))
_subscriber.Unsubscribe(action);
}
}
public void Dispose()
{
Disable();
_subscriber = null;
_delegates.Clear();
}
~EventSubscriber()
=> Dispose();
}

View File

@@ -1,224 +0,0 @@
using Dalamud.Plugin;
using Dalamud.Plugin.Ipc;
namespace Glamourer.Api.Helpers;
/// <summary>
/// Specialized disposable Provider for Funcs.
/// </summary>
public sealed class FuncProvider<TRet> : IDisposable
{
private ICallGateProvider<TRet>? _provider;
public FuncProvider(IDalamudPluginInterface pi, string label, Func<TRet> func)
{
try
{
_provider = pi.GetIpcProvider<TRet>(label);
}
catch (Exception e)
{
PluginLogHelper.WriteError(pi, $"Error registering IPC Provider for {label}\n{e}");
_provider = null;
}
_provider?.RegisterFunc(func);
}
public void Dispose()
{
_provider?.UnregisterFunc();
_provider = null;
GC.SuppressFinalize(this);
}
~FuncProvider()
=> Dispose();
}
/// <inheritdoc cref="FuncProvider{TRet}"/>
public sealed class FuncProvider<T1, TRet> : IDisposable
{
private ICallGateProvider<T1, TRet>? _provider;
public FuncProvider(IDalamudPluginInterface pi, string label, Func<T1, TRet> func)
{
try
{
_provider = pi.GetIpcProvider<T1, TRet>(label);
}
catch (Exception e)
{
PluginLogHelper.WriteError(pi, $"Error registering IPC Provider for {label}\n{e}");
_provider = null;
}
_provider?.RegisterFunc(func);
}
public void Dispose()
{
_provider?.UnregisterFunc();
_provider = null;
GC.SuppressFinalize(this);
}
~FuncProvider()
=> Dispose();
}
/// <inheritdoc cref="FuncProvider{TRet}"/>
public sealed class FuncProvider<T1, T2, TRet> : IDisposable
{
private ICallGateProvider<T1, T2, TRet>? _provider;
public FuncProvider(IDalamudPluginInterface pi, string label, Func<T1, T2, TRet> func)
{
try
{
_provider = pi.GetIpcProvider<T1, T2, TRet>(label);
}
catch (Exception e)
{
PluginLogHelper.WriteError(pi, $"Error registering IPC Provider for {label}\n{e}");
_provider = null;
}
_provider?.RegisterFunc(func);
}
public void Dispose()
{
_provider?.UnregisterFunc();
_provider = null;
GC.SuppressFinalize(this);
}
~FuncProvider()
=> Dispose();
}
/// <inheritdoc cref="FuncProvider{TRet}"/>
public sealed class FuncProvider<T1, T2, T3, TRet> : IDisposable
{
private ICallGateProvider<T1, T2, T3, TRet>? _provider;
public FuncProvider(IDalamudPluginInterface pi, string label, Func<T1, T2, T3, TRet> func)
{
try
{
_provider = pi.GetIpcProvider<T1, T2, T3, TRet>(label);
}
catch (Exception e)
{
PluginLogHelper.WriteError(pi, $"Error registering IPC Provider for {label}\n{e}");
_provider = null;
}
_provider?.RegisterFunc(func);
}
public void Dispose()
{
_provider?.UnregisterFunc();
_provider = null;
GC.SuppressFinalize(this);
}
~FuncProvider()
=> Dispose();
}
/// <inheritdoc cref="FuncProvider{TRet}"/>
public sealed class FuncProvider<T1, T2, T3, T4, TRet> : IDisposable
{
private ICallGateProvider<T1, T2, T3, T4, TRet>? _provider;
public FuncProvider(IDalamudPluginInterface pi, string label, Func<T1, T2, T3, T4, TRet> func)
{
try
{
_provider = pi.GetIpcProvider<T1, T2, T3, T4, TRet>(label);
}
catch (Exception e)
{
PluginLogHelper.WriteError(pi, $"Error registering IPC Provider for {label}\n{e}");
_provider = null;
}
_provider?.RegisterFunc(func);
}
public void Dispose()
{
_provider?.UnregisterFunc();
_provider = null;
GC.SuppressFinalize(this);
}
~FuncProvider()
=> Dispose();
}
/// <inheritdoc cref="FuncProvider{TRet}"/>
public sealed class FuncProvider<T1, T2, T3, T4, T5, TRet> : IDisposable
{
private ICallGateProvider<T1, T2, T3, T4, T5, TRet>? _provider;
public FuncProvider(IDalamudPluginInterface pi, string label, Func<T1, T2, T3, T4, T5, TRet> func)
{
try
{
_provider = pi.GetIpcProvider<T1, T2, T3, T4, T5, TRet>(label);
}
catch (Exception e)
{
PluginLogHelper.WriteError(pi, $"Error registering IPC Provider for {label}\n{e}");
_provider = null;
}
_provider?.RegisterFunc(func);
}
public void Dispose()
{
_provider?.UnregisterFunc();
_provider = null;
GC.SuppressFinalize(this);
}
~FuncProvider()
=> Dispose();
}
/// <inheritdoc cref="FuncProvider{TRet}"/>
public sealed class FuncProvider<T1, T2, T3, T4, T5, T6, TRet> : IDisposable
{
private ICallGateProvider<T1, T2, T3, T4, T5, T6, TRet>? _provider;
public FuncProvider(IDalamudPluginInterface pi, string label, Func<T1, T2, T3, T4, T5, T6, TRet> func)
{
try
{
_provider = pi.GetIpcProvider<T1, T2, T3, T4, T5, T6, TRet>(label);
}
catch (Exception e)
{
PluginLogHelper.WriteError(pi, $"Error registering IPC Provider for {label}\n{e}");
_provider = null;
}
_provider?.RegisterFunc(func);
}
public void Dispose()
{
_provider?.UnregisterFunc();
_provider = null;
GC.SuppressFinalize(this);
}
~FuncProvider()
=> Dispose();
}

View File

@@ -1,217 +0,0 @@
using Dalamud.Plugin;
using Dalamud.Plugin.Ipc;
using Dalamud.Plugin.Ipc.Exceptions;
namespace Glamourer.Api.Helpers;
/// <summary>
/// Specialized subscriber only allowing to invoke functions with a return.
/// </summary>
public class FuncSubscriber<TRet>
{
private readonly string _label;
private readonly ICallGateSubscriber<TRet>? _subscriber;
/// <summary> Whether the subscriber could successfully be created. </summary>
public bool Valid
=> _subscriber != null;
/// <inheritdoc cref="FuncSubscriber{TRet}"/>
protected FuncSubscriber(IDalamudPluginInterface pi, string label)
{
_label = label;
try
{
_subscriber = pi.GetIpcSubscriber<TRet>(label);
}
catch (Exception e)
{
PluginLogHelper.WriteError(pi, $"Error registering IPC Subscriber for {label}\n{e}");
_subscriber = null;
}
}
/// <summary> Invoke the function. See the source of the subscriber for details.</summary>
protected TRet Invoke()
=> _subscriber != null ? _subscriber.InvokeFunc() : throw new IpcNotReadyError(_label);
}
/// <inheritdoc cref="FuncSubscriber{TRet}"/>
public class FuncSubscriber<T1, TRet>
{
private readonly string _label;
private readonly ICallGateSubscriber<T1, TRet>? _subscriber;
/// <inheritdoc cref="FuncSubscriber{TRet}.Valid"/>
public bool Valid
=> _subscriber != null;
/// <inheritdoc cref="FuncSubscriber{TRet}"/>
protected FuncSubscriber(IDalamudPluginInterface pi, string label)
{
_label = label;
try
{
_subscriber = pi.GetIpcSubscriber<T1, TRet>(label);
}
catch (Exception e)
{
PluginLogHelper.WriteError(pi, $"Error registering IPC Subscriber for {label}\n{e}");
_subscriber = null;
}
}
/// <inheritdoc cref="FuncSubscriber{TRet}.Invoke"/>
protected TRet Invoke(T1 a)
=> _subscriber != null ? _subscriber.InvokeFunc(a) : throw new IpcNotReadyError(_label);
}
/// <inheritdoc cref="FuncSubscriber{TRet}"/>
public class FuncSubscriber<T1, T2, TRet>
{
private readonly string _label;
private readonly ICallGateSubscriber<T1, T2, TRet>? _subscriber;
/// <inheritdoc cref="FuncSubscriber{TRet}.Valid"/>
public bool Valid
=> _subscriber != null;
/// <inheritdoc cref="FuncSubscriber{TRet}"/>
protected FuncSubscriber(IDalamudPluginInterface pi, string label)
{
_label = label;
try
{
_subscriber = pi.GetIpcSubscriber<T1, T2, TRet>(label);
}
catch (Exception e)
{
PluginLogHelper.WriteError(pi, $"Error registering IPC Subscriber for {label}\n{e}");
_subscriber = null;
}
}
/// <inheritdoc cref="FuncSubscriber{TRet}.Invoke"/>
protected TRet Invoke(T1 a, T2 b)
=> _subscriber != null ? _subscriber.InvokeFunc(a, b) : throw new IpcNotReadyError(_label);
}
/// <inheritdoc cref="FuncSubscriber{TRet}"/>
public class FuncSubscriber<T1, T2, T3, TRet>
{
private readonly string _label;
private readonly ICallGateSubscriber<T1, T2, T3, TRet>? _subscriber;
/// <inheritdoc cref="FuncSubscriber{TRet}.Valid"/>
public bool Valid
=> _subscriber != null;
/// <inheritdoc cref="FuncSubscriber{TRet}"/>
protected FuncSubscriber(IDalamudPluginInterface pi, string label)
{
_label = label;
try
{
_subscriber = pi.GetIpcSubscriber<T1, T2, T3, TRet>(label);
}
catch (Exception e)
{
PluginLogHelper.WriteError(pi, $"Error registering IPC Subscriber for {label}\n{e}");
_subscriber = null;
}
}
/// <inheritdoc cref="FuncSubscriber{TRet}.Invoke"/>
protected TRet Invoke(T1 a, T2 b, T3 c)
=> _subscriber != null ? _subscriber.InvokeFunc(a, b, c) : throw new IpcNotReadyError(_label);
}
/// <inheritdoc cref="FuncSubscriber{TRet}"/>
public class FuncSubscriber<T1, T2, T3, T4, TRet>
{
private readonly string _label;
private readonly ICallGateSubscriber<T1, T2, T3, T4, TRet>? _subscriber;
/// <inheritdoc cref="FuncSubscriber{TRet}.Valid"/>
public bool Valid
=> _subscriber != null;
/// <inheritdoc cref="FuncSubscriber{TRet}"/>
protected FuncSubscriber(IDalamudPluginInterface pi, string label)
{
_label = label;
try
{
_subscriber = pi.GetIpcSubscriber<T1, T2, T3, T4, TRet>(label);
}
catch (Exception e)
{
PluginLogHelper.WriteError(pi, $"Error registering IPC Subscriber for {label}\n{e}");
_subscriber = null;
}
}
/// <inheritdoc cref="FuncSubscriber{TRet}.Invoke"/>
protected TRet Invoke(T1 a, T2 b, T3 c, T4 d)
=> _subscriber != null ? _subscriber.InvokeFunc(a, b, c, d) : throw new IpcNotReadyError(_label);
}
/// <inheritdoc cref="FuncSubscriber{TRet}"/>
public class FuncSubscriber<T1, T2, T3, T4, T5, TRet>
{
private readonly string _label;
private readonly ICallGateSubscriber<T1, T2, T3, T4, T5, TRet>? _subscriber;
/// <inheritdoc cref="FuncSubscriber{TRet}.Valid"/>
public bool Valid
=> _subscriber != null;
/// <inheritdoc cref="FuncSubscriber{TRet}"/>
protected FuncSubscriber(IDalamudPluginInterface pi, string label)
{
_label = label;
try
{
_subscriber = pi.GetIpcSubscriber<T1, T2, T3, T4, T5, TRet>(label);
}
catch (Exception e)
{
PluginLogHelper.WriteError(pi, $"Error registering IPC Subscriber for {label}\n{e}");
_subscriber = null;
}
}
/// <inheritdoc cref="FuncSubscriber{TRet}.Invoke"/>
protected TRet Invoke(T1 a, T2 b, T3 c, T4 d, T5 e)
=> _subscriber != null ? _subscriber.InvokeFunc(a, b, c, d, e) : throw new IpcNotReadyError(_label);
}
/// <inheritdoc cref="FuncSubscriber{TRet}"/>
public class FuncSubscriber<T1, T2, T3, T4, T5, T6, TRet>
{
private readonly string _label;
private readonly ICallGateSubscriber<T1, T2, T3, T4, T5, T6, TRet>? _subscriber;
/// <inheritdoc cref="FuncSubscriber{TRet}.Valid"/>
public bool Valid
=> _subscriber != null;
/// <inheritdoc cref="FuncSubscriber{TRet}"/>
protected FuncSubscriber(IDalamudPluginInterface pi, string label)
{
_label = label;
try
{
_subscriber = pi.GetIpcSubscriber<T1, T2, T3, T4, T5, T6, TRet>(label);
}
catch (Exception e)
{
PluginLogHelper.WriteError(pi, $"Error registering IPC Subscriber for {label}\n{e}");
_subscriber = null;
}
}
/// <inheritdoc cref="FuncSubscriber{TRet}.Invoke"/>
protected TRet Invoke(T1 a, T2 b, T3 c, T4 d, T5 e, T6 f)
=> _subscriber != null ? _subscriber.InvokeFunc(a, b, c, d, e, f) : throw new IpcNotReadyError(_label);
}

View File

@@ -1,26 +0,0 @@
using Dalamud.IoC;
using Dalamud.Plugin;
using Dalamud.Plugin.Services;
namespace Glamourer.Api.Helpers;
internal class PluginLogHelper
{
[PluginService]
private static IPluginLog? _log { get; set; }
private PluginLogHelper(IDalamudPluginInterface pi)
=> pi.Inject(this);
public static void WriteError(IDalamudPluginInterface pi, string errorMessage)
=> GetLog(pi).Error(errorMessage);
public static IPluginLog GetLog(IDalamudPluginInterface pi)
{
if (_log != null)
return _log;
_ = new PluginLogHelper(pi);
return _log!;
}
}

View File

@@ -1,52 +0,0 @@
using Dalamud.Plugin;
using Glamourer.Api.Api;
using Glamourer.Api.Enums;
using Glamourer.Api.Helpers;
namespace Glamourer.Api.IpcSubscribers;
/// <inheritdoc cref="IGlamourerApiDesigns.GetDesignList"/>
public sealed class GetDesignList(IDalamudPluginInterface pi)
: FuncSubscriber<Dictionary<Guid, string>>(pi, Label)
{
/// <summary> The label. </summary>
public const string Label = $"Glamourer.{nameof(GetDesignList)}.V2";
/// <inheritdoc cref="IGlamourerApiDesigns.GetDesignList"/>
public new Dictionary<Guid, string> Invoke()
=> base.Invoke();
/// <summary> Create a provider. </summary>
public static FuncProvider<Dictionary<Guid, string>> Provider(IDalamudPluginInterface pi, IGlamourerApiDesigns api)
=> new(pi, Label, api.GetDesignList);
}
/// <inheritdoc cref="IGlamourerApiDesigns.ApplyDesign"/>
public sealed class ApplyDesign(IDalamudPluginInterface pi) : FuncSubscriber<Guid, int, uint, ulong, int>(pi, Label)
{
/// <summary> The label. </summary>
public const string Label = $"Glamourer.{nameof(ApplyDesign)}";
/// <inheritdoc cref="IGlamourerApiDesigns.ApplyDesign"/>
public GlamourerApiEc Invoke(Guid designId, int objectIndex, uint key = 0, ApplyFlag flags = ApplyFlagEx.DesignDefault)
=> (GlamourerApiEc)Invoke(designId, objectIndex, key, (ulong)flags);
/// <summary> Create a provider. </summary>
public static FuncProvider<Guid, int, uint, ulong, int> Provider(IDalamudPluginInterface pi, IGlamourerApiDesigns api)
=> new(pi, Label, (a, b, c, d) => (int)api.ApplyDesign(a, b, c, (ApplyFlag)d));
}
/// <inheritdoc cref="IGlamourerApiDesigns.ApplyDesignName"/>
public sealed class ApplyDesignName(IDalamudPluginInterface pi) : FuncSubscriber<Guid, string, uint, ulong, int>(pi, Label)
{
/// <summary> The label. </summary>
public const string Label = $"Glamourer.{nameof(ApplyDesignName)}";
/// <inheritdoc cref="IGlamourerApiDesigns.ApplyDesignName"/>
public GlamourerApiEc Invoke(Guid designId, string objectName, uint key = 0, ApplyFlag flags = ApplyFlagEx.DesignDefault)
=> (GlamourerApiEc)Invoke(designId, objectName, key, (ulong)flags);
/// <summary> Create a provider. </summary>
public static FuncProvider<Guid, string, uint, ulong, int> Provider(IDalamudPluginInterface pi, IGlamourerApiDesigns api)
=> new(pi, Label, (a, b, c, d) => (int)api.ApplyDesignName(a, b, c, (ApplyFlag)d));
}

View File

@@ -1,110 +0,0 @@
using Dalamud.Plugin;
using Glamourer.Api.Api;
using Glamourer.Api.Enums;
using Glamourer.Api.Helpers;
namespace Glamourer.Api.IpcSubscribers;
/// <inheritdoc cref="IGlamourerApiItems.SetItem"/>
public sealed class SetItem(IDalamudPluginInterface pi)
: FuncSubscriber<int, byte, ulong, IReadOnlyList<byte>, uint, ulong, int>(pi, Label)
{
/// <summary> The label. </summary>
public const string Label = $"Glamourer.{nameof(SetItem)}.V3";
/// <inheritdoc cref="IGlamourerApiItems.SetItem"/>
public GlamourerApiEc Invoke(int objectIndex, ApiEquipSlot slot, ulong itemId, IReadOnlyList<byte> stain, uint key = 0,
ApplyFlag flags = ApplyFlag.Once)
=> (GlamourerApiEc)Invoke(objectIndex, (byte)slot, itemId, stain, key, (ulong)flags);
/// <summary> Create a provider. </summary>
public static FuncProvider<int, byte, ulong, IReadOnlyList<byte>, uint, ulong, int> Provider(IDalamudPluginInterface pi,
IGlamourerApiItems api)
=> new(pi, Label, (a, b, c, d, e, f) => (int)api.SetItem(a, (ApiEquipSlot)b, c, d, e, (ApplyFlag)f));
}
/// <inheritdoc cref="IGlamourerApiItems.SetItemName"/>
public sealed class SetItemName(IDalamudPluginInterface pi)
: FuncSubscriber<string, byte, ulong, IReadOnlyList<byte>, uint, ulong, int>(pi, Label)
{
/// <summary> The label. </summary>
public const string Label = $"Glamourer.{nameof(SetItemName)}.V2";
/// <inheritdoc cref="IGlamourerApiItems.SetItem"/>
public GlamourerApiEc Invoke(string objectName, ApiEquipSlot slot, ulong itemId, IReadOnlyList<byte> stain, uint key = 0,
ApplyFlag flags = ApplyFlag.Once)
=> (GlamourerApiEc)Invoke(objectName, (byte)slot, itemId, stain, key, (ulong)flags);
/// <summary> Create a provider. </summary>
public static FuncProvider<string, byte, ulong, IReadOnlyList<byte>, uint, ulong, int> Provider(IDalamudPluginInterface pi,
IGlamourerApiItems api)
=> new(pi, Label, (a, b, c, d, e, f) => (int)api.SetItemName(a, (ApiEquipSlot)b, c, d, e, (ApplyFlag)f));
}
/// <inheritdoc cref="IGlamourerApiItems.SetBonusItem"/>
public sealed class SetBonusItem(IDalamudPluginInterface pi)
: FuncSubscriber<int, byte, ulong, uint, ulong, int>(pi, Label)
{
/// <summary> The label. </summary>
public const string Label = $"Glamourer.{nameof(SetBonusItem)}";
/// <inheritdoc cref="IGlamourerApiItems.SetBonusItem"/>
public GlamourerApiEc Invoke(int objectIndex, ApiBonusSlot slot, ulong itemId, uint key = 0, ApplyFlag flags = ApplyFlag.Once)
=> (GlamourerApiEc)Invoke(objectIndex, (byte)slot, itemId, key, (ulong)flags);
/// <summary> Create a provider. </summary>
public static FuncProvider<int, byte, ulong, uint, ulong, int> Provider(IDalamudPluginInterface pi, IGlamourerApiItems api)
=> new(pi, Label, (a, b, c, d, e) => (int)api.SetBonusItem(a, (ApiBonusSlot)b, c, d, (ApplyFlag)e));
}
/// <inheritdoc cref="IGlamourerApiItems.SetBonusItemName"/>
public sealed class SetBonusItemName(IDalamudPluginInterface pi)
: FuncSubscriber<string, byte, ulong, uint, ulong, int>(pi, Label)
{
/// <summary> The label. </summary>
public const string Label = $"Glamourer.{nameof(SetBonusItemName)}.V2";
/// <inheritdoc cref="IGlamourerApiItems.SetBonusItemName"/>
public GlamourerApiEc Invoke(string objectName, ApiBonusSlot slot, ulong itemId, uint key = 0, ApplyFlag flags = ApplyFlag.Once)
=> (GlamourerApiEc)Invoke(objectName, (byte)slot, itemId, key, (ulong)flags);
/// <summary> Create a provider. </summary>
public static FuncProvider<string, byte, ulong, uint, ulong, int> Provider(IDalamudPluginInterface pi, IGlamourerApiItems api)
=> new(pi, Label, (a, b, c, d, e) => (int)api.SetBonusItemName(a, (ApiBonusSlot)b, c, d, (ApplyFlag)e));
}
/// <inheritdoc cref="IGlamourerApiItems.SetMetaState"/>
public sealed class SetMetaState(IDalamudPluginInterface pi)
: FuncSubscriber<int, ulong, bool, uint, ulong, int>(pi, Label)
{
/// <summary> The label. </summary>
public const string Label = $"Glamourer.{nameof(SetMetaState)}";
/// <inheritdoc cref="IGlamourerApiItems.SetMetaState"/>
public GlamourerApiEc Invoke(int objectIndex, MetaFlag types, bool newValue, uint key = 0,
ApplyFlag flags = ApplyFlag.Once)
=> (GlamourerApiEc)Invoke(objectIndex, (ulong)types, newValue, key, (ulong)flags);
/// <summary> Create a provider. </summary>
public static FuncProvider<int, ulong, bool, uint, ulong, int> Provider(IDalamudPluginInterface pi,
IGlamourerApiItems api)
=> new(pi, Label, (a, b, c, d, e) => (int)api.SetMetaState(a, (MetaFlag)b, c, d, (ApplyFlag)e));
}
/// <inheritdoc cref="IGlamourerApiItems.SetMetaStateName"/>
public sealed class SetMetaStateName(IDalamudPluginInterface pi)
: FuncSubscriber<string, ulong, bool, uint, ulong, int>(pi, Label)
{
/// <summary> The label. </summary>
public const string Label = $"Glamourer.{nameof(SetMetaStateName)}";
/// <inheritdoc cref="IGlamourerApiItems.SetMetaStateName"/>
public GlamourerApiEc Invoke(string objectName, MetaFlag types, bool newValue, uint key = 0,
ApplyFlag flags = ApplyFlag.Once)
=> (GlamourerApiEc)Invoke(objectName, (ulong)types, newValue, key, (ulong)flags);
/// <summary> Create a provider. </summary>
public static FuncProvider<string, ulong, bool, uint, ulong, int> Provider(IDalamudPluginInterface pi,
IGlamourerApiItems api)
=> new(pi, Label, (a, b, c, d, e) => (int)api.SetMetaStateName(a, (MetaFlag)b, c, d, (ApplyFlag)e));
}

View File

@@ -1,52 +0,0 @@
using Dalamud.Game.ClientState.Objects.Types;
using Dalamud.Plugin;
using Glamourer.Api.Helpers;
#pragma warning disable CS1591 // Missing XML comment for publicly visible type or member
namespace Glamourer.Api.IpcSubscribers.Legacy;
public sealed class GetDesignList(IDalamudPluginInterface pi)
: FuncSubscriber<(string Name, Guid Identifier)[]>(pi, Label)
{
public const string Label = $"Glamourer.{nameof(GetDesignList)}";
public new (string Name, Guid Identifier)[] Invoke()
=> base.Invoke();
}
public sealed class ApplyByGuid(IDalamudPluginInterface pi)
: ActionSubscriber<Guid, string>(pi, Label)
{
public const string Label = $"Glamourer.{nameof(ApplyByGuid)}";
public new void Invoke(Guid design, string name)
=> base.Invoke(design, name);
}
public sealed class ApplyByGuidOnce(IDalamudPluginInterface pi)
: ActionSubscriber<Guid, string>(pi, Label)
{
public const string Label = $"Glamourer.{nameof(ApplyByGuidOnce)}";
public new void Invoke(Guid design, string name)
=> base.Invoke(design, name);
}
public sealed class ApplyByGuidToCharacter(IDalamudPluginInterface pi)
: ActionSubscriber<Guid, ICharacter?>(pi, Label)
{
public const string Label = $"Glamourer.{nameof(ApplyByGuidToCharacter)}";
public new void Invoke(Guid design, ICharacter? character)
=> base.Invoke(design, character);
}
public sealed class ApplyByGuidOnceToCharacter(IDalamudPluginInterface pi)
: ActionSubscriber<Guid, ICharacter?>(pi, Label)
{
public const string Label = $"Glamourer.{nameof(ApplyByGuidOnceToCharacter)}";
public new void Invoke(Guid design, ICharacter? character)
=> base.Invoke(design, character);
}

View File

@@ -1,66 +0,0 @@
using Dalamud.Game.ClientState.Objects.Types;
using Dalamud.Plugin;
using Glamourer.Api.Api;
using Glamourer.Api.Enums;
using Glamourer.Api.Helpers;
#pragma warning disable CS1591 // Missing XML comment for publicly visible type or member
namespace Glamourer.Api.IpcSubscribers.Legacy;
public sealed class SetItem(IDalamudPluginInterface pi)
: FuncSubscriber<ICharacter?, byte, ulong, byte, uint, int>(pi, Label)
{
public const string Label = $"Glamourer.{nameof(SetItem)}";
public new GlamourerApiEc Invoke(ICharacter? character, byte slot, ulong itemId, byte stainId, uint key)
=> (GlamourerApiEc)base.Invoke(character, slot, itemId, stainId, key);
}
public sealed class SetItemOnce(IDalamudPluginInterface pi)
: FuncSubscriber<ICharacter?, byte, ulong, byte, uint, int>(pi, Label)
{
public const string Label = $"Glamourer.{nameof(SetItemOnce)}";
public new GlamourerApiEc Invoke(ICharacter? character, byte slot, ulong itemId, byte stainId, uint key)
=> (GlamourerApiEc)base.Invoke(character, slot, itemId, stainId, key);
}
public sealed class SetItemByActorName(IDalamudPluginInterface pi)
: FuncSubscriber<string, byte, ulong, byte, uint, int>(pi, Label)
{
public const string Label = $"Glamourer.{nameof(SetItemByActorName)}";
public new GlamourerApiEc Invoke(string actorName, byte slot, ulong itemId, byte stainId, uint key)
=> (GlamourerApiEc)base.Invoke(actorName, slot, itemId, stainId, key);
}
public sealed class SetItemOnceByActorName(IDalamudPluginInterface pi)
: FuncSubscriber<string, byte, ulong, byte, uint, int>(pi, Label)
{
public const string Label = $"Glamourer.{nameof(SetItemOnceByActorName)}";
public new GlamourerApiEc Invoke(string actorName, byte slot, ulong itemId, byte stainId, uint key)
=> (GlamourerApiEc)base.Invoke(actorName, slot, itemId, stainId, key);
}
public sealed class SetItemV2(IDalamudPluginInterface pi)
: FuncSubscriber<int, byte, ulong, byte, uint, ulong, int>(pi, Label)
{
public const string Label = $"Glamourer.{nameof(SetItem)}.V2";
public GlamourerApiEc Invoke(int objectIndex, ApiEquipSlot slot, ulong itemId, byte stain, uint key = 0, ApplyFlag flags = ApplyFlag.Once)
=> (GlamourerApiEc)Invoke(objectIndex, (byte)slot, itemId, stain, key, (ulong)flags);
}
public sealed class SetItemName(IDalamudPluginInterface pi)
: FuncSubscriber<string, byte, ulong, byte, uint, ulong, int>(pi, Label)
{
public const string Label = $"Glamourer.{nameof(SetItemName)}";
public GlamourerApiEc Invoke(string objectName, ApiEquipSlot slot, ulong itemId, byte stain, uint key = 0, ApplyFlag flags = ApplyFlag.Once)
=> (GlamourerApiEc)Invoke(objectName, (byte)slot, itemId, stain, key, (ulong)flags);
public static FuncProvider<string, byte, ulong, byte, uint, ulong, int> Provider(IDalamudPluginInterface pi, IGlamourerApiItems api)
=> new(pi, Label, (a, b, c, d, e, f) => (int)api.SetItemName(a, (ApiEquipSlot)b, c, [d], e, (ApplyFlag)f));
}

View File

@@ -1,15 +0,0 @@
using Dalamud.Plugin;
using Glamourer.Api.Helpers;
#pragma warning disable CS1591 // Missing XML comment for publicly visible type or member
namespace Glamourer.Api.IpcSubscribers.Legacy;
public sealed class ApiVersions(IDalamudPluginInterface pi)
: FuncSubscriber<(int, int)>(pi, Label)
{
public const string Label = $"Glamourer.{nameof(ApiVersions)}";
public new (int Major, int Minor) Invoke()
=> base.Invoke();
}

View File

@@ -1,250 +0,0 @@
using Dalamud.Game.ClientState.Objects.Types;
using Dalamud.Plugin;
using Glamourer.Api.Helpers;
#pragma warning disable CS1591 // Missing XML comment for publicly visible type or member
namespace Glamourer.Api.IpcSubscribers.Legacy;
public sealed class Revert(IDalamudPluginInterface pi)
: ActionSubscriber<string>(pi, Label)
{
public const string Label = $"Glamourer.{nameof(Revert)}";
public new void Invoke(string characterName)
=> base.Invoke(characterName);
}
public sealed class RevertCharacter(IDalamudPluginInterface pi)
: ActionSubscriber<ICharacter?>(pi, Label)
{
public const string Label = $"Glamourer.{nameof(RevertCharacter)}";
public new void Invoke(ICharacter? character)
=> base.Invoke(character);
}
public sealed class RevertLock(IDalamudPluginInterface pi)
: ActionSubscriber<string, uint>(pi, Label)
{
public const string Label = $"Glamourer.{nameof(RevertLock)}";
public new void Invoke(string characterName, uint key)
=> base.Invoke(characterName, key);
}
public sealed class RevertCharacterLock(IDalamudPluginInterface pi)
: ActionSubscriber<ICharacter?, uint>(pi, Label)
{
public const string Label = $"Glamourer.{nameof(RevertCharacterLock)}";
public new void Invoke(ICharacter? character, uint key)
=> base.Invoke(character, key);
}
public sealed class RevertToAutomation(IDalamudPluginInterface pi)
: FuncSubscriber<string, uint, bool>(pi, Label)
{
public const string Label = $"Glamourer.{nameof(RevertToAutomation)}";
public new bool Invoke(string characterName, uint key)
=> base.Invoke(characterName, key);
}
public sealed class RevertToAutomationCharacter(IDalamudPluginInterface pi)
: FuncSubscriber<ICharacter?, uint, bool>(pi, Label)
{
public const string Label = $"Glamourer.{nameof(RevertToAutomationCharacter)}";
public new bool Invoke(ICharacter? character, uint key)
=> base.Invoke(character, key);
}
public sealed class Unlock(IDalamudPluginInterface pi)
: FuncSubscriber<ICharacter?, uint, bool>(pi, Label)
{
public const string Label = $"Glamourer.{nameof(Unlock)}";
public new bool Invoke(ICharacter? character, uint key)
=> base.Invoke(character, key);
}
public sealed class UnlockName(IDalamudPluginInterface pi)
: FuncSubscriber<string, uint, bool>(pi, Label)
{
public const string Label = $"Glamourer.{nameof(UnlockName)}";
public new bool Invoke(string characterName, uint key)
=> base.Invoke(characterName, key);
}
public static class StateChanged
{
public const string Label = $"Penumbra.{nameof(StateChanged)}";
public static EventSubscriber<int, nint, Lazy<string>> Subscriber(IDalamudPluginInterface pi,
params Action<int, nint, Lazy<string>>[] actions)
=> new(pi, Label, actions);
}
public sealed class GetAllCustomization(IDalamudPluginInterface pi)
: FuncSubscriber<string, string?>(pi, Label)
{
public const string Label = $"Glamourer.{nameof(GetAllCustomization)}";
public new string? Invoke(string characterName)
=> base.Invoke(characterName);
}
public sealed class GetAllCustomizationFromCharacter(IDalamudPluginInterface pi)
: FuncSubscriber<ICharacter?, string?>(pi, Label)
{
public const string Label = $"Glamourer.{nameof(GetAllCustomizationFromCharacter)}";
public new string? Invoke(ICharacter? character)
=> base.Invoke(character);
}
public sealed class GetAllCustomizationLocked(IDalamudPluginInterface pi)
: FuncSubscriber<string, uint, string?>(pi, Label)
{
public const string Label = $"Glamourer.{nameof(GetAllCustomizationLocked)}";
public new string? Invoke(string characterName, uint key)
=> base.Invoke(characterName, key);
}
public sealed class GetAllCustomizationFromLockedCharacter(IDalamudPluginInterface pi)
: FuncSubscriber<ICharacter?, uint, string?>(pi, Label)
{
public const string Label = $"Glamourer.{nameof(GetAllCustomizationFromLockedCharacter)}";
public new string? Invoke(ICharacter? character, uint key)
=> base.Invoke(character, key);
}
public sealed class ApplyAll(IDalamudPluginInterface pi)
: ActionSubscriber<string, string>(pi, Label)
{
public const string Label = $"Glamourer.{nameof(ApplyAll)}";
public new void Invoke(string characterName, string stateBase64)
=> base.Invoke(characterName, stateBase64);
}
public sealed class ApplyAllOnce(IDalamudPluginInterface pi)
: ActionSubscriber<string, string>(pi, Label)
{
public const string Label = $"Glamourer.{nameof(ApplyAllOnce)}";
public new void Invoke(string characterName, string stateBase64)
=> base.Invoke(characterName, stateBase64);
}
public sealed class ApplyAllToCharacter(IDalamudPluginInterface pi)
: ActionSubscriber<ICharacter?, string>(pi, Label)
{
public const string Label = $"Glamourer.{nameof(ApplyAllToCharacter)}";
public new void Invoke(ICharacter? character, string stateBase64)
=> base.Invoke(character, stateBase64);
}
public sealed class ApplyAllOnceToCharacter(IDalamudPluginInterface pi)
: ActionSubscriber<ICharacter?, string>(pi, Label)
{
public const string Label = $"Glamourer.{nameof(ApplyAllOnceToCharacter)}";
public new void Invoke(ICharacter? character, string stateBase64)
=> base.Invoke(character, stateBase64);
}
public sealed class ApplyOnlyEquipment(IDalamudPluginInterface pi)
: ActionSubscriber<string, string>(pi, Label)
{
public const string Label = $"Glamourer.{nameof(ApplyOnlyEquipment)}";
public new void Invoke(string characterName, string stateBase64)
=> base.Invoke(characterName, stateBase64);
}
public sealed class ApplyOnlyEquipmentToCharacter(IDalamudPluginInterface pi)
: ActionSubscriber<ICharacter?, string>(pi, Label)
{
public const string Label = $"Glamourer.{nameof(ApplyOnlyEquipmentToCharacter)}";
public new void Invoke(ICharacter? character, string stateBase64)
=> base.Invoke(character, stateBase64);
}
public sealed class ApplyOnlyCustomization(IDalamudPluginInterface pi)
: ActionSubscriber<string, string>(pi, Label)
{
public const string Label = $"Glamourer.{nameof(ApplyOnlyCustomization)}";
public new void Invoke(string characterName, string stateBase64)
=> base.Invoke(characterName, stateBase64);
}
public sealed class ApplyOnlyCustomizationToCharacter(IDalamudPluginInterface pi)
: ActionSubscriber<ICharacter?, string>(pi, Label)
{
public const string Label = $"Glamourer.{nameof(ApplyOnlyCustomizationToCharacter)}";
public new void Invoke(ICharacter? character, string stateBase64)
=> base.Invoke(character, stateBase64);
}
public sealed class ApplyAllLock(IDalamudPluginInterface pi)
: ActionSubscriber<string, string, uint>(pi, Label)
{
public const string Label = $"Glamourer.{nameof(ApplyAllLock)}";
public new void Invoke(string characterName, string stateBase64, uint key)
=> base.Invoke(characterName, stateBase64, key);
}
public sealed class ApplyAllToCharacterLock(IDalamudPluginInterface pi)
: ActionSubscriber<ICharacter?, string, uint>(pi, Label)
{
public const string Label = $"Glamourer.{nameof(ApplyAllToCharacterLock)}";
public new void Invoke(ICharacter? character, string stateBase64, uint key)
=> base.Invoke(character, stateBase64, key);
}
public sealed class ApplyOnlyEquipmentLock(IDalamudPluginInterface pi)
: ActionSubscriber<string, string, uint>(pi, Label)
{
public const string Label = $"Glamourer.{nameof(ApplyOnlyEquipmentLock)}";
public new void Invoke(string characterName, string stateBase64, uint key)
=> base.Invoke(characterName, stateBase64, key);
}
public sealed class ApplyOnlyEquipmentToCharacterLock(IDalamudPluginInterface pi)
: ActionSubscriber<ICharacter?, string, uint>(pi, Label)
{
public const string Label = $"Glamourer.{nameof(ApplyOnlyEquipmentToCharacterLock)}";
public new void Invoke(ICharacter? character, string stateBase64, uint key)
=> base.Invoke(character, stateBase64, key);
}
public sealed class ApplyOnlyCustomizationLock(IDalamudPluginInterface pi)
: ActionSubscriber<string, string, uint>(pi, Label)
{
public const string Label = $"Glamourer.{nameof(ApplyOnlyCustomizationLock)}";
public new void Invoke(string characterName, string stateBase64, uint key)
=> base.Invoke(characterName, stateBase64, key);
}
public sealed class ApplyOnlyCustomizationToCharacterLock(IDalamudPluginInterface pi)
: ActionSubscriber<ICharacter?, string, uint>(pi, Label)
{
public const string Label = $"Glamourer.{nameof(ApplyOnlyCustomizationToCharacterLock)}";
public new void Invoke(ICharacter? character, string stateBase64, uint key)
=> base.Invoke(character, stateBase64, key);
}

View File

@@ -1,51 +0,0 @@
using Dalamud.Plugin;
using Glamourer.Api.Api;
using Glamourer.Api.Helpers;
namespace Glamourer.Api.IpcSubscribers;
/// <inheritdoc cref="IGlamourerApiBase.ApiVersion"/>
public sealed class ApiVersion(IDalamudPluginInterface pi)
: FuncSubscriber<(int, int)>(pi, Label)
{
/// <summary> The label. </summary>
public const string Label = $"Glamourer.{nameof(ApiVersion)}.V2";
/// <inheritdoc cref="IGlamourerApiBase.ApiVersion"/>
public new (int Major, int Minor) Invoke()
=> base.Invoke();
/// <summary> Create a provider. </summary>
public static FuncProvider<(int, int)> Provider(IDalamudPluginInterface pi, IGlamourerApiBase api)
=> new(pi, Label, () => api.ApiVersion);
}
/// <summary> Triggered when the Glamourer API is initialized and ready. </summary>
public static class Initialized
{
/// <summary> The label. </summary>
public const string Label = $"Glamourer.{nameof(Initialized)}";
/// <summary> Create a new event subscriber. </summary>
public static EventSubscriber Subscriber(IDalamudPluginInterface pi, params Action[] actions)
=> new(pi, Label, actions);
/// <summary> Create a provider. </summary>
public static EventProvider Provider(IDalamudPluginInterface pi)
=> new(pi, Label);
}
/// <summary> Triggered when the Glamourer API is fully disposed and unavailable. </summary>
public static class Disposed
{
/// <summary> The label. </summary>
public const string Label = $"Glamourer.{nameof(Disposed)}";
/// <summary> Create a new event subscriber. </summary>
public static EventSubscriber Subscriber(IDalamudPluginInterface pi, params Action[] actions)
=> new(pi, Label, actions);
/// <summary> Create a provider. </summary>
public static EventProvider Provider(IDalamudPluginInterface pi)
=> new(pi, Label);
}

View File

@@ -1,311 +0,0 @@
using Dalamud.Plugin;
using Glamourer.Api.Api;
using Glamourer.Api.Enums;
using Glamourer.Api.Helpers;
using Newtonsoft.Json.Linq;
namespace Glamourer.Api.IpcSubscribers;
/// <inheritdoc cref="IGlamourerApiState.GetState"/>
public sealed class GetState(IDalamudPluginInterface pi)
: FuncSubscriber<int, uint, (int, JObject?)>(pi, Label)
{
/// <summary> The label. </summary>
public const string Label = $"Glamourer.{nameof(GetState)}";
/// <inheritdoc cref="IGlamourerApiState.GetState"/>
public new (GlamourerApiEc, JObject?) Invoke(int objectIndex, uint key = 0)
{
var (ec, data) = base.Invoke(objectIndex, key);
return ((GlamourerApiEc)ec, data);
}
/// <summary> Create a provider. </summary>
public static FuncProvider<int, uint, (int, JObject?)> Provider(IDalamudPluginInterface pi, IGlamourerApiState api)
=> new(pi, Label, (a, b) =>
{
var (ec, data) = api.GetState(a, b);
return ((int)ec, data);
});
}
/// <inheritdoc cref="IGlamourerApiState.GetStateName"/>
public sealed class GetStateName(IDalamudPluginInterface pi)
: FuncSubscriber<string, uint, (int, JObject?)>(pi, Label)
{
/// <summary> The label. </summary>
public const string Label = $"Glamourer.{nameof(GetStateName)}";
/// <inheritdoc cref="IGlamourerApiState.GetStateName"/>
public new (GlamourerApiEc, JObject?) Invoke(string objectName, uint key = 0)
{
var (ec, data) = base.Invoke(objectName, key);
return ((GlamourerApiEc)ec, data);
}
/// <summary> Create a provider. </summary>
public static FuncProvider<string, uint, (int, JObject?)> Provider(IDalamudPluginInterface pi, IGlamourerApiState api)
=> new(pi, Label, (i, k) =>
{
var (ec, data) = api.GetStateName(i, k);
return ((int)ec, data);
});
}
/// <inheritdoc cref="IGlamourerApiState.GetStateBase64"/>
public sealed class GetStateBase64(IDalamudPluginInterface pi)
: FuncSubscriber<int, uint, (int, string?)>(pi, Label)
{
/// <summary> The label. </summary>
public const string Label = $"Glamourer.{nameof(GetStateBase64)}";
/// <inheritdoc cref="IGlamourerApiState.GetStateBase64"/>
public new (GlamourerApiEc, string?) Invoke(int objectIndex, uint key = 0)
{
var (ec, data) = base.Invoke(objectIndex, key);
return ((GlamourerApiEc)ec, data);
}
/// <summary> Create a provider. </summary>
public static FuncProvider<int, uint, (int, string?)> Provider(IDalamudPluginInterface pi, IGlamourerApiState api)
=> new(pi, Label, (a, b) =>
{
var (ec, data) = api.GetStateBase64(a, b);
return ((int)ec, data);
});
}
/// <inheritdoc cref="IGlamourerApiState.GetStateBase64Name"/>
public sealed class GetStateBase64Name(IDalamudPluginInterface pi)
: FuncSubscriber<string, uint, (int, string?)>(pi, Label)
{
/// <summary> The label. </summary>
public const string Label = $"Glamourer.{nameof(GetStateBase64Name)}";
/// <inheritdoc cref="IGlamourerApiState.GetStateBase64Name"/>
public new (GlamourerApiEc, string?) Invoke(string objectName, uint key = 0)
{
var (ec, data) = base.Invoke(objectName, key);
return ((GlamourerApiEc)ec, data);
}
/// <summary> Create a provider. </summary>
public static FuncProvider<string, uint, (int, string?)> Provider(IDalamudPluginInterface pi, IGlamourerApiState api)
=> new(pi, Label, (i, k) =>
{
var (ec, data) = api.GetStateBase64Name(i, k);
return ((int)ec, data);
});
}
/// <inheritdoc cref="IGlamourerApiState.ApplyState"/>
public sealed class ApplyState(IDalamudPluginInterface pi)
: FuncSubscriber<object, int, uint, ulong, int>(pi, Label)
{
/// <summary> The label. </summary>
public const string Label = $"Glamourer.{nameof(ApplyState)}";
/// <inheritdoc cref="IGlamourerApiState.ApplyState"/>
public GlamourerApiEc Invoke(JObject state, int objectIndex, uint key = 0, ApplyFlag flags = ApplyFlagEx.StateDefault)
=> (GlamourerApiEc)Invoke(state, objectIndex, key, (ulong)flags);
/// <inheritdoc cref="IGlamourerApiState.ApplyState"/>
public GlamourerApiEc Invoke(string base64State, int objectIndex, uint key = 0, ApplyFlag flags = ApplyFlagEx.StateDefault)
=> (GlamourerApiEc)Invoke(base64State, objectIndex, key, (ulong)flags);
/// <summary> Create a provider. </summary>
public static FuncProvider<object, int, uint, ulong, int> Provider(IDalamudPluginInterface pi, IGlamourerApiState api)
=> new(pi, Label, (a, b, c, d) => (int)api.ApplyState(a, b, c, (ApplyFlag)d));
}
/// <inheritdoc cref="IGlamourerApiState.ApplyStateName"/>
public sealed class ApplyStateName(IDalamudPluginInterface pi)
: FuncSubscriber<object, string, uint, ulong, int>(pi, Label)
{
/// <summary> The label. </summary>
public const string Label = $"Glamourer.{nameof(ApplyStateName)}";
/// <inheritdoc cref="IGlamourerApiState.ApplyState"/>
public GlamourerApiEc Invoke(JObject state, string objectName, uint key = 0, ApplyFlag flags = ApplyFlagEx.StateDefault)
=> (GlamourerApiEc)Invoke(state, objectName, key, (ulong)flags);
/// <inheritdoc cref="IGlamourerApiState.ApplyState"/>
public GlamourerApiEc Invoke(string base64State, string objectName, uint key = 0, ApplyFlag flags = ApplyFlagEx.StateDefault)
=> (GlamourerApiEc)Invoke(base64State, objectName, key, (ulong)flags);
/// <summary> Create a provider. </summary>
public static FuncProvider<object, string, uint, ulong, int> Provider(IDalamudPluginInterface pi, IGlamourerApiState api)
=> new(pi, Label, (a, b, c, d) => (int)api.ApplyStateName(a, b, c, (ApplyFlag)d));
}
/// <inheritdoc cref="IGlamourerApiState.RevertState"/>
public sealed class RevertState(IDalamudPluginInterface pi)
: FuncSubscriber<int, uint, ulong, int>(pi, Label)
{
/// <summary> The label. </summary>
public const string Label = $"Glamourer.{nameof(RevertState)}";
/// <inheritdoc cref="IGlamourerApiState.RevertState"/>
public GlamourerApiEc Invoke(int objectIndex, uint key = 0, ApplyFlag flags = ApplyFlagEx.RevertDefault)
=> (GlamourerApiEc)Invoke(objectIndex, key, (ulong)flags);
/// <summary> Create a provider. </summary>
public static FuncProvider<int, uint, ulong, int> Provider(IDalamudPluginInterface pi, IGlamourerApiState api)
=> new(pi, Label, (a, b, c) => (int)api.RevertState(a, b, (ApplyFlag)c));
}
/// <inheritdoc cref="IGlamourerApiState.RevertStateName"/>
public sealed class RevertStateName(IDalamudPluginInterface pi)
: FuncSubscriber<string, uint, ulong, int>(pi, Label)
{
/// <summary> The label. </summary>
public const string Label = $"Glamourer.{nameof(RevertStateName)}";
/// <inheritdoc cref="IGlamourerApiState.RevertStateName"/>
public GlamourerApiEc Invoke(string objectName, uint key = 0, ApplyFlag flags = ApplyFlagEx.RevertDefault)
=> (GlamourerApiEc)Invoke(objectName, key, (ulong)flags);
/// <summary> Create a provider. </summary>
public static FuncProvider<string, uint, ulong, int> Provider(IDalamudPluginInterface pi, IGlamourerApiState api)
=> new(pi, Label, (a, b, c) => (int)api.RevertStateName(a, b, (ApplyFlag)c));
}
/// <inheritdoc cref="IGlamourerApiState.UnlockState"/>
public sealed class UnlockState(IDalamudPluginInterface pi)
: FuncSubscriber<int, uint, int>(pi, Label)
{
/// <summary> The label. </summary>
public const string Label = $"Glamourer.{nameof(UnlockState)}";
/// <inheritdoc cref="IGlamourerApiState.UnlockState"/>
public new GlamourerApiEc Invoke(int objectIndex, uint key = 0)
=> (GlamourerApiEc)base.Invoke(objectIndex, key);
/// <summary> Create a provider. </summary>
public static FuncProvider<int, uint, int> Provider(IDalamudPluginInterface pi, IGlamourerApiState api)
=> new(pi, Label, (a, b) => (int)api.UnlockState(a, b));
}
/// <inheritdoc cref="IGlamourerApiState.UnlockStateName"/>
public sealed class UnlockStateName(IDalamudPluginInterface pi)
: FuncSubscriber<string, uint, int>(pi, Label)
{
/// <summary> The label. </summary>
public const string Label = $"Glamourer.{nameof(UnlockStateName)}";
/// <inheritdoc cref="IGlamourerApiState.UnlockStateName"/>
public new GlamourerApiEc Invoke(string objectName, uint key = 0)
=> (GlamourerApiEc)base.Invoke(objectName, key);
/// <summary> Create a provider. </summary>
public static FuncProvider<string, uint, int> Provider(IDalamudPluginInterface pi, IGlamourerApiState api)
=> new(pi, Label, (a, b) => (int)api.UnlockStateName(a, b));
}
/// <inheritdoc cref="IGlamourerApiState.UnlockAll"/>
public sealed class UnlockAll(IDalamudPluginInterface pi)
: FuncSubscriber<uint, int>(pi, Label)
{
/// <summary> The label. </summary>
public const string Label = $"Glamourer.{nameof(UnlockAll)}";
/// <inheritdoc cref="IGlamourerApiState.UnlockAll"/>
public new int Invoke(uint key)
=> base.Invoke(key);
/// <summary> Create a provider. </summary>
public static FuncProvider<uint, int> Provider(IDalamudPluginInterface pi, IGlamourerApiState api)
=> new(pi, Label, api.UnlockAll);
}
/// <inheritdoc cref="IGlamourerApiState.RevertToAutomation"/>
public sealed class RevertToAutomation(IDalamudPluginInterface pi)
: FuncSubscriber<int, uint, ulong, int>(pi, Label)
{
/// <summary> The label. </summary>
public const string Label = $"Glamourer.{nameof(RevertToAutomation)}.V2";
/// <inheritdoc cref="IGlamourerApiState.RevertToAutomation"/>
public GlamourerApiEc Invoke(int objectIndex, uint key = 0, ApplyFlag flags = ApplyFlagEx.RevertDefault)
=> (GlamourerApiEc)Invoke(objectIndex, key, (ulong)flags);
/// <summary> Create a provider. </summary>
public static FuncProvider<int, uint, ulong, int> Provider(IDalamudPluginInterface pi, IGlamourerApiState api)
=> new(pi, Label, (a, b, c) => (int)api.RevertToAutomation(a, b, (ApplyFlag)c));
}
/// <inheritdoc cref="IGlamourerApiState.RevertToAutomationName"/>
public sealed class RevertToAutomationName(IDalamudPluginInterface pi)
: FuncSubscriber<string, uint, ulong, int>(pi, Label)
{
/// <summary> The label. </summary>
public const string Label = $"Glamourer.{nameof(RevertToAutomationName)}";
/// <inheritdoc cref="IGlamourerApiState.RevertToAutomationName"/>
public GlamourerApiEc Invoke(string objectName, uint key = 0, ApplyFlag flags = ApplyFlagEx.RevertDefault)
=> (GlamourerApiEc)Invoke(objectName, key, (ulong)flags);
/// <summary> Create a provider. </summary>
public static FuncProvider<string, uint, ulong, int> Provider(IDalamudPluginInterface pi, IGlamourerApiState api)
=> new(pi, Label, (a, b, c) => (int)api.RevertToAutomationName(a, b, (ApplyFlag)c));
}
/// <inheritdoc cref="IGlamourerApiState.StateChanged" />
public static class StateChanged
{
/// <summary> The label. </summary>
public const string Label = $"Penumbra.{nameof(StateChanged)}.V2";
/// <summary> Create a new event subscriber. </summary>
public static EventSubscriber<nint> Subscriber(IDalamudPluginInterface pi, params Action<nint>[] actions)
=> new(pi, Label, actions);
/// <summary> Create a provider. </summary>
public static EventProvider<nint> Provider(IDalamudPluginInterface pi, IGlamourerApiState api)
=> new(pi, Label, (t => api.StateChanged += t, t => api.StateChanged -= t));
}
/// <inheritdoc cref="IGlamourerApiState.StateChangedWithType" />
public static class StateChangedWithType
{
/// <summary> The label. </summary>
public const string Label = $"Penumbra.{nameof(StateChangedWithType)}";
/// <summary> Create a new event subscriber. </summary>
public static EventSubscriber<nint, StateChangeType> Subscriber(IDalamudPluginInterface pi, params Action<nint, StateChangeType>[] actions)
=> new(pi, Label, actions);
/// <summary> Create a provider. </summary>
public static EventProvider<nint, StateChangeType> Provider(IDalamudPluginInterface pi, IGlamourerApiState api)
=> new(pi, Label, (t => api.StateChangedWithType += t, t => api.StateChangedWithType -= t));
}
/// <inheritdoc cref="IGlamourerApiState.StateFinalized" />
public static class StateFinalized
{
/// <summary> The label. </summary>
public const string Label = $"Penumbra.{nameof(StateFinalized)}";
/// <summary> Create a new event subscriber. </summary>
public static EventSubscriber<nint, StateFinalizationType> Subscriber(IDalamudPluginInterface pi, params Action<nint, StateFinalizationType>[] actions)
=> new(pi, Label, actions);
/// <summary> Create a provider. </summary>
public static EventProvider<nint, StateFinalizationType> Provider(IDalamudPluginInterface pi, IGlamourerApiState api)
=> new(pi, Label, (t => api.StateFinalized += t, t => api.StateFinalized -= t));
}
/// <inheritdoc cref="IGlamourerApiState.GPoseChanged" />
public static class GPoseChanged
{
/// <summary> The label. </summary>
public const string Label = $"Penumbra.{nameof(GPoseChanged)}";
/// <summary> Create a new event subscriber. </summary>
public static EventSubscriber<bool> Subscriber(IDalamudPluginInterface pi, params Action<bool>[] actions)
=> new(pi, Label, actions);
/// <summary> Create a provider. </summary>
public static EventProvider<bool> Provider(IDalamudPluginInterface pi, IGlamourerApiState api)
=> new(pi, Label, (t => api.GPoseChanged += t, t => api.GPoseChanged -= t));
}

View File

@@ -1,4 +0,0 @@
# Glamourer
This is an auxiliary repository for Glamourers external API.
For more information, see the [main repo](https://github.com/Ottermandias/Glamourer).

View File

@@ -1,13 +0,0 @@
{
"version": 1,
"dependencies": {
"net9.0-windows7.0": {
"DotNet.ReproducibleBuilds": {
"type": "Direct",
"requested": "[1.2.25, )",
"resolved": "1.2.25",
"contentHash": "xCXiw7BCxHJ8pF6wPepRUddlh2dlQlbr81gXA72hdk4FLHkKXas7EH/n+fk5UCA/YfMqG1Z6XaPiUjDbUNBUzg=="
}
}
}
}

View File

@@ -1,6 +1,6 @@
MIT License
Copyright (c) 2022 Mare Synchronos
Copyright (c) 2022 Penumbra-Sync
Permission is hereby granted, free of charge, to any person obtaining a copy
of this software and associated documentation files (the "Software"), to deal

1
MareAPI Submodule

Submodule MareAPI added at d105d20507

View File

@@ -5,7 +5,7 @@ VisualStudioVersion = 17.1.32328.378
MinimumVisualStudioVersion = 10.0.40219.1
Project("{9A19103F-16F7-4668-BE54-9A1E7A4F7556}") = "MareSynchronos", "MareSynchronos\MareSynchronos.csproj", "{13C812E9-0D42-4B95-8646-40EEBF30636F}"
EndProject
Project("{9A19103F-16F7-4668-BE54-9A1E7A4F7556}") = "MareSynchronos.API", "UmbraAPI\MareSynchronosAPI\MareSynchronos.API.csproj", "{5A0B7434-8D89-4E90-B55C-B4A7AE1A6ADE}"
Project("{9A19103F-16F7-4668-BE54-9A1E7A4F7556}") = "MareSynchronos.API", "MareAPI\MareSynchronosAPI\MareSynchronos.API.csproj", "{5A0B7434-8D89-4E90-B55C-B4A7AE1A6ADE}"
EndProject
Project("{2150E333-8FDC-42A3-9474-1A3956D46DE8}") = "Solution Items", "Solution Items", "{585B740D-BA2C-429B-9CF3-B2D223423748}"
ProjectSection(SolutionItems) = preProject

View File

@@ -1,4 +1,5 @@
using MareSynchronos.Interop.Ipc;
using System;
using MareSynchronos.Interop.Ipc;
using MareSynchronos.MareConfiguration;
using MareSynchronos.Services;
using MareSynchronos.Services.Mediator;
@@ -606,14 +607,35 @@ public sealed class CacheMonitor : DisposableMediatorSubscriberBase
protected override void Dispose(bool disposing)
{
base.Dispose(disposing);
_scanCancellationTokenSource?.Cancel();
try
{
_scanCancellationTokenSource.Cancel();
}
catch (ObjectDisposedException)
{
}
_scanCancellationTokenSource.Dispose();
PenumbraWatcher?.Dispose();
MareWatcher?.Dispose();
SubstWatcher?.Dispose();
_penumbraFswCts?.CancelDispose();
_mareFswCts?.CancelDispose();
_substFswCts?.CancelDispose();
_periodicCalculationTokenSource?.CancelDispose();
TryCancelAndDispose(_penumbraFswCts);
TryCancelAndDispose(_mareFswCts);
TryCancelAndDispose(_substFswCts);
TryCancelAndDispose(_periodicCalculationTokenSource);
}
private static void TryCancelAndDispose(CancellationTokenSource? cts)
{
if (cts == null) return;
try
{
cts.Cancel();
}
catch (ObjectDisposedException)
{
}
cts.Dispose();
}
private void FullFileScan(CancellationToken ct)
@@ -856,4 +878,4 @@ public sealed class CacheMonitor : DisposableMediatorSubscriberBase
StartPenumbraWatcher(penumbraDir);
}
}
}
}

View File

@@ -236,7 +236,6 @@ public sealed class FileCacheManager : IHostedService
foreach (var entry in cleanedPaths)
{
//_logger.LogDebug("Checking {path}", entry.Value);
if (dict.TryGetValue(entry.Value, out var entity))
{
@@ -366,8 +365,7 @@ public sealed class FileCacheManager : IHostedService
if (!entries.Exists(u => string.Equals(u.PrefixedFilePath, fileCache.PrefixedFilePath, StringComparison.OrdinalIgnoreCase)))
{
//_logger.LogTrace("Adding to DB: {hash} => {path}", fileCache.Hash, fileCache.PrefixedFilePath);
entries.Add(fileCache);
entries.Add(fileCache);
}
}
@@ -389,7 +387,6 @@ public sealed class FileCacheManager : IHostedService
private FileCacheEntity? GetValidatedFileCache(FileCacheEntity fileCache)
{
var resultingFileCache = ReplacePathPrefixes(fileCache);
//_logger.LogTrace("Validating {path}", fileCache.PrefixedFilePath);
resultingFileCache = Validate(resultingFileCache);
return resultingFileCache;
}

View File

@@ -12,7 +12,7 @@ public sealed class FileCompactor
private readonly Dictionary<string, int> _clusterSizes;
private readonly WOF_FILE_COMPRESSION_INFO_V1 _efInfo;
private readonly WofFileCompressionInfoV1 _efInfo;
private readonly ILogger<FileCompactor> _logger;
private readonly MareConfigService _mareConfigService;
@@ -24,7 +24,7 @@ public sealed class FileCompactor
_logger = logger;
_mareConfigService = mareConfigService;
_dalamudUtilService = dalamudUtilService;
_efInfo = new WOF_FILE_COMPRESSION_INFO_V1
_efInfo = new WofFileCompressionInfoV1
{
Algorithm = CompressionAlgorithm.XPRESS8K,
Flags = 0
@@ -123,7 +123,7 @@ public sealed class FileCompactor
out uint lpTotalNumberOfClusters);
[DllImport("WoFUtil.dll")]
private static extern int WofIsExternalFile([MarshalAs(UnmanagedType.LPWStr)] string Filepath, out int IsExternalFile, out uint Provider, out WOF_FILE_COMPRESSION_INFO_V1 Info, ref uint BufferLength);
private static extern int WofIsExternalFile([MarshalAs(UnmanagedType.LPWStr)] string Filepath, out int IsExternalFile, out uint Provider, out WofFileCompressionInfoV1 Info, ref uint BufferLength);
[DllImport("WofUtil.dll")]
private static extern int WofSetFileDataLocation(IntPtr FileHandle, ulong Provider, IntPtr ExternalFileInfo, ulong Length);
@@ -242,9 +242,9 @@ public sealed class FileCompactor
}
[StructLayout(LayoutKind.Sequential)]
private struct WOF_FILE_COMPRESSION_INFO_V1
private struct WofFileCompressionInfoV1
{
public CompressionAlgorithm Algorithm;
public ulong Flags;
}
}
}

View File

@@ -17,8 +17,8 @@ namespace MareSynchronos.Interop;
public record ChatChannelOverride
{
public string ChannelName = string.Empty;
public Action<byte[]>? ChatMessageHandler;
public string ChannelName { get; set; } = string.Empty;
public Action<byte[]>? ChatMessageHandler { get; set; }
}
public unsafe sealed class GameChatHooks : IDisposable

View File

@@ -31,11 +31,11 @@ public class MdlFile
public ushort Unknown9;
// Offsets are stored relative to RuntimeSize instead of file start.
public uint[] VertexOffset = [0, 0, 0];
public uint[] IndexOffset = [0, 0, 0];
public uint[] VertexOffset;
public uint[] IndexOffset;
public uint[] VertexBufferSize = [0, 0, 0];
public uint[] IndexBufferSize = [0, 0, 0];
public uint[] VertexBufferSize;
public uint[] IndexBufferSize;
public byte LodCount;
public bool EnableIndexBufferStreaming;
public bool EnableEdgeGeometry;
@@ -43,15 +43,26 @@ public class MdlFile
public ModelFlags1 Flags1;
public ModelFlags2 Flags2;
public VertexDeclarationStruct[] VertexDeclarations = [];
public ElementIdStruct[] ElementIds = [];
public MeshStruct[] Meshes = [];
public BoundingBoxStruct[] BoneBoundingBoxes = [];
public LodStruct[] Lods = [];
public ExtraLodStruct[] ExtraLods = [];
public VertexDeclarationStruct[] VertexDeclarations;
public ElementIdStruct[] ElementIds;
public MeshStruct[] Meshes;
public BoundingBoxStruct[] BoneBoundingBoxes;
public LodStruct[] Lods;
public ExtraLodStruct[] ExtraLods;
public MdlFile(string filePath)
{
VertexOffset = Array.Empty<uint>();
IndexOffset = Array.Empty<uint>();
VertexBufferSize = Array.Empty<uint>();
IndexBufferSize = Array.Empty<uint>();
VertexDeclarations = Array.Empty<VertexDeclarationStruct>();
ElementIds = Array.Empty<ElementIdStruct>();
Meshes = Array.Empty<MeshStruct>();
BoneBoundingBoxes = Array.Empty<BoundingBoxStruct>();
Lods = Array.Empty<LodStruct>();
ExtraLods = Array.Empty<ExtraLodStruct>();
using var stream = new FileStream(filePath, FileMode.Open, FileAccess.Read);
using var r = new LuminaBinaryReader(stream);
@@ -256,4 +267,4 @@ public class MdlFile
}
}
}
#pragma warning restore S1104 // Fields should not have public accessibility
#pragma warning restore S1104 // Fields should not have public accessibility

View File

@@ -95,8 +95,6 @@ public sealed class IpcCallerBrio : IIpcCaller
if (gameObject == null) return default;
var data = await _dalamudUtilService.RunOnFrameworkThread(() => _brioGetModelTransform.InvokeFunc(gameObject)).ConfigureAwait(false);
if (data.Item1 == null || data.Item2 == null || data.Item3 == null) return default;
//_logger.LogDebug("Getting Transform from Actor {actor}", gameObject.Name.TextValue);
return new WorldData()
{
PositionX = data.Item1.Value.X,

View File

@@ -1,4 +1,5 @@
using Dalamud.Game.ClientState.Objects.Types;
using System;
using Dalamud.Plugin;
using Dalamud.Plugin.Ipc;
using MareSynchronos.MareConfiguration;
@@ -29,7 +30,7 @@ public class IpcProvider : IHostedService, IMediatorSubscriber
private bool _marePluginEnabled = false;
private bool _impersonating = false;
private DateTime _unregisterTime = DateTime.UtcNow;
private CancellationTokenSource _registerDelayCts = new();
private CancellationTokenSource? _registerDelayCts = new();
public bool MarePluginEnabled => _marePluginEnabled;
public bool ImpersonationActive => _impersonating;
@@ -100,7 +101,7 @@ public class IpcProvider : IHostedService, IMediatorSubscriber
{
if (_mareConfig.Current.MareAPI)
{
var cancelToken = _registerDelayCts.Token;
var cancelToken = EnsureFreshCts(ref _registerDelayCts).Token;
Task.Run(async () =>
{
// Wait before registering to reduce the chance of a race condition
@@ -125,7 +126,7 @@ public class IpcProvider : IHostedService, IMediatorSubscriber
}
else
{
_registerDelayCts = _registerDelayCts.CancelRecreate();
EnsureFreshCts(ref _registerDelayCts);
if (_impersonating)
{
_loadFileProviderMare?.UnregisterFunc();
@@ -146,7 +147,7 @@ public class IpcProvider : IHostedService, IMediatorSubscriber
_loadFileAsyncProvider?.UnregisterFunc();
_handledGameAddresses?.UnregisterFunc();
_registerDelayCts.Cancel();
TryCancel(_registerDelayCts);
if (_impersonating)
{
_loadFileProviderMare?.UnregisterFunc();
@@ -155,6 +156,7 @@ public class IpcProvider : IHostedService, IMediatorSubscriber
}
Mediator.UnsubscribeAll(this);
CancelAndDispose(ref _registerDelayCts);
return Task.CompletedTask;
}
@@ -193,4 +195,31 @@ public class IpcProvider : IHostedService, IMediatorSubscriber
return _activeGameObjectHandlers.Where(g => g.Address != nint.Zero).Select(g => g.Address).Distinct().ToList();
}
private static CancellationTokenSource EnsureFreshCts(ref CancellationTokenSource? cts)
{
CancelAndDispose(ref cts);
cts = new CancellationTokenSource();
return cts;
}
private static void CancelAndDispose(ref CancellationTokenSource? cts)
{
if (cts == null) return;
TryCancel(cts);
cts.Dispose();
cts = null;
}
private static void TryCancel(CancellationTokenSource? cts)
{
if (cts == null) return;
try
{
cts.Cancel();
}
catch (ObjectDisposedException)
{
}
}
}

View File

@@ -1,19 +1,20 @@
using Dalamud.Game.ClientState.Objects.Types;
using System;
using MareSynchronos.PlayerData.Handlers;
using MareSynchronos.Services;
using MareSynchronos.Services.Mediator;
using MareSynchronos.Utils;
using Microsoft.Extensions.Logging;
using System.Collections.Concurrent;
namespace MareSynchronos.Interop.Ipc;
public class RedrawManager
public class RedrawManager : IDisposable
{
private readonly MareMediator _mareMediator;
private readonly DalamudUtilService _dalamudUtil;
private readonly ConcurrentDictionary<nint, bool> _penumbraRedrawRequests = [];
private CancellationTokenSource _disposalCts = new();
private CancellationTokenSource? _disposalCts = new();
private bool _disposed;
public SemaphoreSlim RedrawSemaphore { get; init; } = new(2, 2);
@@ -32,12 +33,12 @@ public class RedrawManager
try
{
using CancellationTokenSource cancelToken = new CancellationTokenSource();
using CancellationTokenSource combinedCts = CancellationTokenSource.CreateLinkedTokenSource(cancelToken.Token, token, _disposalCts.Token);
using CancellationTokenSource combinedCts = CancellationTokenSource.CreateLinkedTokenSource(cancelToken.Token, token, EnsureFreshCts(ref _disposalCts).Token);
var combinedToken = combinedCts.Token;
cancelToken.CancelAfter(TimeSpan.FromSeconds(15));
await handler.ActOnFrameworkAfterEnsureNoDrawAsync(action, combinedToken).ConfigureAwait(false);
if (!_disposalCts.Token.IsCancellationRequested)
if (!_disposalCts!.Token.IsCancellationRequested)
await _dalamudUtil.WaitWhileCharacterIsDrawing(logger, handler, applicationId, 30000, combinedToken).ConfigureAwait(false);
}
finally
@@ -49,6 +50,45 @@ public class RedrawManager
internal void Cancel()
{
_disposalCts = _disposalCts.CancelRecreate();
EnsureFreshCts(ref _disposalCts);
}
public void Dispose()
{
Dispose(true);
GC.SuppressFinalize(this);
}
protected virtual void Dispose(bool disposing)
{
if (_disposed) return;
if (disposing)
{
CancelAndDispose(ref _disposalCts);
}
_disposed = true;
}
private static CancellationTokenSource EnsureFreshCts(ref CancellationTokenSource? cts)
{
CancelAndDispose(ref cts);
cts = new CancellationTokenSource();
return cts;
}
private static void CancelAndDispose(ref CancellationTokenSource? cts)
{
if (cts == null) return;
try
{
cts.Cancel();
}
catch (ObjectDisposedException)
{
}
cts.Dispose();
cts = null;
}
}

View File

@@ -1,15 +1,72 @@
using MareSynchronos.WebAPI;
using System.Text.Json;
using Microsoft.Extensions.Hosting;
using Microsoft.Extensions.Logging;
namespace MareSynchronos.MareConfiguration;
public class ConfigurationMigrator(ILogger<ConfigurationMigrator> logger) : IHostedService
public class ConfigurationMigrator(ILogger<ConfigurationMigrator> logger, MareConfigService mareConfig) : IHostedService
{
private readonly ILogger<ConfigurationMigrator> _logger = logger;
private readonly MareConfigService _mareConfig = mareConfig;
public void Migrate()
{
try
{
var path = _mareConfig.ConfigurationPath;
if (!File.Exists(path)) return;
using var doc = JsonDocument.Parse(File.ReadAllText(path));
var root = doc.RootElement;
bool changed = false;
if (root.TryGetProperty("EnableAutoSyncDiscovery", out var enableAutoSync))
{
var val = enableAutoSync.GetBoolean();
if (_mareConfig.Current.EnableAutoDetectDiscovery != val)
{
_mareConfig.Current.EnableAutoDetectDiscovery = val;
changed = true;
}
}
if (root.TryGetProperty("AllowAutoSyncPairRequests", out var allowAutoSync))
{
var val = allowAutoSync.GetBoolean();
if (_mareConfig.Current.AllowAutoDetectPairRequests != val)
{
_mareConfig.Current.AllowAutoDetectPairRequests = val;
changed = true;
}
}
if (root.TryGetProperty("AutoSyncMaxDistanceMeters", out var maxDistSync) && maxDistSync.TryGetInt32(out var md))
{
if (_mareConfig.Current.AutoDetectMaxDistanceMeters != md)
{
_mareConfig.Current.AutoDetectMaxDistanceMeters = md;
changed = true;
}
}
if (root.TryGetProperty("AutoSyncMuteMinutes", out var muteSync) && muteSync.TryGetInt32(out var mm))
{
if (_mareConfig.Current.AutoDetectMuteMinutes != mm)
{
_mareConfig.Current.AutoDetectMuteMinutes = mm;
changed = true;
}
}
if (changed)
{
_logger.LogInformation("Migrated config: AutoSync -> AutoDetect fields");
_mareConfig.Save();
}
}
catch (Exception ex)
{
_logger.LogWarning(ex, "Configuration migration failed");
}
}
public Task StartAsync(CancellationToken cancellationToken)

View File

@@ -12,8 +12,9 @@ public class CharaDataConfig : IMareConfiguration
public bool NearbyOwnServerOnly { get; set; } = false;
public bool NearbyIgnoreHousingLimitations { get; set; } = false;
public bool NearbyDrawWisps { get; set; } = true;
public int NearbyMaxWisps { get; set; } = 20;
public int NearbyDistanceFilter { get; set; } = 100;
public bool NearbyShowOwnData { get; set; } = false;
public bool ShowHelpTexts { get; set; } = true;
public bool NearbyShowAlways { get; set; } = false;
}
}

View File

@@ -1,4 +1,5 @@
using MareSynchronos.MareConfiguration.Models;
using System.Collections.Generic;
using MareSynchronos.MareConfiguration.Models;
using MareSynchronos.UI;
using Microsoft.Extensions.Logging;
@@ -19,7 +20,7 @@ public class MareConfig : IMareConfiguration
public bool UseColorsInDtr { get; set; } = true;
public DtrEntry.Colors DtrColorsDefault { get; set; } = default;
public DtrEntry.Colors DtrColorsNotConnected { get; set; } = new(Glow: 0x0428FFu);
public DtrEntry.Colors DtrColorsPairsInRange { get; set; } = new(Glow: 0xFFBA47u);
public DtrEntry.Colors DtrColorsPairsInRange { get; set; } = new(Glow: 0x8D37C0u);
public bool UseNameColors { get; set; } = false;
public DtrEntry.Colors NameColors { get; set; } = new(Foreground: 0x67EBF5u, Glow: 0x00303Cu);
public DtrEntry.Colors BlockedNameColors { get; set; } = new(Foreground: 0x8AADC7, Glow: 0x000080u);
@@ -37,6 +38,7 @@ public class MareConfig : IMareConfiguration
public bool OpenGposeImportOnGposeStart { get; set; } = false;
public bool OpenPopupOnAdd { get; set; } = true;
public int ParallelDownloads { get; set; } = 10;
public bool EnableDownloadQueue { get; set; } = false;
public int DownloadSpeedLimitInBytes { get; set; } = 0;
public DownloadSpeeds DownloadSpeedType { get; set; } = DownloadSpeeds.MBps;
[Obsolete] public bool PreferNotesOverNamesForVisible { get; set; } = false;
@@ -59,6 +61,16 @@ public class MareConfig : IMareConfiguration
public bool ShowUploading { get; set; } = true;
public bool ShowUploadingBigText { get; set; } = true;
public bool ShowVisibleUsersSeparately { get; set; } = true;
public string LastChangelogVersionSeen { get; set; } = string.Empty;
public bool DefaultDisableSounds { get; set; } = false;
public bool DefaultDisableAnimations { get; set; } = false;
public bool DefaultDisableVfx { get; set; } = false;
public Dictionary<string, SyncOverrideEntry> PairSyncOverrides { get; set; } = new(StringComparer.Ordinal);
public Dictionary<string, SyncOverrideEntry> GroupSyncOverrides { get; set; } = new(StringComparer.Ordinal);
public bool EnableAutoDetectDiscovery { get; set; } = true;
public bool AllowAutoDetectPairRequests { get; set; } = true;
public int AutoDetectMaxDistanceMeters { get; set; } = 40;
public int AutoDetectMuteMinutes { get; set; } = 5;
public int TimeSpanBetweenScansInSeconds { get; set; } = 30;
public int TransferBarsHeight { get; set; } = 12;
public bool TransferBarsShowText { get; set; } = true;
@@ -73,6 +85,11 @@ public class MareConfig : IMareConfiguration
public int ChatLogKind { get; set; } = 1; // XivChatType.Debug
public bool ExtraChatAPI { get; set; } = false;
public bool ExtraChatTags { get; set; } = false;
public bool TypingIndicatorShowOnNameplates { get; set; } = true;
public bool TypingIndicatorShowOnPartyList { get; set; } = true;
public bool TypingIndicatorEnabled { get; set; } = true;
public bool TypingIndicatorShowSelf { get; set; } = true;
public TypingIndicatorBubbleSize TypingIndicatorBubbleSize { get; set; } = TypingIndicatorBubbleSize.Large;
public bool MareAPI { get; set; } = true;
}
}

View File

@@ -0,0 +1,12 @@
using System;
using System.Collections.Generic;
using MareSynchronos.MareConfiguration.Models;
namespace MareSynchronos.MareConfiguration.Configurations;
[Serializable]
public class NotificationsConfig : IMareConfiguration
{
public List<StoredNotification> Notifications { get; set; } = new();
public int Version { get; set; } = 1;
}

View File

@@ -8,9 +8,10 @@ public class PlayerPerformanceConfig : IMareConfiguration
public bool AutoPausePlayersExceedingThresholds { get; set; } = true;
public bool NotifyAutoPauseDirectPairs { get; set; } = true;
public bool NotifyAutoPauseGroupPairs { get; set; } = true;
public bool ShowSelfAnalysisWarnings { get; set; } = true;
public int VRAMSizeAutoPauseThresholdMiB { get; set; } = 500;
public int TrisAutoPauseThresholdThousands { get; set; } = 400;
public bool IgnoreDirectPairs { get; set; } = true;
public TextureShrinkMode TextureShrinkMode { get; set; } = TextureShrinkMode.Default;
public bool TextureShrinkDeleteOriginal { get; set; } = false;
}
}

View File

@@ -0,0 +1,14 @@
using System;
using System.Collections.Generic;
namespace MareSynchronos.MareConfiguration.Models;
[Serializable]
public class StoredNotification
{
public string Category { get; set; } = string.Empty; // name of enum NotificationCategory
public string Id { get; set; } = string.Empty;
public string Title { get; set; } = string.Empty;
public string? Description { get; set; }
public DateTime CreatedAtUtc { get; set; } = DateTime.UtcNow;
}

View File

@@ -0,0 +1,13 @@
using System;
namespace MareSynchronos.MareConfiguration.Models;
[Serializable]
public class SyncOverrideEntry
{
public bool? DisableSounds { get; set; }
public bool? DisableAnimations { get; set; }
public bool? DisableVfx { get; set; }
public bool IsEmpty => DisableSounds is null && DisableAnimations is null && DisableVfx is null;
}

View File

@@ -0,0 +1,8 @@
namespace MareSynchronos.MareConfiguration.Models;
public enum TypingIndicatorBubbleSize
{
Small,
Medium,
Large
}

View File

@@ -0,0 +1,14 @@
using MareSynchronos.MareConfiguration.Configurations;
namespace MareSynchronos.MareConfiguration;
public class NotificationsConfigService : ConfigurationServiceBase<NotificationsConfig>
{
public const string ConfigName = "notifications.json";
public NotificationsConfigService(string configDir) : base(configDir)
{
}
public override string ConfigurationName => ConfigName;
}

View File

@@ -5,6 +5,7 @@ using MareSynchronos.PlayerData.Services;
using MareSynchronos.Services;
using MareSynchronos.Services.Mediator;
using MareSynchronos.Services.ServerConfiguration;
using MareSynchronos.Interop;
using Microsoft.Extensions.DependencyInjection;
using Microsoft.Extensions.Hosting;
using Microsoft.Extensions.Logging;
@@ -150,8 +151,12 @@ public class MarePlugin : MediatorSubscriberBase, IHostedService
_runtimeServiceScope.ServiceProvider.GetRequiredService<TransientResourceManager>();
_runtimeServiceScope.ServiceProvider.GetRequiredService<OnlinePlayerManager>();
_runtimeServiceScope.ServiceProvider.GetRequiredService<NotificationService>();
_runtimeServiceScope.ServiceProvider.GetRequiredService<SyncDefaultsService>();
_runtimeServiceScope.ServiceProvider.GetRequiredService<ChatService>();
_runtimeServiceScope.ServiceProvider.GetRequiredService<ChatTypingDetectionService>();
_runtimeServiceScope.ServiceProvider.GetRequiredService<GuiHookService>();
var characterAnalyzer = _runtimeServiceScope.ServiceProvider.GetRequiredService<CharacterAnalyzer>();
_ = characterAnalyzer.ComputeAnalysis(print: false);
#if !DEBUG
if (_mareConfigService.Current.LogLevel != LogLevel.Information)
@@ -167,4 +172,4 @@ public class MarePlugin : MediatorSubscriberBase, IHostedService
Logger?.LogCritical(ex, "Error during launch of managers");
}
}
}
}

View File

@@ -3,7 +3,7 @@
<PropertyGroup>
<AssemblyName>UmbraSync</AssemblyName>
<RootNamespace>UmbraSync</RootNamespace>
<Version>0.0.3</Version>
<Version>0.1.9.9</Version>
</PropertyGroup>
<ItemGroup>
@@ -50,10 +50,12 @@
<PropertyGroup>
<SourceRevisionId>build$([System.DateTime]::UtcNow.ToString("yyyy-MM-ddTHH:mm:ss:fffZ"))</SourceRevisionId>
<ImplicitUsings>enable</ImplicitUsings>
<DisablePackageVulnerabilityAnalysis>true</DisablePackageVulnerabilityAnalysis>
<NoWarn>$(NoWarn);NU1900</NoWarn>
</PropertyGroup>
<ItemGroup>
<ProjectReference Include="..\UmbraAPI\MareSynchronosAPI\MareSynchronos.API.csproj" />
<ProjectReference Include="..\MareAPI\MareSynchronosAPI\MareSynchronos.API.csproj" />
</ItemGroup>
<ItemGroup>

View File

@@ -1,4 +1,5 @@
using MareSynchronos.FileCache;
using MareSynchronos.MareConfiguration;
using MareSynchronos.Services.Mediator;
using MareSynchronos.WebAPI.Files;
using Microsoft.Extensions.Logging;
@@ -10,21 +11,23 @@ public class FileDownloadManagerFactory
private readonly FileCacheManager _fileCacheManager;
private readonly FileCompactor _fileCompactor;
private readonly FileTransferOrchestrator _fileTransferOrchestrator;
private readonly MareConfigService _mareConfigService;
private readonly ILoggerFactory _loggerFactory;
private readonly MareMediator _mareMediator;
public FileDownloadManagerFactory(ILoggerFactory loggerFactory, MareMediator mareMediator, FileTransferOrchestrator fileTransferOrchestrator,
FileCacheManager fileCacheManager, FileCompactor fileCompactor)
FileCacheManager fileCacheManager, FileCompactor fileCompactor, MareConfigService mareConfigService)
{
_loggerFactory = loggerFactory;
_mareMediator = mareMediator;
_fileTransferOrchestrator = fileTransferOrchestrator;
_fileCacheManager = fileCacheManager;
_fileCompactor = fileCompactor;
_mareConfigService = mareConfigService;
}
public FileDownloadManager Create()
{
return new FileDownloadManager(_loggerFactory.CreateLogger<FileDownloadManager>(), _mareMediator, _fileTransferOrchestrator, _fileCacheManager, _fileCompactor);
return new FileDownloadManager(_loggerFactory.CreateLogger<FileDownloadManager>(), _mareMediator, _fileTransferOrchestrator, _fileCacheManager, _fileCompactor, _mareConfigService);
}
}
}

View File

@@ -5,7 +5,6 @@ using MareSynchronos.PlayerData.Handlers;
using MareSynchronos.PlayerData.Pairs;
using MareSynchronos.Services;
using MareSynchronos.Services.Mediator;
using MareSynchronos.Services.ServerConfiguration;
using Microsoft.Extensions.Hosting;
using Microsoft.Extensions.Logging;
@@ -23,7 +22,6 @@ public class PairHandlerFactory
private readonly ILoggerFactory _loggerFactory;
private readonly MareMediator _mareMediator;
private readonly PlayerPerformanceService _playerPerformanceService;
private readonly ServerConfigurationManager _serverConfigManager;
private readonly PluginWarningNotificationService _pluginWarningNotificationManager;
private readonly PairAnalyzerFactory _pairAnalyzerFactory;
private readonly VisibilityService _visibilityService;
@@ -32,7 +30,7 @@ public class PairHandlerFactory
FileDownloadManagerFactory fileDownloadManagerFactory, DalamudUtilService dalamudUtilService,
PluginWarningNotificationService pluginWarningNotificationManager, IHostApplicationLifetime hostApplicationLifetime,
FileCacheManager fileCacheManager, MareMediator mareMediator, PlayerPerformanceService playerPerformanceService,
ServerConfigurationManager serverConfigManager, PairAnalyzerFactory pairAnalyzerFactory,
PairAnalyzerFactory pairAnalyzerFactory,
MareConfigService configService, VisibilityService visibilityService)
{
_loggerFactory = loggerFactory;
@@ -45,7 +43,6 @@ public class PairHandlerFactory
_fileCacheManager = fileCacheManager;
_mareMediator = mareMediator;
_playerPerformanceService = playerPerformanceService;
_serverConfigManager = serverConfigManager;
_pairAnalyzerFactory = pairAnalyzerFactory;
_configService = configService;
_visibilityService = visibilityService;
@@ -55,6 +52,6 @@ public class PairHandlerFactory
{
return new PairHandler(_loggerFactory.CreateLogger<PairHandler>(), pair, _pairAnalyzerFactory.Create(pair), _gameObjectHandlerFactory,
_ipcManager, _fileDownloadManagerFactory.Create(), _pluginWarningNotificationManager, _dalamudUtilService, _hostApplicationLifetime,
_fileCacheManager, _mareMediator, _playerPerformanceService, _serverConfigManager, _configService, _visibilityService);
_fileCacheManager, _mareMediator, _playerPerformanceService, _configService, _visibilityService);
}
}
}

View File

@@ -7,7 +7,6 @@ using MareSynchronos.PlayerData.Pairs;
using MareSynchronos.Services;
using MareSynchronos.Services.Events;
using MareSynchronos.Services.Mediator;
using MareSynchronos.Services.ServerConfiguration;
using MareSynchronos.Utils;
using MareSynchronos.WebAPI.Files;
using Microsoft.Extensions.Hosting;
@@ -29,7 +28,6 @@ public sealed class PairHandler : DisposableMediatorSubscriberBase
private readonly GameObjectHandlerFactory _gameObjectHandlerFactory;
private readonly IpcManager _ipcManager;
private readonly PlayerPerformanceService _playerPerformanceService;
private readonly ServerConfigurationManager _serverConfigManager;
private readonly PluginWarningNotificationService _pluginWarningNotificationManager;
private readonly VisibilityService _visibilityService;
private CancellationTokenSource? _applicationCancellationTokenSource = new();
@@ -53,7 +51,6 @@ public sealed class PairHandler : DisposableMediatorSubscriberBase
DalamudUtilService dalamudUtil, IHostApplicationLifetime lifetime,
FileCacheManager fileDbManager, MareMediator mediator,
PlayerPerformanceService playerPerformanceService,
ServerConfigurationManager serverConfigManager,
MareConfigService configService, VisibilityService visibilityService) : base(logger, mediator)
{
Pair = pair;
@@ -65,7 +62,6 @@ public sealed class PairHandler : DisposableMediatorSubscriberBase
_dalamudUtil = dalamudUtil;
_fileDbManager = fileDbManager;
_playerPerformanceService = playerPerformanceService;
_serverConfigManager = serverConfigManager;
_configService = configService;
_visibilityService = visibilityService;
@@ -887,4 +883,4 @@ public sealed class PairHandler : DisposableMediatorSubscriberBase
Logger.LogDebug("[BASE-{appBase}] ModdedPaths calculated in {time}ms, missing files: {count}, total files: {total}", applicationBase, st.ElapsedMilliseconds, missingFiles.Count, moddedDictionary.Keys.Count);
return [.. missingFiles];
}
}
}

View File

@@ -270,6 +270,20 @@ public class Pair : DisposableMediatorSubscriberBase
}
}
protected override void Dispose(bool disposing)
{
base.Dispose(disposing);
try
{
_applicationCts.Cancel();
}
catch (ObjectDisposedException)
{
}
_applicationCts.Dispose();
}
public void SetNote(string note)
{
_serverConfigurationManager.SetNoteForUid(UserData.UID, note);
@@ -368,4 +382,4 @@ public class Pair : DisposableMediatorSubscriberBase
return data;
}
}
}

View File

@@ -51,7 +51,7 @@ public sealed class PairManager : DisposableMediatorSubscriberBase
RecreateLazy();
}
public void AddGroupPair(GroupPairFullInfoDto dto)
public void AddGroupPair(GroupPairFullInfoDto dto, bool isInitialLoad = false)
{
if (!_allClientPairs.ContainsKey(dto.User))
_allClientPairs[dto.User] = _pairFactory.Create(dto.User);
@@ -59,6 +59,11 @@ public sealed class PairManager : DisposableMediatorSubscriberBase
var group = _allGroups[dto.Group];
_allClientPairs[dto.User].GroupPair[group] = dto;
RecreateLazy();
if (!isInitialLoad)
{
Mediator.Publish(new ApplyDefaultGroupPermissionsMessage(dto));
}
}
public Pair? GetPairByUID(string uid)
@@ -88,6 +93,11 @@ public sealed class PairManager : DisposableMediatorSubscriberBase
LastAddedUser = _allClientPairs[dto.User];
_allClientPairs[dto.User].ApplyLastReceivedData();
RecreateLazy();
if (addToLastAddedUser)
{
Mediator.Publish(new ApplyDefaultPairPermissionsMessage(dto));
}
}
public void ClearPairs()
@@ -210,9 +220,16 @@ public sealed class PairManager : DisposableMediatorSubscriberBase
public void SetGroupInfo(GroupInfoDto dto)
{
_allGroups[dto.Group].Group = dto.Group;
_allGroups[dto.Group].Owner = dto.Owner;
_allGroups[dto.Group].GroupPermissions = dto.GroupPermissions;
if (!_allGroups.TryGetValue(dto.Group, out var groupInfo))
{
return;
}
groupInfo.Group = dto.Group;
groupInfo.Owner = dto.Owner;
groupInfo.GroupPermissions = dto.GroupPermissions;
groupInfo.IsTemporary = dto.IsTemporary;
groupInfo.ExpiresAt = dto.ExpiresAt;
RecreateLazy();
}
@@ -400,4 +417,4 @@ public sealed class PairManager : DisposableMediatorSubscriberBase
_directPairsInternal = DirectPairsLazy();
_groupPairsInternal = GroupPairsLazy();
}
}
}

View File

@@ -15,6 +15,7 @@ using MareSynchronos.Services;
using MareSynchronos.Services.Events;
using MareSynchronos.Services.Mediator;
using MareSynchronos.Services.ServerConfiguration;
using MareSynchronos.Services.Notifications;
using MareSynchronos.UI;
using MareSynchronos.UI.Components;
using MareSynchronos.UI.Components.Popup;
@@ -39,8 +40,6 @@ public sealed class Plugin : IDalamudPlugin
public static Plugin Self;
#pragma warning restore CA2211, CS8618, MA0069, S1104, S2223
public Action<IFramework>? RealOnFrameworkUpdate { get; set; }
// Proxy function in the UmbraSync namespace to avoid confusion in /xlstats
public void OnFrameworkUpdate(IFramework framework)
{
RealOnFrameworkUpdate?.Invoke(framework);
@@ -98,6 +97,13 @@ public sealed class Plugin : IDalamudPlugin
collection.AddSingleton<HubFactory>();
collection.AddSingleton<FileUploadManager>();
collection.AddSingleton<FileTransferOrchestrator>();
collection.AddSingleton<MareSynchronos.Services.AutoDetect.DiscoveryConfigProvider>();
collection.AddSingleton<MareSynchronos.WebAPI.AutoDetect.DiscoveryApiClient>();
collection.AddSingleton<MareSynchronos.Services.AutoDetect.AutoDetectRequestService>();
collection.AddSingleton<MareSynchronos.Services.AutoDetect.NearbyDiscoveryService>();
collection.AddSingleton<MareSynchronos.Services.AutoDetect.NearbyPendingService>();
collection.AddSingleton<MareSynchronos.Services.AutoDetect.AutoDetectSuppressionService>();
collection.AddSingleton<MareSynchronos.Services.AutoDetect.SyncshellDiscoveryService>();
collection.AddSingleton<MarePlugin>();
collection.AddSingleton<MareProfileManager>();
collection.AddSingleton<GameObjectHandlerFactory>();
@@ -112,6 +118,7 @@ public sealed class Plugin : IDalamudPlugin
collection.AddSingleton<PluginWarningNotificationService>();
collection.AddSingleton<FileCompactor>();
collection.AddSingleton<TagHandler>();
collection.AddSingleton<SyncDefaultsService>();
collection.AddSingleton<UidDisplayHandler>();
collection.AddSingleton<PluginWatcherService>();
collection.AddSingleton<PlayerPerformanceService>();
@@ -121,6 +128,7 @@ public sealed class Plugin : IDalamudPlugin
collection.AddSingleton<CharaDataCharacterHandler>();
collection.AddSingleton<CharaDataNearbyManager>();
collection.AddSingleton<CharaDataGposeTogetherManager>();
collection.AddSingleton<McdfShareManager>();
collection.AddSingleton<VfxSpawnManager>();
collection.AddSingleton<BlockedCharacterHandler>();
@@ -142,6 +150,11 @@ public sealed class Plugin : IDalamudPlugin
collection.AddSingleton<IpcCallerMare>();
collection.AddSingleton<IpcManager>();
collection.AddSingleton<NotificationService>();
collection.AddSingleton<TemporarySyncshellNotificationService>();
collection.AddSingleton<PartyListTypingService>();
collection.AddSingleton<TypingIndicatorStateService>();
collection.AddSingleton<ChatTwoCompatibilityService>();
collection.AddSingleton<NotificationTracker>();
collection.AddSingleton((s) => new MareConfigService(pluginInterface.ConfigDirectory.FullName));
collection.AddSingleton((s) => new ServerConfigService(pluginInterface.ConfigDirectory.FullName));
@@ -154,6 +167,7 @@ public sealed class Plugin : IDalamudPlugin
collection.AddSingleton((s) => new ServerBlockConfigService(pluginInterface.ConfigDirectory.FullName));
collection.AddSingleton((s) => new CharaDataConfigService(pluginInterface.ConfigDirectory.FullName));
collection.AddSingleton((s) => new RemoteConfigCacheService(pluginInterface.ConfigDirectory.FullName));
collection.AddSingleton((s) => new NotificationsConfigService(pluginInterface.ConfigDirectory.FullName));
collection.AddSingleton<IConfigService<IMareConfiguration>>(s => s.GetRequiredService<MareConfigService>());
collection.AddSingleton<IConfigService<IMareConfiguration>>(s => s.GetRequiredService<ServerConfigService>());
collection.AddSingleton<IConfigService<IMareConfiguration>>(s => s.GetRequiredService<NotesConfigService>());
@@ -165,6 +179,7 @@ public sealed class Plugin : IDalamudPlugin
collection.AddSingleton<IConfigService<IMareConfiguration>>(s => s.GetRequiredService<ServerBlockConfigService>());
collection.AddSingleton<IConfigService<IMareConfiguration>>(s => s.GetRequiredService<CharaDataConfigService>());
collection.AddSingleton<IConfigService<IMareConfiguration>>(s => s.GetRequiredService<RemoteConfigCacheService>());
collection.AddSingleton<IConfigService<IMareConfiguration>>(s => s.GetRequiredService<NotificationsConfigService>());
collection.AddSingleton<ConfigurationMigrator>();
collection.AddSingleton<ConfigurationSaveService>();
@@ -173,16 +188,25 @@ public sealed class Plugin : IDalamudPlugin
// add scoped services
collection.AddScoped<CacheMonitor>();
collection.AddScoped<UiFactory>();
collection.AddScoped<WindowMediatorSubscriberBase, SettingsUi>();
collection.AddScoped<WindowMediatorSubscriberBase, CompactUi>();
collection.AddScoped<SettingsUi>();
collection.AddScoped<CompactUi>();
collection.AddScoped<EditProfileUi>();
collection.AddScoped<DataAnalysisUi>();
collection.AddScoped<CharaDataHubUi>();
collection.AddScoped<AutoDetectUi>();
collection.AddScoped<WindowMediatorSubscriberBase>(sp => sp.GetRequiredService<SettingsUi>());
collection.AddScoped<WindowMediatorSubscriberBase>(sp => sp.GetRequiredService<CompactUi>());
collection.AddScoped<WindowMediatorSubscriberBase, IntroUi>();
collection.AddScoped<WindowMediatorSubscriberBase, DownloadUi>();
collection.AddScoped<WindowMediatorSubscriberBase>(sp => sp.GetRequiredService<AutoDetectUi>());
collection.AddScoped<WindowMediatorSubscriberBase, ChangelogUi>();
collection.AddScoped<WindowMediatorSubscriberBase, PopoutProfileUi>();
collection.AddScoped<WindowMediatorSubscriberBase, DataAnalysisUi>();
collection.AddScoped<WindowMediatorSubscriberBase>(sp => sp.GetRequiredService<DataAnalysisUi>());
collection.AddScoped<WindowMediatorSubscriberBase, EventViewerUI>();
collection.AddScoped<WindowMediatorSubscriberBase, CharaDataHubUi>();
collection.AddScoped<WindowMediatorSubscriberBase, EditProfileUi>();
collection.AddScoped<WindowMediatorSubscriberBase>(sp => sp.GetRequiredService<EditProfileUi>());
collection.AddScoped<WindowMediatorSubscriberBase, PopupHandler>();
collection.AddScoped<WindowMediatorSubscriberBase, TypingIndicatorOverlay>();
collection.AddScoped<IPopupHandler, ReportPopupHandler>();
collection.AddScoped<IPopupHandler, BanUserPopupHandler>();
collection.AddScoped<CacheCreationService>();
@@ -194,11 +218,13 @@ public sealed class Plugin : IDalamudPlugin
collection.AddScoped<UiSharedService>();
collection.AddScoped<ChatService>();
collection.AddScoped<GuiHookService>();
collection.AddScoped<ChatTypingDetectionService>();
collection.AddHostedService(p => p.GetRequiredService<PluginWatcherService>());
collection.AddHostedService(p => p.GetRequiredService<ConfigurationSaveService>());
collection.AddHostedService(p => p.GetRequiredService<MareMediator>());
collection.AddHostedService(p => p.GetRequiredService<NotificationService>());
collection.AddHostedService(p => p.GetRequiredService<TemporarySyncshellNotificationService>());
collection.AddHostedService(p => p.GetRequiredService<FileCacheManager>());
collection.AddHostedService(p => p.GetRequiredService<ConfigurationMigrator>());
collection.AddHostedService(p => p.GetRequiredService<DalamudUtilService>());
@@ -207,9 +233,23 @@ public sealed class Plugin : IDalamudPlugin
collection.AddHostedService(p => p.GetRequiredService<EventAggregator>());
collection.AddHostedService(p => p.GetRequiredService<MarePlugin>());
collection.AddHostedService(p => p.GetRequiredService<IpcProvider>());
collection.AddHostedService(p => p.GetRequiredService<MareSynchronos.Services.AutoDetect.NearbyDiscoveryService>());
collection.AddHostedService(p => p.GetRequiredService<MareSynchronos.Services.AutoDetect.SyncshellDiscoveryService>());
collection.AddHostedService(p => p.GetRequiredService<ChatTwoCompatibilityService>());
collection.AddHostedService(p => p.GetRequiredService<MareSynchronos.Services.AutoDetect.AutoDetectSuppressionService>());
})
.Build();
try
{
var partyListTypingService = _host.Services.GetRequiredService<PartyListTypingService>();
pluginInterface.UiBuilder.Draw += partyListTypingService.Draw;
}
catch (Exception e)
{
pluginLog.Warning(e, "Failed to initialize PartyListTypingService draw hook");
}
_ = Task.Run(async () => {
try
{
@@ -227,4 +267,4 @@ public sealed class Plugin : IDalamudPlugin
_host.StopAsync().GetAwaiter().GetResult();
_host.Dispose();
}
}
}

View File

@@ -0,0 +1,386 @@
using System;
using System.Collections.Concurrent;
using System.Collections.Generic;
using System.Linq;
using System.Threading;
using Microsoft.Extensions.Logging;
using MareSynchronos.WebAPI.AutoDetect;
using MareSynchronos.MareConfiguration;
using MareSynchronos.Services;
using MareSynchronos.Services.Mediator;
using NotificationType = MareSynchronos.MareConfiguration.Models.NotificationType;
using MareSynchronos.WebAPI;
using MareSynchronos.API.Dto.User;
using MareSynchronos.API.Data;
namespace MareSynchronos.Services.AutoDetect;
public class AutoDetectRequestService
{
private readonly ILogger<AutoDetectRequestService> _logger;
private readonly DiscoveryConfigProvider _configProvider;
private readonly DiscoveryApiClient _client;
private readonly MareConfigService _configService;
private readonly DalamudUtilService _dalamud;
private readonly MareMediator _mediator;
private readonly object _syncRoot = new();
private readonly Dictionary<string, DateTime> _activeCooldowns = new(StringComparer.Ordinal);
private readonly Dictionary<string, RefusalTracker> _refusalTrackers = new(StringComparer.Ordinal);
private readonly ConcurrentDictionary<string, PendingRequestInfo> _pendingRequests = new(StringComparer.Ordinal);
private static readonly TimeSpan RequestCooldown = TimeSpan.FromMinutes(5);
private static readonly TimeSpan RefusalLockDuration = TimeSpan.FromMinutes(15);
private readonly ApiController _apiController;
public AutoDetectRequestService(ILogger<AutoDetectRequestService> logger, DiscoveryConfigProvider configProvider, DiscoveryApiClient client, MareConfigService configService, MareMediator mediator, DalamudUtilService dalamudUtilService, ApiController apiController)
{
_logger = logger;
_configProvider = configProvider;
_client = client;
_configService = configService;
_mediator = mediator;
_dalamud = dalamudUtilService;
_apiController = apiController;
}
public async Task<bool> SendRequestAsync(string? token, string? uid = null, string? targetDisplayName = null, CancellationToken ct = default)
{
if (!_configService.Current.AllowAutoDetectPairRequests)
{
_logger.LogDebug("Nearby request blocked: AllowAutoDetectPairRequests is disabled");
_mediator.Publish(new NotificationMessage("Nearby request blocked", "Enable 'Allow pair requests' in Settings to send requests.", NotificationType.Info));
return false;
}
if (string.IsNullOrEmpty(token) && string.IsNullOrEmpty(uid))
{
_logger.LogDebug("Nearby request blocked: no token or UID provided");
return false;
}
var targetKey = BuildTargetKey(uid, token, targetDisplayName);
if (!string.IsNullOrEmpty(targetKey))
{
var now = DateTime.UtcNow;
lock (_syncRoot)
{
if (_refusalTrackers.TryGetValue(targetKey, out var tracker))
{
if (tracker.LockUntil.HasValue && tracker.LockUntil.Value > now)
{
PublishLockNotification(tracker.LockUntil.Value - now);
return false;
}
if (tracker.LockUntil.HasValue && tracker.LockUntil.Value <= now)
{
tracker.LockUntil = null;
tracker.Count = 0;
if (tracker.Count == 0 && tracker.LockUntil == null)
{
_refusalTrackers.Remove(targetKey);
}
}
}
if (_activeCooldowns.TryGetValue(targetKey, out var lastSent))
{
var elapsed = now - lastSent;
if (elapsed < RequestCooldown)
{
PublishCooldownNotification(RequestCooldown - elapsed);
return false;
}
if (elapsed >= RequestCooldown)
{
_activeCooldowns.Remove(targetKey);
}
}
}
}
var endpoint = _configProvider.RequestEndpoint;
if (string.IsNullOrEmpty(endpoint))
{
_logger.LogDebug("No request endpoint configured");
_mediator.Publish(new NotificationMessage("Nearby request failed", "Server does not expose request endpoint.", NotificationType.Error));
return false;
}
string? displayName = null;
try
{
var me = await _dalamud.RunOnFrameworkThread(() => _dalamud.GetPlayerCharacter()).ConfigureAwait(false);
displayName = me?.Name.TextValue;
}
catch { }
var requestToken = string.IsNullOrEmpty(token) ? null : token;
var requestUid = requestToken == null ? uid : null;
_logger.LogInformation("Nearby: sending pair request via {endpoint}", endpoint);
var ok = await _client.SendRequestAsync(endpoint!, requestToken, requestUid, displayName, ct).ConfigureAwait(false);
if (ok)
{
if (!string.IsNullOrEmpty(targetKey))
{
lock (_syncRoot)
{
_activeCooldowns[targetKey] = DateTime.UtcNow;
if (_refusalTrackers.TryGetValue(targetKey, out var tracker))
{
tracker.Count = 0;
tracker.LockUntil = null;
if (tracker.Count == 0 && tracker.LockUntil == null)
{
_refusalTrackers.Remove(targetKey);
}
}
}
}
_mediator.Publish(new NotificationMessage("Nearby request sent", "The other user will receive a request notification.", NotificationType.Info));
var pendingKey = EnsureTargetKey(targetKey);
var label = !string.IsNullOrWhiteSpace(targetDisplayName)
? targetDisplayName!
: (!string.IsNullOrEmpty(uid) ? uid : (!string.IsNullOrEmpty(token) ? token : pendingKey));
_pendingRequests[pendingKey] = new PendingRequestInfo(pendingKey, uid, token, label, DateTime.UtcNow);
}
else
{
if (!string.IsNullOrEmpty(targetKey))
{
var now = DateTime.UtcNow;
lock (_syncRoot)
{
_activeCooldowns.Remove(targetKey);
if (!_refusalTrackers.TryGetValue(targetKey, out var tracker))
{
tracker = new RefusalTracker();
_refusalTrackers[targetKey] = tracker;
}
if (tracker.LockUntil.HasValue && tracker.LockUntil.Value <= now)
{
tracker.LockUntil = null;
tracker.Count = 0;
}
tracker.Count++;
if (tracker.Count >= 3)
{
tracker.LockUntil = now.Add(RefusalLockDuration);
}
}
_pendingRequests.TryRemove(targetKey, out _);
}
_mediator.Publish(new NotificationMessage("Nearby request failed", "The server rejected the request. Try again soon.", NotificationType.Warning));
}
return ok;
}
public async Task<bool> SendAcceptNotifyAsync(string targetUid, CancellationToken ct = default)
{
var endpoint = _configProvider.AcceptEndpoint;
if (string.IsNullOrEmpty(endpoint))
{
_logger.LogDebug("No accept endpoint configured");
return false;
}
string? displayName = null;
try
{
var me = await _dalamud.RunOnFrameworkThread(() => _dalamud.GetPlayerCharacter()).ConfigureAwait(false);
displayName = me?.Name.TextValue;
}
catch { }
_logger.LogInformation("Nearby: sending accept notify via {endpoint}", endpoint);
return await _client.SendAcceptAsync(endpoint!, targetUid, displayName, ct).ConfigureAwait(false);
}
public async Task<bool> SendDirectUidRequestAsync(string uid, string? targetDisplayName = null, CancellationToken ct = default)
{
if (!_configService.Current.AllowAutoDetectPairRequests)
{
_logger.LogDebug("Nearby request blocked: AllowAutoDetectPairRequests is disabled");
_mediator.Publish(new NotificationMessage("Nearby request blocked", "Enable 'Allow pair requests' in Settings to send requests.", NotificationType.Info));
return false;
}
if (string.IsNullOrEmpty(uid))
{
_logger.LogDebug("Direct pair request aborted: UID is empty");
return false;
}
var targetKey = BuildTargetKey(uid, null, targetDisplayName);
if (!string.IsNullOrEmpty(targetKey))
{
var now = DateTime.UtcNow;
lock (_syncRoot)
{
if (_refusalTrackers.TryGetValue(targetKey, out var tracker))
{
if (tracker.LockUntil.HasValue && tracker.LockUntil.Value > now)
{
PublishLockNotification(tracker.LockUntil.Value - now);
return false;
}
if (tracker.LockUntil.HasValue && tracker.LockUntil.Value <= now)
{
tracker.LockUntil = null;
tracker.Count = 0;
if (tracker.Count == 0 && tracker.LockUntil == null)
{
_refusalTrackers.Remove(targetKey);
}
}
}
if (_activeCooldowns.TryGetValue(targetKey, out var lastSent))
{
var elapsed = now - lastSent;
if (elapsed < RequestCooldown)
{
PublishCooldownNotification(RequestCooldown - elapsed);
return false;
}
if (elapsed >= RequestCooldown)
{
_activeCooldowns.Remove(targetKey);
}
}
}
}
try
{
await _apiController.UserAddPair(new UserDto(new UserData(uid))).ConfigureAwait(false);
if (!string.IsNullOrEmpty(targetKey))
{
lock (_syncRoot)
{
_activeCooldowns[targetKey] = DateTime.UtcNow;
if (_refusalTrackers.TryGetValue(targetKey, out var tracker))
{
tracker.Count = 0;
tracker.LockUntil = null;
if (tracker.Count == 0 && tracker.LockUntil == null)
{
_refusalTrackers.Remove(targetKey);
}
}
}
}
_mediator.Publish(new NotificationMessage("Nearby request sent", "The other user will receive a request notification.", NotificationType.Info));
var pendingKey = EnsureTargetKey(targetKey);
var label = !string.IsNullOrWhiteSpace(targetDisplayName) ? targetDisplayName! : uid;
_pendingRequests[pendingKey] = new PendingRequestInfo(pendingKey, uid, null, label, DateTime.UtcNow);
return true;
}
catch (Exception ex)
{
_logger.LogWarning(ex, "Direct pair request failed for {uid}", uid);
if (!string.IsNullOrEmpty(targetKey))
{
var now = DateTime.UtcNow;
lock (_syncRoot)
{
_activeCooldowns.Remove(targetKey);
if (!_refusalTrackers.TryGetValue(targetKey, out var tracker))
{
tracker = new RefusalTracker();
_refusalTrackers[targetKey] = tracker;
}
if (tracker.LockUntil.HasValue && tracker.LockUntil.Value <= now)
{
tracker.LockUntil = null;
tracker.Count = 0;
}
tracker.Count++;
if (tracker.Count >= 3)
{
tracker.LockUntil = now.Add(RefusalLockDuration);
}
}
_pendingRequests.TryRemove(targetKey, out _);
}
_mediator.Publish(new NotificationMessage("Nearby request failed", "The server rejected the request. Try again soon.", NotificationType.Warning));
return false;
}
}
private static string? BuildTargetKey(string? uid, string? token, string? displayName)
{
if (!string.IsNullOrEmpty(uid)) return "uid:" + uid;
if (!string.IsNullOrEmpty(token)) return "token:" + token;
if (!string.IsNullOrEmpty(displayName)) return "name:" + displayName;
return null;
}
private void PublishCooldownNotification(TimeSpan remaining)
{
var durationText = FormatDuration(remaining);
_mediator.Publish(new NotificationMessage("Nearby request en attente", $"Nearby request déjà envoyée. Merci d'attendre environ {durationText} avant de réessayer.", NotificationType.Info, TimeSpan.FromSeconds(5)));
}
private void PublishLockNotification(TimeSpan remaining)
{
var durationText = FormatDuration(remaining);
_mediator.Publish(new NotificationMessage("Nearby request bloquée", $"Nearby request bloquée après plusieurs refus. Réessayez dans {durationText}.", NotificationType.Warning, TimeSpan.FromSeconds(5)));
}
private static string FormatDuration(TimeSpan remaining)
{
if (remaining.TotalMinutes >= 1)
{
var minutes = Math.Max(1, (int)Math.Ceiling(remaining.TotalMinutes));
return minutes == 1 ? "1 minute" : minutes + " minutes";
}
var seconds = Math.Max(1, (int)Math.Ceiling(remaining.TotalSeconds));
return seconds == 1 ? "1 seconde" : seconds + " secondes";
}
private sealed class RefusalTracker
{
public int Count;
public DateTime? LockUntil;
}
public IReadOnlyCollection<PendingRequestInfo> GetPendingRequestsSnapshot()
{
return _pendingRequests.Values.OrderByDescending(v => v.SentAt).ToList();
}
public void RemovePendingRequestByUid(string uid)
{
if (string.IsNullOrEmpty(uid)) return;
foreach (var kvp in _pendingRequests)
{
if (string.Equals(kvp.Value.Uid, uid, StringComparison.Ordinal))
{
_pendingRequests.TryRemove(kvp.Key, out _);
break;
}
}
}
public void RemovePendingRequestByKey(string key)
{
if (string.IsNullOrEmpty(key)) return;
_pendingRequests.TryRemove(key, out _);
}
private static string EnsureTargetKey(string? targetKey)
{
return !string.IsNullOrEmpty(targetKey) ? targetKey! : "target:" + Guid.NewGuid().ToString("N");
}
public sealed record PendingRequestInfo(string Key, string? Uid, string? Token, string TargetDisplayName, DateTime SentAt);
}

View File

@@ -0,0 +1,209 @@
using System;
using System.Linq;
using System.Threading;
using System.Threading.Tasks;
using Dalamud.Plugin.Services;
using Lumina.Excel.Sheets;
using MareSynchronos.MareConfiguration;
using MareSynchronos.MareConfiguration.Models;
using MareSynchronos.Services;
using MareSynchronos.Services.Mediator;
using Microsoft.Extensions.Hosting;
using Microsoft.Extensions.Logging;
namespace MareSynchronos.Services.AutoDetect;
public sealed class AutoDetectSuppressionService : IHostedService, IMediatorSubscriber
{
private static readonly string[] ContentTypeKeywords =
[
"dungeon",
"donjon",
"raid",
"trial",
"défi",
"front",
"frontline",
"pvp",
"jcj",
"conflict",
"conflit"
];
private readonly ILogger<AutoDetectSuppressionService> _logger;
private readonly MareConfigService _configService;
private readonly IClientState _clientState;
private readonly IDataManager _dataManager;
private readonly MareMediator _mediator;
private readonly DalamudUtilService _dalamudUtilService;
private bool _isSuppressed;
private bool _hasSavedState;
private bool _savedDiscoveryEnabled;
private bool _savedAllowRequests;
private bool _suppressionWarningShown;
public bool IsSuppressed => _isSuppressed;
public AutoDetectSuppressionService(ILogger<AutoDetectSuppressionService> logger,
MareConfigService configService, IClientState clientState,
IDataManager dataManager, DalamudUtilService dalamudUtilService, MareMediator mediator)
{
_logger = logger;
_configService = configService;
_clientState = clientState;
_dataManager = dataManager;
_dalamudUtilService = dalamudUtilService;
_mediator = mediator;
}
public MareMediator Mediator => _mediator;
public Task StartAsync(CancellationToken cancellationToken)
{
_mediator.Subscribe<ZoneSwitchEndMessage>(this, _ => UpdateSuppressionState());
_mediator.Subscribe<DalamudLoginMessage>(this, _ => UpdateSuppressionState());
_mediator.Subscribe<DalamudLogoutMessage>(this, _ => ClearSuppression());
UpdateSuppressionState();
return Task.CompletedTask;
}
public Task StopAsync(CancellationToken cancellationToken)
{
_mediator.UnsubscribeAll(this);
return Task.CompletedTask;
}
private void UpdateSuppressionState()
{
_ = _dalamudUtilService.RunOnFrameworkThread(() =>
{
try
{
if (!_clientState.IsLoggedIn || _clientState.LocalPlayer == null)
{
ClearSuppression();
return;
}
uint territoryId = _clientState.TerritoryType;
bool shouldSuppress = ShouldSuppressForTerritory(territoryId);
ApplySuppression(shouldSuppress, territoryId);
}
catch (Exception ex)
{
_logger.LogWarning(ex, "Failed to update AutoDetect suppression state");
}
});
}
private void ApplySuppression(bool shouldSuppress, uint territoryId)
{
if (shouldSuppress)
{
if (!_isSuppressed)
{
_savedDiscoveryEnabled = _configService.Current.EnableAutoDetectDiscovery;
_savedAllowRequests = _configService.Current.AllowAutoDetectPairRequests;
_hasSavedState = true;
_isSuppressed = true;
}
bool changed = false;
if (_configService.Current.EnableAutoDetectDiscovery)
{
_configService.Current.EnableAutoDetectDiscovery = false;
changed = true;
}
if (_configService.Current.AllowAutoDetectPairRequests)
{
_configService.Current.AllowAutoDetectPairRequests = false;
changed = true;
}
if (changed)
{
_logger.LogInformation("AutoDetect temporarily disabled in instanced content (territory {territoryId}).", territoryId);
if (!_suppressionWarningShown)
{
_suppressionWarningShown = true;
const string warningText = "Zone instanciée détectée : les fonctions AutoDetect/Nearby sont coupées pour économiser de la bande passante.";
_mediator.Publish(new DualNotificationMessage("AutoDetect désactivé",
warningText,
NotificationType.Warning, TimeSpan.FromSeconds(5)));
}
}
return;
}
else
{
if (!_isSuppressed) return;
bool changed = false;
bool wasSuppressed = _suppressionWarningShown;
if (_hasSavedState)
{
if (_configService.Current.EnableAutoDetectDiscovery != _savedDiscoveryEnabled)
{
_configService.Current.EnableAutoDetectDiscovery = _savedDiscoveryEnabled;
changed = true;
}
if (_configService.Current.AllowAutoDetectPairRequests != _savedAllowRequests)
{
_configService.Current.AllowAutoDetectPairRequests = _savedAllowRequests;
changed = true;
}
}
_isSuppressed = false;
_hasSavedState = false;
_suppressionWarningShown = false;
if (changed || wasSuppressed)
{
_logger.LogInformation("AutoDetect restored after leaving instanced content (territory {territoryId}).", territoryId);
const string restoredText = "Vous avez quitté la zone instanciée : AutoDetect/Nearby fonctionnent de nouveau.";
_mediator.Publish(new DualNotificationMessage("AutoDetect réactivé",
restoredText,
NotificationType.Info, TimeSpan.FromSeconds(5)));
}
}
}
private void ClearSuppression()
{
if (!_isSuppressed) return;
_isSuppressed = false;
if (_hasSavedState)
{
_configService.Current.EnableAutoDetectDiscovery = _savedDiscoveryEnabled;
_configService.Current.AllowAutoDetectPairRequests = _savedAllowRequests;
}
_hasSavedState = false;
_suppressionWarningShown = false;
}
private bool ShouldSuppressForTerritory(uint territoryId)
{
if (territoryId == 0) return false;
var cfcSheet = _dataManager.GetExcelSheet<ContentFinderCondition>();
if (cfcSheet == null) return false;
var cfc = cfcSheet.FirstOrDefault(c => c.TerritoryType.RowId == territoryId);
if (cfc.RowId == 0) return false;
if (MatchesSuppressionKeyword(cfc.Name.ToString())) return true;
var contentType = cfc.ContentType.Value;
if (contentType.RowId == 0) return false;
return MatchesSuppressionKeyword(contentType.Name.ToString());
}
private static bool MatchesSuppressionKeyword(string? text)
{
if (string.IsNullOrWhiteSpace(text)) return false;
return ContentTypeKeywords.Any(keyword => text.Contains(keyword, StringComparison.OrdinalIgnoreCase));
}
}

View File

@@ -0,0 +1,170 @@
using System.Text.Json;
using System.Text.Json.Serialization;
using Microsoft.Extensions.Logging;
using MareSynchronos.Services.Mediator;
using MareSynchronos.Services.ServerConfiguration;
using MareSynchronos.WebAPI.SignalR;
using System.Net.Http;
using System.Net.Http.Headers;
using System.Reflection;
namespace MareSynchronos.Services.AutoDetect;
public class DiscoveryConfigProvider
{
private readonly ILogger<DiscoveryConfigProvider> _logger;
private readonly ServerConfigurationManager _serverManager;
private readonly TokenProvider _tokenProvider;
private WellKnownRoot? _config;
public DiscoveryConfigProvider(ILogger<DiscoveryConfigProvider> logger, ServerConfigurationManager serverManager, TokenProvider tokenProvider)
{
_logger = logger;
_serverManager = serverManager;
_tokenProvider = tokenProvider;
}
public bool HasConfig => _config != null;
public bool NearbyEnabled => _config?.NearbyDiscovery?.Enabled ?? false;
public byte[]? Salt => _config?.NearbyDiscovery?.SaltBytes;
public string? SaltB64 => _config?.NearbyDiscovery?.SaltB64;
public DateTimeOffset? SaltExpiresAt => _config?.NearbyDiscovery?.SaltExpiresAt;
public int RefreshSec => _config?.NearbyDiscovery?.RefreshSec ?? 300;
public int MinQueryIntervalMs => _config?.NearbyDiscovery?.Policies?.MinQueryIntervalMs ?? 2000;
public int MaxQueryBatch => _config?.NearbyDiscovery?.Policies?.MaxQueryBatch ?? 100;
public string? PublishEndpoint => _config?.NearbyDiscovery?.Endpoints?.Publish;
public string? QueryEndpoint => _config?.NearbyDiscovery?.Endpoints?.Query;
public string? RequestEndpoint => _config?.NearbyDiscovery?.Endpoints?.Request;
public string? AcceptEndpoint => _config?.NearbyDiscovery?.Endpoints?.Accept;
public bool TryLoadFromStapled()
{
try
{
var json = _tokenProvider.GetStapledWellKnown(_serverManager.CurrentApiUrl);
if (string.IsNullOrEmpty(json)) return false;
var root = JsonSerializer.Deserialize<WellKnownRoot>(json!);
if (root == null) return false;
root.NearbyDiscovery?.Hydrate();
_config = root;
_logger.LogDebug("Loaded Nearby well-known (stapled), enabled={enabled}, expires={exp}", NearbyEnabled, _config?.NearbyDiscovery?.SaltExpiresAt);
return true;
}
catch (Exception ex)
{
_logger.LogWarning(ex, "Failed to parse stapled well-known");
return false;
}
}
public async Task<bool> TryFetchFromServerAsync(CancellationToken ct = default)
{
try
{
var baseUrl = _serverManager.CurrentApiUrl
.Replace("wss://", "https://", StringComparison.OrdinalIgnoreCase)
.Replace("ws://", "http://", StringComparison.OrdinalIgnoreCase);
// Try likely candidates based on nginx config
string[] candidates =
[
"/.well-known/Umbra/client", // matches provided nginx
"/.well-known/umbra", // lowercase variant
];
using var http = new HttpClient();
try
{
var ver = Assembly.GetExecutingAssembly().GetName().Version!;
http.DefaultRequestHeaders.UserAgent.Add(new ProductInfoHeaderValue("MareSynchronos", $"{ver.Major}.{ver.Minor}.{ver.Build}"));
}
catch { }
foreach (var path in candidates)
{
try
{
var uri = new Uri(new Uri(baseUrl), path);
var json = await http.GetStringAsync(uri, ct).ConfigureAwait(false);
if (string.IsNullOrEmpty(json)) continue;
var root = JsonSerializer.Deserialize<WellKnownRoot>(json);
if (root == null) continue;
root.NearbyDiscovery?.Hydrate();
_config = root;
_logger.LogInformation("Loaded Nearby well-known (http {path}), enabled={enabled}", path, NearbyEnabled);
return true;
}
catch (Exception ex)
{
_logger.LogDebug(ex, "Nearby well-known fetch failed for {path}", path);
}
}
_logger.LogInformation("Nearby well-known not found via HTTP candidates");
return false;
}
catch (Exception ex)
{
_logger.LogWarning(ex, "Failed to fetch Nearby well-known via HTTP");
return false;
}
}
public bool IsExpired()
{
if (_config?.NearbyDiscovery?.SaltExpiresAt == null) return false;
return DateTimeOffset.UtcNow > _config.NearbyDiscovery.SaltExpiresAt;
}
// DTOs for well-known JSON
private sealed class WellKnownRoot
{
[JsonPropertyName("features")] public Features? Features { get; set; }
[JsonPropertyName("nearby_discovery")] public Nearby? NearbyDiscovery { get; set; }
}
private sealed class Features
{
[JsonPropertyName("nearby_discovery")] public bool NearbyDiscovery { get; set; }
}
private sealed class Nearby
{
[JsonPropertyName("enabled")] public bool Enabled { get; set; }
[JsonPropertyName("hash_algo")] public string? HashAlgo { get; set; }
[JsonPropertyName("salt_b64")] public string? SaltB64 { get; set; }
[JsonPropertyName("salt_expires_at")] public string? SaltExpiresAtRaw { get; set; }
[JsonPropertyName("refresh_sec")] public int RefreshSec { get; set; } = 300;
[JsonPropertyName("endpoints")] public Endpoints? Endpoints { get; set; }
[JsonPropertyName("policies")] public Policies? Policies { get; set; }
[JsonIgnore] public byte[]? SaltBytes { get; private set; }
[JsonIgnore] public DateTimeOffset? SaltExpiresAt { get; private set; }
public void Hydrate()
{
try { SaltBytes = string.IsNullOrEmpty(SaltB64) ? null : Convert.FromBase64String(SaltB64!); } catch { SaltBytes = null; }
if (DateTimeOffset.TryParse(SaltExpiresAtRaw, out var dto)) SaltExpiresAt = dto;
}
}
private sealed class Endpoints
{
[JsonPropertyName("publish")] public string? Publish { get; set; }
[JsonPropertyName("query")] public string? Query { get; set; }
[JsonPropertyName("request")] public string? Request { get; set; }
[JsonPropertyName("accept")] public string? Accept { get; set; }
}
private sealed class Policies
{
[JsonPropertyName("max_query_batch")] public int MaxQueryBatch { get; set; } = 100;
[JsonPropertyName("min_query_interval_ms")] public int MinQueryIntervalMs { get; set; } = 2000;
[JsonPropertyName("rate_limit_per_min")] public int RateLimitPerMin { get; set; } = 30;
[JsonPropertyName("token_ttl_sec")] public int TokenTtlSec { get; set; } = 120;
}
}

View File

@@ -0,0 +1,514 @@
using System;
using Microsoft.Extensions.Logging;
using Microsoft.Extensions.Hosting;
using MareSynchronos.Services.Mediator;
using MareSynchronos.MareConfiguration;
using MareSynchronos.Services.ServerConfiguration;
using MareSynchronos.WebAPI.AutoDetect;
using Dalamud.Plugin.Services;
using System.Numerics;
using System.Linq;
using System.Collections.Generic;
using MareSynchronos.Utils;
namespace MareSynchronos.Services.AutoDetect;
public class NearbyDiscoveryService : IHostedService, IMediatorSubscriber
{
private readonly ILogger<NearbyDiscoveryService> _logger;
private readonly MareMediator _mediator;
private readonly MareConfigService _config;
private readonly DiscoveryConfigProvider _configProvider;
private readonly DalamudUtilService _dalamud;
private readonly IObjectTable _objectTable;
private readonly DiscoveryApiClient _api;
private CancellationTokenSource? _loopCts;
private string? _lastPublishedSignature;
private bool _loggedLocalOnly;
private int _lastLocalCount = -1;
private int _lastMatchCount = -1;
private bool _loggedConfigReady;
private string? _lastSnapshotSig;
private volatile bool _isConnected;
private bool _notifiedDisabled;
private bool _notifiedEnabled;
private bool _disableSent;
private bool _lastAutoDetectState;
private DateTime _lastHeartbeat = DateTime.MinValue;
private static readonly TimeSpan HeartbeatInterval = TimeSpan.FromSeconds(75);
private readonly object _entriesLock = new();
private List<NearbyEntry> _lastEntries = [];
public NearbyDiscoveryService(ILogger<NearbyDiscoveryService> logger, MareMediator mediator,
MareConfigService config, DiscoveryConfigProvider configProvider, DalamudUtilService dalamudUtilService,
IObjectTable objectTable, DiscoveryApiClient api)
{
_logger = logger;
_mediator = mediator;
_config = config;
_configProvider = configProvider;
_dalamud = dalamudUtilService;
_objectTable = objectTable;
_api = api;
}
public MareMediator Mediator => _mediator;
public Task StartAsync(CancellationToken cancellationToken)
{
CancelAndDispose(ref _loopCts);
_loopCts = new CancellationTokenSource();
_mediator.Subscribe<ConnectedMessage>(this, _ => { _isConnected = true; _configProvider.TryLoadFromStapled(); });
_mediator.Subscribe<DisconnectedMessage>(this, _ => { _isConnected = false; _lastPublishedSignature = null; });
_mediator.Subscribe<AllowPairRequestsToggled>(this, OnAllowPairRequestsToggled);
_ = Task.Run(() => Loop(_loopCts.Token));
_lastAutoDetectState = _config.Current.EnableAutoDetectDiscovery;
return Task.CompletedTask;
}
private async void OnAllowPairRequestsToggled(AllowPairRequestsToggled msg)
{
try
{
if (!_config.Current.EnableAutoDetectDiscovery) return;
// Force a publish now so the server immediately reflects the new allow/deny state
_lastPublishedSignature = null; // ensure next loop won't skip
await PublishSelfOnceAsync(CancellationToken.None).ConfigureAwait(false);
}
catch (Exception ex)
{
_logger.LogDebug(ex, "OnAllowPairRequestsToggled failed");
}
}
private async Task PublishSelfOnceAsync(CancellationToken ct)
{
try
{
if (!_config.Current.EnableAutoDetectDiscovery || !_dalamud.IsLoggedIn || !_isConnected) return;
if (!_configProvider.HasConfig || _configProvider.IsExpired())
{
if (!_configProvider.TryLoadFromStapled())
{
await _configProvider.TryFetchFromServerAsync(ct).ConfigureAwait(false);
}
}
var ep = _configProvider.PublishEndpoint;
var saltBytes = _configProvider.Salt;
if (string.IsNullOrEmpty(ep) || saltBytes is not { Length: > 0 }) return;
var saltHex = Convert.ToHexString(saltBytes);
string? displayName = null;
ushort meWorld = 0;
try
{
var me = await _dalamud.RunOnFrameworkThread(() => _dalamud.GetPlayerCharacter()).ConfigureAwait(false);
if (me != null)
{
displayName = me.Name.TextValue;
if (me is Dalamud.Game.ClientState.Objects.SubKinds.IPlayerCharacter mePc)
meWorld = (ushort)mePc.HomeWorld.RowId;
}
}
catch { }
if (string.IsNullOrEmpty(displayName)) return;
var selfHash = (saltHex + displayName + meWorld.ToString()).GetHash256();
var ok = await _api.PublishAsync(ep!, new[] { selfHash }, displayName, ct, _config.Current.AllowAutoDetectPairRequests).ConfigureAwait(false);
_logger.LogInformation("Nearby publish (manual/immediate): {result}", ok ? "success" : "failed");
if (ok)
{
_lastPublishedSignature = selfHash;
_lastHeartbeat = DateTime.UtcNow;
}
}
catch (Exception ex)
{
_logger.LogDebug(ex, "Immediate publish failed");
}
}
public Task StopAsync(CancellationToken cancellationToken)
{
_mediator.UnsubscribeAll(this);
CancelAndDispose(ref _loopCts);
return Task.CompletedTask;
}
public List<NearbyEntry> SnapshotEntries()
{
lock (_entriesLock)
{
return _lastEntries.ToList();
}
}
private void UpdateSnapshot(List<NearbyEntry> entries)
{
lock (_entriesLock)
{
_lastEntries = entries.ToList();
}
}
private static void CancelAndDispose(ref CancellationTokenSource? cts)
{
if (cts == null) return;
try
{
cts.Cancel();
}
catch (ObjectDisposedException)
{
}
cts.Dispose();
cts = null;
}
private async Task Loop(CancellationToken ct)
{
_configProvider.TryLoadFromStapled();
while (!ct.IsCancellationRequested)
{
try
{
bool currentState = _config.Current.EnableAutoDetectDiscovery;
if (currentState != _lastAutoDetectState)
{
_lastAutoDetectState = currentState;
if (currentState)
{
// Force immediate publish on toggle ON
try
{
// Ensure well-known is present
if (!_configProvider.HasConfig || _configProvider.IsExpired())
{
if (!_configProvider.TryLoadFromStapled())
{
await _configProvider.TryFetchFromServerAsync(ct).ConfigureAwait(false);
}
}
var ep = _configProvider.PublishEndpoint;
var saltBytes = _configProvider.Salt;
if (!string.IsNullOrEmpty(ep) && saltBytes is { Length: > 0 })
{
var saltHex = Convert.ToHexString(saltBytes);
string? displayName = null;
ushort meWorld = 0;
try
{
var me = await _dalamud.RunOnFrameworkThread(() => _dalamud.GetPlayerCharacter()).ConfigureAwait(false);
if (me != null)
{
displayName = me.Name.TextValue;
if (me is Dalamud.Game.ClientState.Objects.SubKinds.IPlayerCharacter mePc)
meWorld = (ushort)mePc.HomeWorld.RowId;
}
}
catch { }
if (!string.IsNullOrEmpty(displayName))
{
var selfHash = (saltHex + displayName + meWorld.ToString()).GetHash256();
_lastPublishedSignature = null; // ensure future loop doesn't skip
var okNow = await _api.PublishAsync(ep!, new[] { selfHash }, displayName, ct, _config.Current.AllowAutoDetectPairRequests).ConfigureAwait(false);
_logger.LogInformation("Nearby immediate publish on toggle ON: {result}", okNow ? "success" : "failed");
}
}
}
catch (Exception ex)
{
_logger.LogDebug(ex, "Nearby immediate publish on toggle ON failed");
}
if (!_notifiedEnabled)
{
_mediator.Publish(new NotificationMessage("Nearby Detection", "AutoDetect enabled : you are now visible.", default));
_notifiedEnabled = true;
_notifiedDisabled = false;
_disableSent = false;
}
}
else
{
var ep = _configProvider.PublishEndpoint;
if (!string.IsNullOrEmpty(ep) && !_disableSent)
{
var disableUrl = ep.Replace("/publish", "/disable");
try { await _api.DisableAsync(disableUrl, ct).ConfigureAwait(false); _disableSent = true; } catch { }
}
if (!_notifiedDisabled)
{
_mediator.Publish(new NotificationMessage("Nearby Detection", "AutoDetect disabled : you are not visible.", default));
_notifiedDisabled = true;
_notifiedEnabled = false;
}
}
}
if (!_config.Current.EnableAutoDetectDiscovery || !_dalamud.IsLoggedIn || !_isConnected)
{
if (!_config.Current.EnableAutoDetectDiscovery && !string.IsNullOrEmpty(_configProvider.PublishEndpoint))
{
var disableUrl = _configProvider.PublishEndpoint.Replace("/publish", "/disable");
try
{
if (!_disableSent)
{
await _api.DisableAsync(disableUrl, ct).ConfigureAwait(false);
_disableSent = true;
}
}
catch { }
if (!_notifiedDisabled)
{
_mediator.Publish(new NotificationMessage("Nearby Detection", "AutoDetect disabled : you are not visible.", default));
_notifiedDisabled = true;
_notifiedEnabled = false;
}
}
await Task.Delay(1000, ct).ConfigureAwait(false);
continue;
}
if (!_configProvider.HasConfig || _configProvider.IsExpired())
{
if (!_configProvider.TryLoadFromStapled())
{
await _configProvider.TryFetchFromServerAsync(ct).ConfigureAwait(false);
}
}
else if (!_loggedConfigReady && _configProvider.NearbyEnabled)
{
_loggedConfigReady = true;
_logger.LogInformation("Nearby: well-known loaded and enabled; refresh={refresh}s, expires={exp}", _configProvider.RefreshSec, _configProvider.SaltExpiresAt);
}
var entries = await GetLocalNearbyAsync().ConfigureAwait(false);
// Log when local count changes (including 0) to indicate activity
if (entries.Count != _lastLocalCount)
{
_lastLocalCount = entries.Count;
_logger.LogTrace("Nearby: {count} players detected locally", _lastLocalCount);
}
// Try query server if config and endpoints are present
if (_configProvider.NearbyEnabled && !_configProvider.IsExpired() &&
_configProvider.Salt is { Length: > 0 })
{
try
{
var saltHex = Convert.ToHexString(_configProvider.Salt!);
// map hash->index for result matching
Dictionary<string, int> hashToIndex = new(StringComparer.Ordinal);
List<string> hashes = new(entries.Count);
foreach (var (entry, idx) in entries.Select((e, i) => (e, i)))
{
var h = (saltHex + entry.Name + entry.WorldId.ToString()).GetHash256();
hashToIndex[h] = idx;
hashes.Add(h);
}
try
{
var snapSig = string.Join(',', hashes.OrderBy(s => s, StringComparer.Ordinal)).GetHash256();
if (!string.Equals(snapSig, _lastSnapshotSig, StringComparison.Ordinal))
{
_lastSnapshotSig = snapSig;
var sample = entries.Take(5).Select(e =>
{
var hh = (saltHex + e.Name + e.WorldId.ToString()).GetHash256();
var shortH = hh.Length > 8 ? hh[..8] : hh;
return $"{e.Name}({e.WorldId})->{shortH}";
});
var saltShort = saltHex.Length > 8 ? saltHex[..8] : saltHex;
_logger.LogTrace("Nearby snapshot: {count} entries; salt={saltShort}…; samples=[{samples}]",
entries.Count, saltShort, string.Join(", ", sample));
}
}
catch { }
if (!string.IsNullOrEmpty(_configProvider.PublishEndpoint))
{
string? displayName = null;
string? selfHash = null;
try
{
var me = await _dalamud.RunOnFrameworkThread(() => _dalamud.GetPlayerCharacter()).ConfigureAwait(false);
if (me != null)
{
displayName = me.Name.TextValue;
ushort meWorld = 0;
if (me is Dalamud.Game.ClientState.Objects.SubKinds.IPlayerCharacter mePc)
meWorld = (ushort)mePc.HomeWorld.RowId;
_logger.LogTrace("Nearby self ident: {name} ({world})", displayName, meWorld);
selfHash = (saltHex + displayName + meWorld.ToString()).GetHash256();
}
}
catch { /* ignore */ }
if (!string.IsNullOrEmpty(selfHash))
{
var sig = selfHash!;
if (!string.Equals(sig, _lastPublishedSignature, StringComparison.Ordinal))
{
_lastPublishedSignature = sig;
var shortSelf = selfHash!.Length > 8 ? selfHash[..8] : selfHash;
_logger.LogDebug("Nearby publish: self presence updated (hash={hash})", shortSelf);
var ok = await _api.PublishAsync(_configProvider.PublishEndpoint!, new[] { selfHash! }, displayName, ct, _config.Current.AllowAutoDetectPairRequests).ConfigureAwait(false);
_logger.LogInformation("Nearby publish result: {result}", ok ? "success" : "failed");
if (ok) _lastHeartbeat = DateTime.UtcNow;
if (ok)
{
if (!_notifiedEnabled)
{
_mediator.Publish(new NotificationMessage("Nearby Detection", "AutoDetect enabled : you are now visible.", default));
_notifiedEnabled = true;
_notifiedDisabled = false;
_disableSent = false; // allow future /disable when turning off again
}
}
}
else
{
// No changes; perform heartbeat publish if interval elapsed
if (DateTime.UtcNow - _lastHeartbeat >= HeartbeatInterval)
{
var okHb = await _api.PublishAsync(_configProvider.PublishEndpoint!, new[] { selfHash! }, displayName, ct, _config.Current.AllowAutoDetectPairRequests).ConfigureAwait(false);
_logger.LogDebug("Nearby heartbeat publish: {result}", okHb ? "success" : "failed");
if (okHb) _lastHeartbeat = DateTime.UtcNow;
}
else
{
_logger.LogDebug("Nearby publish skipped (no changes)");
}
}
}
// else: no self character available; skip publish silently
}
// Query for matches if endpoint is available
if (!string.IsNullOrEmpty(_configProvider.QueryEndpoint))
{
// chunked queries
int batch = Math.Max(1, _configProvider.MaxQueryBatch);
List<ServerMatch> allMatches = new();
for (int i = 0; i < hashes.Count; i += batch)
{
var slice = hashes.Skip(i).Take(batch).ToArray();
var res = await _api.QueryAsync(_configProvider.QueryEndpoint!, slice, ct).ConfigureAwait(false);
if (res != null && res.Count > 0) allMatches.AddRange(res);
}
if (allMatches.Count > 0)
{
foreach (var m in allMatches)
{
if (hashToIndex.TryGetValue(m.Hash, out var idx))
{
var e = entries[idx];
entries[idx] = new NearbyEntry(e.Name, e.WorldId, e.Distance, true, m.Token, m.DisplayName, m.Uid);
}
}
}
if (allMatches.Count > 0)
{
_logger.LogInformation("Nearby: server returned {count} matches", allMatches.Count);
}
else
{
_logger.LogTrace("Nearby: server returned {count} matches", allMatches.Count);
}
// Log change in number of Umbra matches
int matchCount = entries.Count(e => e.IsMatch);
if (matchCount != _lastMatchCount)
{
_lastMatchCount = matchCount;
if (matchCount > 0)
{
var matchSamples = entries.Where(e => e.IsMatch).Take(5)
.Select(e => string.IsNullOrEmpty(e.DisplayName) ? e.Name : e.DisplayName!);
_logger.LogInformation("Nearby: {count} Umbra users nearby [{samples}]",
matchCount, string.Join(", ", matchSamples));
}
else
{
_logger.LogTrace("Nearby: {count} Umbra users nearby", matchCount);
}
}
}
}
catch (Exception ex)
{
_logger.LogDebug(ex, "Nearby query failed; falling back to local list");
if (ex.Message.Contains("DISCOVERY_SALT_EXPIRED", StringComparison.OrdinalIgnoreCase))
{
_logger.LogInformation("Nearby: salt expired, refetching well-known");
try { await _configProvider.TryFetchFromServerAsync(ct).ConfigureAwait(false); } catch { }
}
}
}
else
{
if (!_loggedLocalOnly)
{
_loggedLocalOnly = true;
_logger.LogDebug("Nearby: well-known not available or disabled; running in local-only mode");
}
}
UpdateSnapshot(entries);
_mediator.Publish(new DiscoveryListUpdated(entries));
var delayMs = Math.Max(1000, _configProvider.MinQueryIntervalMs);
if (entries.Count == 0) delayMs = Math.Max(delayMs, 5000);
await Task.Delay(delayMs, ct).ConfigureAwait(false);
}
catch (OperationCanceledException) { }
catch (Exception ex)
{
_logger.LogDebug(ex, "NearbyDiscoveryService loop error");
await Task.Delay(2000, ct).ConfigureAwait(false);
}
}
}
private async Task<List<NearbyEntry>> GetLocalNearbyAsync()
{
var list = new List<NearbyEntry>();
try
{
var local = await _dalamud.RunOnFrameworkThread(() => _dalamud.GetPlayerCharacter()).ConfigureAwait(false);
var localPos = local?.Position ?? Vector3.Zero;
int maxDist = Math.Clamp(_config.Current.AutoDetectMaxDistanceMeters, 5, 100);
int limit = Math.Min(200, _objectTable.Length);
for (int i = 0; i < limit; i++)
{
var obj = await _dalamud.RunOnFrameworkThread(() => _objectTable[i]).ConfigureAwait(false);
if (obj == null || obj.ObjectKind != Dalamud.Game.ClientState.Objects.Enums.ObjectKind.Player) continue;
if (local != null && obj.Address == local.Address) continue;
float dist = local == null ? float.NaN : Vector3.Distance(localPos, obj.Position);
if (!float.IsNaN(dist) && dist > maxDist) continue;
string name = obj.Name.TextValue;
ushort worldId = 0;
if (obj is Dalamud.Game.ClientState.Objects.SubKinds.IPlayerCharacter pc)
worldId = (ushort)pc.HomeWorld.RowId;
list.Add(new NearbyEntry(name, worldId, dist, false, null, null, null));
}
}
catch
{
// ignore
}
return list;
}
}

View File

@@ -0,0 +1,116 @@
using System;
using System.Collections.Concurrent;
using System.Text.RegularExpressions;
using MareSynchronos.MareConfiguration.Models;
using MareSynchronos.Services.Mediator;
using MareSynchronos.Services.Notifications;
using MareSynchronos.WebAPI;
using Microsoft.Extensions.Logging;
namespace MareSynchronos.Services.AutoDetect;
public sealed class NearbyPendingService : IMediatorSubscriber
{
private readonly ILogger<NearbyPendingService> _logger;
private readonly MareMediator _mediator;
private readonly ApiController _api;
private readonly AutoDetectRequestService _requestService;
private readonly NotificationTracker _notificationTracker;
private readonly ConcurrentDictionary<string, string> _pending = new(StringComparer.Ordinal);
private static readonly TimeSpan RegexTimeout = TimeSpan.FromSeconds(1);
private static readonly Regex ReqRegex = new(@"^Nearby Request: .+ \[(?<uid>[A-Z0-9]+)\]$", RegexOptions.Compiled | RegexOptions.ExplicitCapture, RegexTimeout);
private static readonly Regex AcceptRegex = new(@"^Nearby Accept: .+ \[(?<uid>[A-Z0-9]+)\]$", RegexOptions.Compiled | RegexOptions.ExplicitCapture, RegexTimeout);
public NearbyPendingService(ILogger<NearbyPendingService> logger, MareMediator mediator, ApiController api, AutoDetectRequestService requestService, NotificationTracker notificationTracker)
{
_logger = logger;
_mediator = mediator;
_api = api;
_requestService = requestService;
_notificationTracker = notificationTracker;
_mediator.Subscribe<NotificationMessage>(this, OnNotification);
_mediator.Subscribe<ManualPairInviteMessage>(this, OnManualPairInvite);
}
public MareMediator Mediator => _mediator;
public IReadOnlyDictionary<string, string> Pending => _pending;
private void OnNotification(NotificationMessage msg)
{
// Watch info messages for Nearby request pattern
if (msg.Type != MareSynchronos.MareConfiguration.Models.NotificationType.Info) return;
var ma = AcceptRegex.Match(msg.Message);
if (ma.Success)
{
var uidA = ma.Groups["uid"].Value;
if (!string.IsNullOrEmpty(uidA))
{
_ = _api.UserAddPair(new MareSynchronos.API.Dto.User.UserDto(new MareSynchronos.API.Data.UserData(uidA)));
_pending.TryRemove(uidA, out _);
_requestService.RemovePendingRequestByUid(uidA);
_notificationTracker.Remove(NotificationCategory.AutoDetect, uidA);
_logger.LogInformation("NearbyPending: auto-accepted pairing with {uid}", uidA);
}
return;
}
var m = ReqRegex.Match(msg.Message);
if (!m.Success) return;
var uid = m.Groups["uid"].Value;
if (string.IsNullOrEmpty(uid)) return;
// Try to extract name as everything before space and '['
var name = msg.Message;
try
{
var idx = msg.Message.IndexOf(':');
if (idx >= 0) name = msg.Message[(idx + 1)..].Trim();
var br = name.LastIndexOf('[');
if (br > 0) name = name[..br].Trim();
}
catch { name = uid; }
_pending[uid] = name;
_logger.LogInformation("NearbyPending: received request from {uid} ({name})", uid, name);
_notificationTracker.Upsert(NotificationEntry.AutoDetect(uid, name));
}
private void OnManualPairInvite(ManualPairInviteMessage msg)
{
if (!string.Equals(msg.TargetUid, _api.UID, StringComparison.Ordinal))
return;
var display = !string.IsNullOrWhiteSpace(msg.DisplayName)
? msg.DisplayName!
: (!string.IsNullOrWhiteSpace(msg.SourceAlias) ? msg.SourceAlias : msg.SourceUid);
_pending[msg.SourceUid] = display;
_logger.LogInformation("NearbyPending: received manual invite from {uid} ({name})", msg.SourceUid, display);
_mediator.Publish(new NotificationMessage("Nearby request", $"{display} vous a envoyé une invitation de pair.", NotificationType.Info, TimeSpan.FromSeconds(5)));
_notificationTracker.Upsert(NotificationEntry.AutoDetect(msg.SourceUid, display));
}
public void Remove(string uid)
{
_pending.TryRemove(uid, out _);
_requestService.RemovePendingRequestByUid(uid);
_notificationTracker.Remove(NotificationCategory.AutoDetect, uid);
}
public async Task<bool> AcceptAsync(string uid)
{
try
{
await _api.UserAddPair(new MareSynchronos.API.Dto.User.UserDto(new MareSynchronos.API.Data.UserData(uid))).ConfigureAwait(false);
_pending.TryRemove(uid, out _);
_requestService.RemovePendingRequestByUid(uid);
_ = _requestService.SendAcceptNotifyAsync(uid);
_notificationTracker.Remove(NotificationCategory.AutoDetect, uid);
return true;
}
catch (Exception ex)
{
_logger.LogWarning(ex, "NearbyPending: accept failed for {uid}", uid);
return false;
}
}
}

View File

@@ -0,0 +1,158 @@
using System;
using System.Collections.Generic;
using System.Linq;
using System.Threading;
using System.Threading.Tasks;
using MareSynchronos.API.Data;
using MareSynchronos.API.Dto.Group;
using MareSynchronos.Services.Mediator;
using MareSynchronos.WebAPI;
using Microsoft.Extensions.Hosting;
using Microsoft.Extensions.Logging;
namespace MareSynchronos.Services.AutoDetect;
public sealed class SyncshellDiscoveryService : IHostedService, IMediatorSubscriber
{
private readonly ILogger<SyncshellDiscoveryService> _logger;
private readonly MareMediator _mediator;
private readonly ApiController _apiController;
private readonly SemaphoreSlim _refreshSemaphore = new(1, 1);
private readonly object _entriesLock = new();
private List<SyncshellDiscoveryEntryDto> _entries = [];
private string? _lastError;
private bool _isRefreshing;
public SyncshellDiscoveryService(ILogger<SyncshellDiscoveryService> logger, MareMediator mediator, ApiController apiController)
{
_logger = logger;
_mediator = mediator;
_apiController = apiController;
}
public MareMediator Mediator => _mediator;
public IReadOnlyList<SyncshellDiscoveryEntryDto> Entries
{
get
{
lock (_entriesLock)
{
return _entries.ToList();
}
}
}
public bool IsRefreshing => _isRefreshing;
public string? LastError => _lastError;
public async Task<bool> JoinAsync(string gid, CancellationToken ct)
{
try
{
return await _apiController.SyncshellDiscoveryJoin(new GroupDto(new GroupData(gid))).ConfigureAwait(false);
}
catch (Exception ex)
{
_logger.LogWarning(ex, "Join syncshell discovery failed for {gid}", gid);
return false;
}
}
public async Task<SyncshellDiscoveryStateDto?> GetStateAsync(string gid, CancellationToken ct)
{
try
{
return await _apiController.SyncshellDiscoveryGetState(new GroupDto(new GroupData(gid))).ConfigureAwait(false);
}
catch (Exception ex)
{
_logger.LogWarning(ex, "Failed to fetch syncshell discovery state for {gid}", gid);
return null;
}
}
public async Task<bool> SetVisibilityAsync(string gid, bool visible, CancellationToken ct)
{
return await SetVisibilityAsync(gid, visible, null, null, null, null, null, ct).ConfigureAwait(false);
}
public async Task<bool> SetVisibilityAsync(string gid, bool visible, int? displayDurationHours,
int[]? activeWeekdays, TimeSpan? timeStartLocal, TimeSpan? timeEndLocal, string? timeZone,
CancellationToken ct)
{
try
{
var request = new SyncshellDiscoveryVisibilityRequestDto
{
GID = gid,
AutoDetectVisible = visible,
DisplayDurationHours = displayDurationHours,
ActiveWeekdays = activeWeekdays,
TimeStartLocal = timeStartLocal.HasValue ? new DateTime(timeStartLocal.Value.Ticks).ToString("HH:mm") : null,
TimeEndLocal = timeEndLocal.HasValue ? new DateTime(timeEndLocal.Value.Ticks).ToString("HH:mm") : null,
TimeZone = timeZone,
};
var success = await _apiController.SyncshellDiscoverySetVisibility(request).ConfigureAwait(false);
if (!success) return false;
var state = await GetStateAsync(gid, ct).ConfigureAwait(false);
if (state != null)
{
_mediator.Publish(new SyncshellAutoDetectStateChanged(state.GID, state.AutoDetectVisible, state.PasswordTemporarilyDisabled));
}
return true;
}
catch (Exception ex)
{
_logger.LogWarning(ex, "Failed to set syncshell visibility for {gid}", gid);
return false;
}
}
public async Task RefreshAsync(CancellationToken ct)
{
if (!await _refreshSemaphore.WaitAsync(0, ct).ConfigureAwait(false))
{
return;
}
try
{
_isRefreshing = true;
var discovered = await _apiController.SyncshellDiscoveryList().ConfigureAwait(false);
lock (_entriesLock)
{
_entries = discovered ?? [];
}
_lastError = null;
_mediator.Publish(new SyncshellDiscoveryUpdated(Entries.ToList()));
}
catch (Exception ex)
{
_logger.LogWarning(ex, "Failed to refresh syncshell discovery list");
_lastError = ex.Message;
}
finally
{
_isRefreshing = false;
_refreshSemaphore.Release();
}
}
public Task StartAsync(CancellationToken cancellationToken)
{
_mediator.Subscribe<ConnectedMessage>(this, msg =>
{
_ = RefreshAsync(CancellationToken.None);
});
return Task.CompletedTask;
}
public Task StopAsync(CancellationToken cancellationToken)
{
_mediator.UnsubscribeAll(this);
return Task.CompletedTask;
}
}

View File

@@ -11,6 +11,7 @@ using MareSynchronos.Services.CharaData.Models;
using MareSynchronos.Utils;
using MareSynchronos.WebAPI.Files;
using Microsoft.Extensions.Logging;
using System.Threading;
namespace MareSynchronos.Services;
@@ -295,6 +296,32 @@ public sealed class CharaDataFileHandler : IDisposable
}
}
internal async Task<byte[]?> CreateCharaFileBytesAsync(string description, CancellationToken token = default)
{
var tempFilePath = Path.Combine(Path.GetTempPath(), "umbra_mcdfshare_" + Guid.NewGuid().ToString("N") + ".mcdf");
try
{
await SaveCharaFileAsync(description, tempFilePath).ConfigureAwait(false);
if (!File.Exists(tempFilePath)) return null;
token.ThrowIfCancellationRequested();
return await File.ReadAllBytesAsync(tempFilePath, token).ConfigureAwait(false);
}
finally
{
try
{
if (File.Exists(tempFilePath))
{
File.Delete(tempFilePath);
}
}
catch
{
// ignored
}
}
}
internal async Task<List<string>> UploadFiles(List<string> fileList, ValueProgress<string> uploadProgress, CancellationToken token)
{
return await _fileUploadManager.UploadFiles(fileList, uploadProgress, token).ConfigureAwait(false);

View File

@@ -13,7 +13,9 @@ using MareSynchronos.Utils;
using MareSynchronos.WebAPI;
using Microsoft.Extensions.Logging;
using System.Collections.Concurrent;
using System.IO;
using System.Text;
using System.Threading;
namespace MareSynchronos.Services;
@@ -457,6 +459,14 @@ public sealed partial class CharaDataManager : DisposableMediatorSubscriberBase
LoadedMcdfHeader = _fileHandler.LoadCharaFileHeader(filePath);
}
public async Task<string> LoadMcdfFromBytes(byte[] data, CancellationToken token = default)
{
var tempFilePath = Path.Combine(Path.GetTempPath(), "umbra_mcdfshare_" + Guid.NewGuid().ToString("N") + ".mcdf");
await File.WriteAllBytesAsync(tempFilePath, data, token).ConfigureAwait(false);
LoadedMcdfHeader = _fileHandler.LoadCharaFileHeader(tempFilePath);
return tempFilePath;
}
public void McdfApplyToTarget(string charaName)
{
if (LoadedMcdfHeader == null || !LoadedMcdfHeader.IsCompletedSuccessfully) return;

View File

@@ -28,7 +28,7 @@ public sealed class CharaDataNearbyManager : DisposableMediatorSubscriberBase
private Task? _filterEntriesRunningTask;
private (Guid VfxId, PoseEntryExtended Pose)? _hoveredVfx = null;
private DateTime _lastExecutionTime = DateTime.UtcNow;
private SemaphoreSlim _sharedDataUpdateSemaphore = new(1, 1);
private readonly SemaphoreSlim _sharedDataUpdateSemaphore = new(1, 1);
public CharaDataNearbyManager(ILogger<CharaDataNearbyManager> logger, MareMediator mediator,
DalamudUtilService dalamudUtilService, VfxSpawnManager vfxSpawnManager,
ServerConfigurationManager serverConfigurationManager,
@@ -201,7 +201,7 @@ public sealed class CharaDataNearbyManager : DisposableMediatorSubscriberBase
foreach (var data in _metaInfoCache.Where(d => (string.IsNullOrWhiteSpace(UserNoteFilter)
|| ((d.Key.Alias ?? string.Empty).Contains(UserNoteFilter, StringComparison.OrdinalIgnoreCase)
|| d.Key.UID.Contains(UserNoteFilter, StringComparison.OrdinalIgnoreCase)
|| (_serverConfigurationManager.GetNoteForUid(UserNoteFilter) ?? string.Empty).Contains(UserNoteFilter, StringComparison.OrdinalIgnoreCase))))
|| (_serverConfigurationManager.GetNoteForUid(d.Key.UID) ?? string.Empty).Contains(UserNoteFilter, StringComparison.OrdinalIgnoreCase))))
.ToDictionary(k => k.Key, k => k.Value))
{
// filter all poses based on territory, that always must be correct
@@ -266,26 +266,47 @@ public sealed class CharaDataNearbyManager : DisposableMediatorSubscriberBase
private void ManageWispsNearby(List<PoseEntryExtended> previousPoses)
{
foreach (var data in _nearbyData.Keys)
int maxWisps = _charaDataConfigService.Current.NearbyMaxWisps;
if (maxWisps <= 0)
{
if (_poseVfx.TryGetValue(data, out var _)) continue;
ClearAllVfx();
return;
}
const int hardLimit = 200;
if (maxWisps > hardLimit) maxWisps = hardLimit;
var orderedAllowedPoses = _nearbyData
.OrderBy(kvp => kvp.Value.Distance)
.Take(maxWisps)
.Select(kvp => kvp.Key)
.ToList();
var allowedPoseSet = orderedAllowedPoses.ToHashSet();
foreach (var data in orderedAllowedPoses)
{
if (_poseVfx.TryGetValue(data, out _)) continue;
Guid? vfxGuid = data.MetaInfo.IsOwnData
? _vfxSpawnManager.SpawnObject(data.Position, data.Rotation, Vector3.One * 2, 0.8f, 0.5f, 0.0f, 0.7f)
: _vfxSpawnManager.SpawnObject(data.Position, data.Rotation, Vector3.One * 2);
Guid? vfxGuid;
if (data.MetaInfo.IsOwnData)
{
vfxGuid = _vfxSpawnManager.SpawnObject(data.Position, data.Rotation, Vector3.One * 2, 0.8f, 0.5f, 0.0f, 0.7f);
}
else
{
vfxGuid = _vfxSpawnManager.SpawnObject(data.Position, data.Rotation, Vector3.One * 2);
}
if (vfxGuid != null)
{
_poseVfx[data] = vfxGuid.Value;
}
}
foreach (var data in previousPoses.Except(_nearbyData.Keys))
foreach (var data in previousPoses.Except(allowedPoseSet))
{
if (_poseVfx.Remove(data, out var guid))
{
_vfxSpawnManager.DespawnObject(guid);
}
}
foreach (var data in _poseVfx.Keys.Except(allowedPoseSet).ToList())
{
if (_poseVfx.Remove(data, out var guid))
{

View File

@@ -0,0 +1,309 @@
using System;
using System.Collections.Generic;
using System.IO;
using System.Linq;
using System.Security.Cryptography;
using System.Threading;
using System.Threading.Tasks;
using MareSynchronos.API.Dto.McdfShare;
using MareSynchronos.MareConfiguration.Models;
using MareSynchronos.Services.ServerConfiguration;
using MareSynchronos.WebAPI;
using Microsoft.Extensions.Logging;
namespace MareSynchronos.Services.CharaData;
public sealed class McdfShareManager
{
private readonly ILogger<McdfShareManager> _logger;
private readonly ApiController _apiController;
private readonly CharaDataFileHandler _fileHandler;
private readonly CharaDataManager _charaDataManager;
private readonly ServerConfigurationManager _serverConfigurationManager;
private readonly SemaphoreSlim _operationSemaphore = new(1, 1);
private readonly List<McdfShareEntryDto> _ownShares = new();
private readonly List<McdfShareEntryDto> _sharedWithMe = new();
private Task? _currentTask;
public McdfShareManager(ILogger<McdfShareManager> logger, ApiController apiController,
CharaDataFileHandler fileHandler, CharaDataManager charaDataManager,
ServerConfigurationManager serverConfigurationManager)
{
_logger = logger;
_apiController = apiController;
_fileHandler = fileHandler;
_charaDataManager = charaDataManager;
_serverConfigurationManager = serverConfigurationManager;
}
public IReadOnlyList<McdfShareEntryDto> OwnShares => _ownShares;
public IReadOnlyList<McdfShareEntryDto> SharedShares => _sharedWithMe;
public bool IsBusy => _currentTask is { IsCompleted: false };
public string? LastError { get; private set; }
public string? LastSuccess { get; private set; }
public Task RefreshAsync(CancellationToken token)
{
return RunOperation(() => InternalRefreshAsync(token));
}
public Task CreateShareAsync(string description, IReadOnlyList<string> allowedIndividuals, IReadOnlyList<string> allowedSyncshells, DateTime? expiresAtUtc, CancellationToken token)
{
return RunOperation(async () =>
{
token.ThrowIfCancellationRequested();
var mcdfBytes = await _fileHandler.CreateCharaFileBytesAsync(description, token).ConfigureAwait(false);
if (mcdfBytes == null || mcdfBytes.Length == 0)
{
LastError = "Impossible de préparer les données MCDF.";
return;
}
var secretKey = _serverConfigurationManager.GetSecretKey(out bool hasMultiple);
if (hasMultiple)
{
LastError = "Plusieurs clés secrètes sont configurées pour ce personnage. Corrigez cela dans les paramètres.";
return;
}
if (string.IsNullOrEmpty(secretKey))
{
LastError = "Aucune clé secrète n'est configurée pour ce personnage.";
return;
}
var shareId = Guid.NewGuid();
byte[] salt = RandomNumberGenerator.GetBytes(16);
byte[] nonce = RandomNumberGenerator.GetBytes(12);
byte[] key = DeriveKey(secretKey, shareId, salt);
byte[] cipher = new byte[mcdfBytes.Length];
byte[] tag = new byte[16];
using (var aes = new AesGcm(key, 16))
{
aes.Encrypt(nonce, mcdfBytes, cipher, tag);
}
var uploadDto = new McdfShareUploadRequestDto
{
ShareId = shareId,
Description = description,
CipherData = cipher,
Nonce = nonce,
Salt = salt,
Tag = tag,
ExpiresAtUtc = expiresAtUtc,
AllowedIndividuals = allowedIndividuals.ToList(),
AllowedSyncshells = allowedSyncshells.ToList()
};
await _apiController.McdfShareUpload(uploadDto).ConfigureAwait(false);
await InternalRefreshAsync(token).ConfigureAwait(false);
LastSuccess = "Partage MCDF créé.";
});
}
public Task DeleteShareAsync(Guid shareId)
{
return RunOperation(async () =>
{
var result = await _apiController.McdfShareDelete(shareId).ConfigureAwait(false);
if (!result)
{
LastError = "Le serveur a refusé de supprimer le partage MCDF.";
return;
}
_ownShares.RemoveAll(s => s.Id == shareId);
_sharedWithMe.RemoveAll(s => s.Id == shareId);
await InternalRefreshAsync(CancellationToken.None).ConfigureAwait(false);
LastSuccess = "Partage MCDF supprimé.";
});
}
public Task UpdateShareAsync(McdfShareUpdateRequestDto updateRequest)
{
return RunOperation(async () =>
{
var updated = await _apiController.McdfShareUpdate(updateRequest).ConfigureAwait(false);
if (updated == null)
{
LastError = "Le serveur a refusé de mettre à jour le partage MCDF.";
return;
}
var idx = _ownShares.FindIndex(s => s.Id == updated.Id);
if (idx >= 0)
{
_ownShares[idx] = updated;
}
LastSuccess = "Partage MCDF mis à jour.";
});
}
public Task ApplyShareAsync(Guid shareId, CancellationToken token)
{
return RunOperation(async () =>
{
token.ThrowIfCancellationRequested();
var plainBytes = await DownloadAndDecryptShareAsync(shareId, token).ConfigureAwait(false);
if (plainBytes == null)
{
LastError ??= "Échec du téléchargement du partage MCDF.";
return;
}
var tempPath = await _charaDataManager.LoadMcdfFromBytes(plainBytes, token).ConfigureAwait(false);
try
{
await _charaDataManager.McdfApplyToGposeTarget().ConfigureAwait(false);
}
finally
{
try
{
if (File.Exists(tempPath))
{
File.Delete(tempPath);
}
}
catch
{
// ignored
}
}
LastSuccess = "Partage MCDF appliqué sur la cible GPose.";
});
}
public Task ExportShareAsync(Guid shareId, string filePath, CancellationToken token)
{
return RunOperation(async () =>
{
token.ThrowIfCancellationRequested();
var plainBytes = await DownloadAndDecryptShareAsync(shareId, token).ConfigureAwait(false);
if (plainBytes == null)
{
LastError ??= "Échec du téléchargement du partage MCDF.";
return;
}
var directory = Path.GetDirectoryName(filePath);
if (!string.IsNullOrEmpty(directory))
{
Directory.CreateDirectory(directory);
}
await File.WriteAllBytesAsync(filePath, plainBytes, token).ConfigureAwait(false);
LastSuccess = "Partage MCDF exporté.";
});
}
public Task DownloadShareToFileAsync(McdfShareEntryDto entry, string filePath, CancellationToken token)
{
return ExportShareAsync(entry.Id, filePath, token);
}
private async Task<byte[]?> DownloadAndDecryptShareAsync(Guid shareId, CancellationToken token)
{
var payload = await _apiController.McdfShareDownload(shareId).ConfigureAwait(false);
if (payload == null)
{
LastError = "Partage indisponible.";
return null;
}
var secretKey = _serverConfigurationManager.GetSecretKey(out bool hasMultiple);
if (hasMultiple)
{
LastError = "Plusieurs clés secrètes sont configurées pour ce personnage.";
return null;
}
if (string.IsNullOrEmpty(secretKey))
{
LastError = "Aucune clé secrète n'est configurée pour ce personnage.";
return null;
}
byte[] key = DeriveKey(secretKey, payload.ShareId, payload.Salt);
byte[] plaintext = new byte[payload.CipherData.Length];
try
{
using var aes = new AesGcm(key, 16);
aes.Decrypt(payload.Nonce, payload.CipherData, payload.Tag, plaintext);
}
catch (CryptographicException ex)
{
_logger.LogWarning(ex, "Failed to decrypt MCDF share {ShareId}", shareId);
LastError = "Impossible de déchiffrer le partage MCDF.";
return null;
}
token.ThrowIfCancellationRequested();
return plaintext;
}
private async Task InternalRefreshAsync(CancellationToken token)
{
token.ThrowIfCancellationRequested();
var own = await _apiController.McdfShareGetOwn().ConfigureAwait(false);
token.ThrowIfCancellationRequested();
var shared = await _apiController.McdfShareGetShared().ConfigureAwait(false);
_ownShares.Clear();
_ownShares.AddRange(own);
_sharedWithMe.Clear();
_sharedWithMe.AddRange(shared);
LastSuccess = "Partages MCDF actualisés.";
}
private Task RunOperation(Func<Task> operation)
{
async Task Wrapper()
{
await _operationSemaphore.WaitAsync().ConfigureAwait(false);
try
{
LastError = null;
LastSuccess = null;
await operation().ConfigureAwait(false);
}
catch (Exception ex)
{
_logger.LogError(ex, "Error during MCDF share operation");
LastError = ex.Message;
}
finally
{
_operationSemaphore.Release();
}
}
var task = Wrapper();
_currentTask = task;
return task;
}
private static byte[] DeriveKey(string secretKey, Guid shareId, byte[] salt)
{
byte[] secretBytes;
try
{
secretBytes = Convert.FromHexString(secretKey);
}
catch (FormatException)
{
// fallback to UTF8 if not hex
secretBytes = System.Text.Encoding.UTF8.GetBytes(secretKey);
}
byte[] shareBytes = shareId.ToByteArray();
byte[] material = new byte[secretBytes.Length + shareBytes.Length + salt.Length];
Buffer.BlockCopy(secretBytes, 0, material, 0, secretBytes.Length);
Buffer.BlockCopy(shareBytes, 0, material, secretBytes.Length, shareBytes.Length);
Buffer.BlockCopy(salt, 0, material, secretBytes.Length + shareBytes.Length, salt.Length);
return SHA256.HashData(material);
}
}

View File

@@ -1,7 +1,11 @@
using Lumina.Data.Files;
using System;
using System.Runtime.InteropServices;
using Lumina.Data.Files;
using MareSynchronos.API.Data;
using MareSynchronos.API.Data.Enum;
using MareSynchronos.FileCache;
using MareSynchronos.MareConfiguration;
using MareSynchronos.MareConfiguration.Models;
using MareSynchronos.Services.Mediator;
using MareSynchronos.UI;
using MareSynchronos.Utils;
@@ -14,40 +18,52 @@ public sealed class CharacterAnalyzer : DisposableMediatorSubscriberBase
private readonly FileCacheManager _fileCacheManager;
private readonly XivDataAnalyzer _xivDataAnalyzer;
private CancellationTokenSource? _analysisCts;
private CancellationTokenSource _baseAnalysisCts = new();
private CancellationTokenSource? _baseAnalysisCts = new();
private string _lastDataHash = string.Empty;
private CharacterAnalysisSummary _previousSummary = CharacterAnalysisSummary.Empty;
private DateTime _lastAutoAnalysis = DateTime.MinValue;
private string _lastAutoAnalysisHash = string.Empty;
private const int AutoAnalysisFileDeltaThreshold = 25;
private const long AutoAnalysisSizeDeltaThreshold = 50L * 1024 * 1024;
private static readonly TimeSpan AutoAnalysisCooldown = TimeSpan.FromMinutes(2);
private const long NotificationSizeThreshold = 300L * 1024 * 1024;
private const long NotificationTriangleThreshold = 150_000;
private bool _sizeWarningShown;
private bool _triangleWarningShown;
private readonly PlayerPerformanceConfigService _playerPerformanceConfigService;
public CharacterAnalyzer(ILogger<CharacterAnalyzer> logger, MareMediator mediator, FileCacheManager fileCacheManager, XivDataAnalyzer modelAnalyzer)
public CharacterAnalyzer(ILogger<CharacterAnalyzer> logger, MareMediator mediator, FileCacheManager fileCacheManager, XivDataAnalyzer modelAnalyzer, PlayerPerformanceConfigService playerPerformanceConfigService)
: base(logger, mediator)
{
Mediator.Subscribe<CharacterDataCreatedMessage>(this, (msg) =>
{
_baseAnalysisCts = _baseAnalysisCts.CancelRecreate();
var token = _baseAnalysisCts.Token;
var tokenSource = EnsureFreshCts(ref _baseAnalysisCts);
var token = tokenSource.Token;
_ = BaseAnalysis(msg.CharacterData, token);
});
_fileCacheManager = fileCacheManager;
_xivDataAnalyzer = modelAnalyzer;
_playerPerformanceConfigService = playerPerformanceConfigService;
}
public int CurrentFile { get; internal set; }
public bool IsAnalysisRunning => _analysisCts != null;
public int TotalFiles { get; internal set; }
public CharacterAnalysisSummary CurrentSummary { get; private set; } = CharacterAnalysisSummary.Empty;
public DateTime? LastCompletedAnalysis { get; private set; }
internal Dictionary<ObjectKind, Dictionary<string, FileDataEntry>> LastAnalysis { get; } = [];
public void CancelAnalyze()
{
_analysisCts?.CancelDispose();
_analysisCts = null;
CancelAndDispose(ref _analysisCts);
}
public async Task ComputeAnalysis(bool print = true, bool recalculate = false)
{
Logger.LogDebug("=== Calculating Character Analysis ===");
_analysisCts = _analysisCts?.CancelRecreate() ?? new();
var cancelToken = _analysisCts.Token;
var analysisCts = EnsureFreshCts(ref _analysisCts);
var cancelToken = analysisCts.Token;
var allFiles = LastAnalysis.SelectMany(v => v.Value.Select(d => d.Value)).ToList();
if (allFiles.Exists(c => !c.IsComputed || recalculate))
@@ -80,10 +96,16 @@ public sealed class CharacterAnalyzer : DisposableMediatorSubscriberBase
}
}
RefreshSummary(false, _lastDataHash);
Mediator.Publish(new CharacterDataAnalyzedMessage());
_analysisCts.CancelDispose();
_analysisCts = null;
if (!cancelToken.IsCancellationRequested)
{
LastCompletedAnalysis = DateTime.UtcNow;
}
CancelAndDispose(ref _analysisCts);
if (print) PrintAnalysis();
}
@@ -94,8 +116,8 @@ public sealed class CharacterAnalyzer : DisposableMediatorSubscriberBase
if (!disposing) return;
_analysisCts?.CancelDispose();
_baseAnalysisCts.CancelDispose();
CancelAndDispose(ref _analysisCts);
CancelAndDispose(ref _baseAnalysisCts);
}
private async Task BaseAnalysis(CharacterData charaData, CancellationToken token)
@@ -142,9 +164,11 @@ public sealed class CharacterAnalyzer : DisposableMediatorSubscriberBase
LastAnalysis[obj.Key] = data;
}
_lastDataHash = charaData.DataHash.Value;
RefreshSummary(true, _lastDataHash);
Mediator.Publish(new CharacterDataAnalyzedMessage());
_lastDataHash = charaData.DataHash.Value;
}
private void PrintAnalysis()
@@ -193,6 +217,169 @@ public sealed class CharacterAnalyzer : DisposableMediatorSubscriberBase
Logger.LogInformation("IMPORTANT NOTES:\n\r- For uploads and downloads only the compressed size is relevant.\n\r- An unusually high total files count beyond 200 and up will also increase your download time to others significantly.");
}
private void RefreshSummary(bool evaluateAutoAnalysis, string dataHash)
{
var summary = CalculateSummary();
CurrentSummary = summary;
if (evaluateAutoAnalysis)
{
EvaluateAutoAnalysis(summary, dataHash);
}
else
{
_previousSummary = summary;
if (!summary.HasUncomputedEntries && string.Equals(_lastAutoAnalysisHash, dataHash, StringComparison.Ordinal))
{
_lastAutoAnalysisHash = string.Empty;
}
}
EvaluateThresholdNotifications(summary);
}
private CharacterAnalysisSummary CalculateSummary()
{
if (LastAnalysis.Count == 0)
{
return CharacterAnalysisSummary.Empty;
}
long original = 0;
long compressed = 0;
long triangles = 0;
int files = 0;
bool hasUncomputed = false;
foreach (var obj in LastAnalysis.Values)
{
foreach (var entry in obj.Values)
{
files++;
original += entry.OriginalSize;
compressed += entry.CompressedSize;
triangles += entry.Triangles;
hasUncomputed |= !entry.IsComputed;
}
}
return new CharacterAnalysisSummary(files, original, compressed, triangles, hasUncomputed);
}
private void EvaluateAutoAnalysis(CharacterAnalysisSummary newSummary, string dataHash)
{
var previous = _previousSummary;
_previousSummary = newSummary;
if (newSummary.TotalFiles == 0)
{
return;
}
if (string.Equals(_lastAutoAnalysisHash, dataHash, StringComparison.Ordinal))
{
return;
}
if (IsAnalysisRunning)
{
return;
}
var now = DateTime.UtcNow;
if (now - _lastAutoAnalysis < AutoAnalysisCooldown)
{
return;
}
bool firstSummary = previous.TotalFiles == 0;
bool filesIncreased = newSummary.TotalFiles - previous.TotalFiles >= AutoAnalysisFileDeltaThreshold;
bool sizeIncreased = newSummary.TotalCompressedSize - previous.TotalCompressedSize >= AutoAnalysisSizeDeltaThreshold;
bool needsCompute = newSummary.HasUncomputedEntries;
if (!firstSummary && !filesIncreased && !sizeIncreased && !needsCompute)
{
return;
}
_lastAutoAnalysis = now;
_lastAutoAnalysisHash = dataHash;
_ = ComputeAnalysis(print: false);
}
private void EvaluateThresholdNotifications(CharacterAnalysisSummary summary)
{
if (summary.IsEmpty || summary.HasUncomputedEntries)
{
ResetThresholdFlagsIfNeeded(summary);
return;
}
if (!_playerPerformanceConfigService.Current.ShowSelfAnalysisWarnings)
{
ResetThresholdFlagsIfNeeded(summary);
return;
}
bool sizeExceeded = summary.TotalCompressedSize >= NotificationSizeThreshold;
bool trianglesExceeded = summary.TotalTriangles >= NotificationTriangleThreshold;
List<string> exceededReasons = new();
if (sizeExceeded && !_sizeWarningShown)
{
exceededReasons.Add($"un poids partagé de {UiSharedService.ByteToString(summary.TotalCompressedSize)} (≥ 300 MiB)");
_sizeWarningShown = true;
}
else if (!sizeExceeded && _sizeWarningShown)
{
_sizeWarningShown = false;
}
if (trianglesExceeded && !_triangleWarningShown)
{
exceededReasons.Add($"un total de {UiSharedService.TrisToString(summary.TotalTriangles)} triangles (≥ 150k)");
_triangleWarningShown = true;
}
else if (!trianglesExceeded && _triangleWarningShown)
{
_triangleWarningShown = false;
}
if (exceededReasons.Count == 0) return;
string combined = string.Join(" et ", exceededReasons);
string message = $"Attention : votre self-analysis indique {combined}. Des joueurs risquent de ne pas vous voir et UmbraSync peut activer un auto-pause. Pensez à réduire textures ou modèles lourds.";
Mediator.Publish(new DualNotificationMessage("Self Analysis", message, NotificationType.Warning));
}
private void ResetThresholdFlagsIfNeeded(CharacterAnalysisSummary summary)
{
if (summary.IsEmpty)
{
_sizeWarningShown = false;
_triangleWarningShown = false;
return;
}
if (summary.TotalCompressedSize < NotificationSizeThreshold)
{
_sizeWarningShown = false;
}
if (summary.TotalTriangles < NotificationTriangleThreshold)
{
_triangleWarningShown = false;
}
}
[StructLayout(LayoutKind.Auto)]
public readonly record struct CharacterAnalysisSummary(int TotalFiles, long TotalOriginalSize, long TotalCompressedSize, long TotalTriangles, bool HasUncomputedEntries)
{
public static CharacterAnalysisSummary Empty => new();
public bool IsEmpty => TotalFiles == 0 && TotalOriginalSize == 0 && TotalCompressedSize == 0 && TotalTriangles == 0;
}
internal sealed record FileDataEntry(string Hash, string FileType, List<string> GamePaths, List<string> FilePaths, long OriginalSize, long CompressedSize, long Triangles)
{
public bool IsComputed => OriginalSize > 0 && CompressedSize > 0;
@@ -239,4 +426,26 @@ public sealed class CharacterAnalyzer : DisposableMediatorSubscriberBase
}
});
}
}
private static CancellationTokenSource EnsureFreshCts(ref CancellationTokenSource? cts)
{
CancelAndDispose(ref cts);
cts = new CancellationTokenSource();
return cts;
}
private static void CancelAndDispose(ref CancellationTokenSource? cts)
{
if (cts == null) return;
try
{
cts.Cancel();
}
catch (ObjectDisposedException)
{
}
cts.Dispose();
cts = null;
}
}

View File

@@ -1,3 +1,6 @@
using System;
using System.Text;
using System.Threading;
using Dalamud.Game.Text;
using Dalamud.Game.Text.SeStringHandling;
using Dalamud.Game.Text.SeStringHandling.Payloads;
@@ -5,6 +8,7 @@ using Dalamud.Plugin.Services;
using MareSynchronos.API.Data;
using MareSynchronos.Interop;
using MareSynchronos.MareConfiguration;
using MareSynchronos.API.Data.Enum;
using MareSynchronos.MareConfiguration.Models;
using MareSynchronos.PlayerData.Pairs;
using MareSynchronos.Services.Mediator;
@@ -18,6 +22,7 @@ namespace MareSynchronos.Services;
public class ChatService : DisposableMediatorSubscriberBase
{
public const int DefaultColor = 710;
private const string ManualPairInvitePrefix = "[UmbraPairInvite|";
public const int CommandMaxNumber = 50;
private readonly ILogger<ChatService> _logger;
@@ -30,6 +35,14 @@ public class ChatService : DisposableMediatorSubscriberBase
private readonly Lazy<GameChatHooks> _gameChatHooks;
private readonly object _typingLock = new();
private CancellationTokenSource? _typingCts;
private bool _isTypingAnnounced;
private DateTime _lastTypingSent = DateTime.MinValue;
private TypingScope _lastScope = TypingScope.Unknown;
private static readonly TimeSpan TypingIdle = TimeSpan.FromSeconds(2);
private static readonly TimeSpan TypingResendInterval = TimeSpan.FromMilliseconds(750);
public ChatService(ILogger<ChatService> logger, DalamudUtilService dalamudUtil, MareMediator mediator, ApiController apiController,
PairManager pairManager, ILoggerFactory loggerFactory, IGameInteropProvider gameInteropProvider, IChatGui chatGui,
MareConfigService mareConfig, ServerConfigurationManager serverConfigurationManager) : base(logger, mediator)
@@ -46,13 +59,12 @@ public class ChatService : DisposableMediatorSubscriberBase
Mediator.Subscribe<GroupChatMsgMessage>(this, HandleGroupChat);
_gameChatHooks = new(() => new GameChatHooks(loggerFactory.CreateLogger<GameChatHooks>(), gameInteropProvider, SendChatShell));
// Initialize chat hooks in advance
_ = Task.Run(() =>
{
try
{
_ = _gameChatHooks.Value;
_isTypingAnnounced = false;
}
catch (Exception ex)
{
@@ -64,15 +76,87 @@ public class ChatService : DisposableMediatorSubscriberBase
protected override void Dispose(bool disposing)
{
base.Dispose(disposing);
_typingCts?.Cancel();
_typingCts?.Dispose();
if (_gameChatHooks.IsValueCreated)
_gameChatHooks.Value!.Dispose();
}
public void NotifyTypingKeystroke(TypingScope scope)
{
lock (_typingLock)
{
var now = DateTime.UtcNow;
if (!_isTypingAnnounced || (now - _lastTypingSent) >= TypingResendInterval)
{
_ = Task.Run(async () =>
{
try { await _apiController.UserSetTypingState(true, scope).ConfigureAwait(false); }
catch (Exception ex) { _logger.LogDebug(ex, "NotifyTypingKeystroke: failed to send typing=true"); }
});
_isTypingAnnounced = true;
_lastTypingSent = now;
_lastScope = scope;
}
_typingCts?.Cancel();
_typingCts?.Dispose();
_typingCts = new CancellationTokenSource();
var token = _typingCts.Token;
_ = Task.Run(async () =>
{
try
{
await Task.Delay(TypingIdle, token).ConfigureAwait(false);
await _apiController.UserSetTypingState(false, _lastScope).ConfigureAwait(false);
}
catch (TaskCanceledException)
{
// reset timer
}
catch (Exception ex)
{
_logger.LogDebug(ex, "NotifyTypingKeystroke: failed to send typing=false");
}
finally
{
lock (_typingLock)
{
if (!token.IsCancellationRequested)
{
_isTypingAnnounced = false;
_lastTypingSent = DateTime.MinValue;
}
}
}
});
}
}
public void ClearTypingState()
{
lock (_typingLock)
{
_typingCts?.Cancel();
_typingCts?.Dispose();
_typingCts = null;
if (_isTypingAnnounced)
{
_ = Task.Run(async () =>
{
try { await _apiController.UserSetTypingState(false, _lastScope).ConfigureAwait(false); }
catch (Exception ex) { _logger.LogDebug(ex, "ClearTypingState: failed to send typing=false"); }
});
_isTypingAnnounced = false;
_lastTypingSent = DateTime.MinValue;
}
}
}
private void HandleUserChat(UserChatMsgMessage message)
{
var chatMsg = message.ChatMsg;
var prefix = new SeStringBuilder();
prefix.AddText("[SnowChat] ");
prefix.AddText("[UmbraChat] ");
_chatGui.Print(new XivChatEntry{
MessageBytes = [..prefix.Build().Encode(), ..message.ChatMsg.PayloadContent],
Name = chatMsg.SenderName,
@@ -113,6 +197,10 @@ public class ChatService : DisposableMediatorSubscriberBase
var extraChatTags = _mareConfig.Current.ExtraChatTags;
var logKind = ResolveShellLogKind(shellConfig.LogKind);
var payload = SeString.Parse(message.ChatMsg.PayloadContent);
if (TryHandleManualPairInvite(message, payload))
return;
var msg = new SeStringBuilder();
if (extraChatTags)
{
@@ -124,7 +212,6 @@ public class ChatService : DisposableMediatorSubscriberBase
msg.AddText($"[SS{shellNumber}]<");
if (message.ChatMsg.Sender.UID.Equals(_apiController.UID, StringComparison.Ordinal))
{
// Don't link to your own character
msg.AddText(chatMsg.SenderName);
}
else
@@ -132,7 +219,7 @@ public class ChatService : DisposableMediatorSubscriberBase
msg.Add(new PlayerPayload(chatMsg.SenderName, chatMsg.SenderHomeWorldId));
}
msg.AddText("> ");
msg.Append(SeString.Parse(message.ChatMsg.PayloadContent));
msg.Append(payload);
if (color != 0)
msg.AddUiForegroundOff();
@@ -143,7 +230,51 @@ public class ChatService : DisposableMediatorSubscriberBase
});
}
// Print an example message to the configured global chat channel
private bool TryHandleManualPairInvite(GroupChatMsgMessage message, SeString payload)
{
var textValue = payload.TextValue;
if (string.IsNullOrEmpty(textValue) || !textValue.StartsWith(ManualPairInvitePrefix, StringComparison.Ordinal))
return false;
var content = textValue[ManualPairInvitePrefix.Length..];
if (content.EndsWith("]", StringComparison.Ordinal))
{
content = content[..^1];
}
var parts = content.Split('|');
if (parts.Length < 4)
return true;
var sourceUid = parts[0];
var sourceAlias = DecodeInviteField(parts[1]);
var targetUid = parts[2];
var displayName = DecodeInviteField(parts[3]);
var inviteId = parts.Length > 4 ? parts[4] : Guid.NewGuid().ToString("N");
if (!string.Equals(targetUid, _apiController.UID, StringComparison.Ordinal))
return true;
Mediator.Publish(new ManualPairInviteMessage(sourceUid, sourceAlias, targetUid, string.IsNullOrEmpty(displayName) ? null : displayName, inviteId));
_logger.LogDebug("Received manual pair invite from {source} via syncshell", sourceUid);
return true;
}
private static string DecodeInviteField(string encoded)
{
if (string.IsNullOrEmpty(encoded)) return string.Empty;
try
{
var bytes = Convert.FromBase64String(encoded);
return Encoding.UTF8.GetString(bytes);
}
catch
{
return encoded;
}
}
public void PrintChannelExample(string message, string gid = "")
{
int chatType = _mareConfig.Current.ChatLogKind;
@@ -164,8 +295,6 @@ public class ChatService : DisposableMediatorSubscriberBase
Type = (XivChatType)chatType
});
}
// Called to update the active chat shell name if its renamed
public void MaybeUpdateShellName(int shellNumber)
{
if (_mareConfig.Current.DisableSyncshellChat)
@@ -178,7 +307,6 @@ public class ChatService : DisposableMediatorSubscriberBase
{
if (_gameChatHooks.IsValueCreated && _gameChatHooks.Value.ChatChannelOverride != null)
{
// Very dumb and won't handle re-numbering -- need to identify the active chat channel more reliably later
if (_gameChatHooks.Value.ChatChannelOverride.ChannelName.StartsWith($"SS [{shellNumber}]", StringComparison.Ordinal))
SwitchChatShell(shellNumber);
}
@@ -197,7 +325,6 @@ public class ChatService : DisposableMediatorSubscriberBase
if (shellConfig.Enabled && shellConfig.ShellNumber == shellNumber)
{
var name = _serverConfigurationManager.GetNoteForGid(group.Key.GID) ?? group.Key.AliasOrGID;
// BUG: This doesn't always update the chat window e.g. when renaming a group
_gameChatHooks.Value.ChatChannelOverride = new()
{
ChannelName = $"SS [{shellNumber}]: {name}",
@@ -221,7 +348,6 @@ public class ChatService : DisposableMediatorSubscriberBase
if (shellConfig.Enabled && shellConfig.ShellNumber == shellNumber)
{
_ = Task.Run(async () => {
// Should cache the name and home world instead of fetching it every time
var chatMsg = await _dalamudUtil.RunOnFrameworkThread(() => {
return new ChatMessage()
{
@@ -230,6 +356,7 @@ public class ChatService : DisposableMediatorSubscriberBase
PayloadContent = chatBytes
};
}).ConfigureAwait(false);
ClearTypingState();
await _apiController.GroupChatSendMsg(new(group.Key), chatMsg).ConfigureAwait(false);
}).ConfigureAwait(false);
return;
@@ -238,4 +365,4 @@ public class ChatService : DisposableMediatorSubscriberBase
_chatGui.PrintError($"[UmbraSync] Syncshell number #{shellNumber} not found");
}
}
}

View File

@@ -0,0 +1,68 @@
using System;
using System.Threading;
using System.Threading.Tasks;
using Dalamud.Plugin;
using MareSynchronos.MareConfiguration.Models;
using MareSynchronos.Services.Mediator;
using Microsoft.Extensions.Hosting;
using Microsoft.Extensions.Logging;
namespace MareSynchronos.Services;
public sealed class ChatTwoCompatibilityService : MediatorSubscriberBase, IHostedService
{
private const string ChatTwoInternalName = "ChatTwo";
private readonly IDalamudPluginInterface _pluginInterface;
private bool _warningShown;
public ChatTwoCompatibilityService(ILogger<ChatTwoCompatibilityService> logger, IDalamudPluginInterface pluginInterface, MareMediator mediator)
: base(logger, mediator)
{
_pluginInterface = pluginInterface;
Mediator.SubscribeKeyed<PluginChangeMessage>(this, ChatTwoInternalName, OnChatTwoStateChanged);
}
public Task StartAsync(CancellationToken cancellationToken)
{
try
{
var initialState = PluginWatcherService.GetInitialPluginState(_pluginInterface, ChatTwoInternalName);
if (initialState?.IsLoaded == true)
{
ShowWarning();
}
}
catch (Exception ex)
{
Logger.LogDebug(ex, "Failed to inspect ChatTwo initial state");
}
return Task.CompletedTask;
}
public Task StopAsync(CancellationToken cancellationToken)
{
Mediator.UnsubscribeAll(this);
return Task.CompletedTask;
}
private void OnChatTwoStateChanged(PluginChangeMessage message)
{
if (message.IsLoaded)
{
ShowWarning();
}
}
private void ShowWarning()
{
if (_warningShown) return;
_warningShown = true;
const string warningTitle = "ChatTwo détecté";
const string warningBody = "Actuellement, le plugin ChatTwo n'est pas compatible avec la bulle d'écriture d'UmbraSync. Désactivez ChatTwo si vous souhaitez conserver l'indicateur de saisie.";
Mediator.Publish(new NotificationMessage(warningTitle, warningBody, NotificationType.Warning, TimeSpan.FromSeconds(10)));
}
}

View File

@@ -0,0 +1,373 @@
using System;
using System.Collections.Generic;
using System.Linq;
using System.Runtime.CompilerServices;
using Dalamud.Plugin.Services;
using Dalamud.Utility;
using Dalamud.Game.Text;
using FFXIVClientStructs.FFXIV.Component.GUI;
using FFXIVClientStructs.FFXIV.Client.UI.Shell;
using Microsoft.Extensions.Logging;
using MareSynchronos.PlayerData.Pairs;
using MareSynchronos.WebAPI;
using MareSynchronos.MareConfiguration;
using MareSynchronos.API.Data.Enum;
namespace MareSynchronos.Services;
public sealed class ChatTypingDetectionService : IDisposable
{
private readonly ILogger<ChatTypingDetectionService> _logger;
private readonly IFramework _framework;
private readonly IClientState _clientState;
private readonly IGameGui _gameGui;
private readonly ChatService _chatService;
private readonly TypingIndicatorStateService _typingStateService;
private readonly ApiController _apiController;
private readonly PairManager _pairManager;
private readonly IPartyList _partyList;
private readonly MareConfigService _configService;
private string _lastChatText = string.Empty;
private bool _isTyping;
private bool _notifyingRemote;
private bool _serverSupportWarnLogged;
private bool _remoteNotificationsEnabled;
private bool _subscribed;
public ChatTypingDetectionService(ILogger<ChatTypingDetectionService> logger, IFramework framework,
IClientState clientState, IGameGui gameGui, ChatService chatService, PairManager pairManager, IPartyList partyList,
TypingIndicatorStateService typingStateService, ApiController apiController, MareConfigService configService)
{
_logger = logger;
_framework = framework;
_clientState = clientState;
_gameGui = gameGui;
_chatService = chatService;
_pairManager = pairManager;
_partyList = partyList;
_typingStateService = typingStateService;
_apiController = apiController;
_configService = configService;
Subscribe();
_logger.LogInformation("ChatTypingDetectionService initialized");
}
public void Dispose()
{
Unsubscribe();
ResetTypingState();
}
public void SoftRestart()
{
try
{
_logger.LogInformation("TypingDetection: soft restarting");
Unsubscribe();
ResetTypingState();
_chatService.ClearTypingState();
_typingStateService.ClearAll();
Subscribe();
_logger.LogInformation("TypingDetection: soft restart completed");
}
catch (Exception ex)
{
_logger.LogWarning(ex, "TypingDetection: soft restart failed");
}
}
private void Subscribe()
{
if (_subscribed) return;
_framework.Update += OnFrameworkUpdate;
_subscribed = true;
}
private void Unsubscribe()
{
if (!_subscribed) return;
_framework.Update -= OnFrameworkUpdate;
_subscribed = false;
}
private void OnFrameworkUpdate(IFramework framework)
{
try
{
if (!_clientState.IsLoggedIn)
{
ResetTypingState();
return;
}
if (!_configService.Current.TypingIndicatorEnabled)
{
ResetTypingState();
_chatService.ClearTypingState();
return;
}
if (!TryGetChatInput(out var chatText) || string.IsNullOrEmpty(chatText))
{
ResetTypingState();
return;
}
if (IsIgnoredCommand(chatText))
{
ResetTypingState();
return;
}
var notifyRemote = ShouldNotifyRemote();
UpdateRemoteNotificationLogState(notifyRemote);
if (!notifyRemote && _notifyingRemote)
{
_chatService.ClearTypingState();
_notifyingRemote = false;
}
if (!_isTyping || !string.Equals(chatText, _lastChatText, StringComparison.Ordinal))
{
if (notifyRemote)
{
var scope = GetCurrentTypingScope();
_chatService.NotifyTypingKeystroke(scope);
_notifyingRemote = true;
}
_typingStateService.SetSelfTypingLocal(true);
_isTyping = true;
}
_lastChatText = chatText;
}
catch (Exception ex)
{
_logger.LogTrace(ex, "ChatTypingDetectionService tick failed");
}
}
private void ResetTypingState()
{
if (!_isTyping)
{
_lastChatText = string.Empty;
return;
}
_isTyping = false;
_lastChatText = string.Empty;
_chatService.ClearTypingState();
_notifyingRemote = false;
_typingStateService.SetSelfTypingLocal(false);
}
private unsafe TypingScope GetCurrentTypingScope()
{
try
{
var shellModule = RaptureShellModule.Instance();
if (shellModule == null)
return TypingScope.Unknown;
var chatType = (XivChatType)shellModule->ChatType;
switch (chatType)
{
case XivChatType.Say:
case XivChatType.Shout:
case XivChatType.Yell:
return TypingScope.Proximity;
case XivChatType.Party:
return TypingScope.Party;
case XivChatType.CrossParty:
return TypingScope.CrossParty;
default:
return TypingScope.Unknown;
}
}
catch
{
return TypingScope.Unknown;
}
}
private static bool IsIgnoredCommand(string chatText)
{
if (string.IsNullOrWhiteSpace(chatText))
return false;
var trimmed = chatText.TrimStart();
if (!trimmed.StartsWith('/'))
return false;
var firstTokenEnd = trimmed.IndexOf(' ');
var command = firstTokenEnd >= 0 ? trimmed[..firstTokenEnd] : trimmed;
command = command.TrimEnd();
var comparison = StringComparison.OrdinalIgnoreCase;
return command.StartsWith("/tell", comparison)
|| command.StartsWith("/t", comparison)
|| command.StartsWith("/xllog", comparison)
|| command.StartsWith("/umbra", comparison)
|| command.StartsWith("/fc", comparison)
|| command.StartsWith("/freecompany", comparison);
}
private unsafe bool ShouldNotifyRemote()
{
try
{
if (!_configService.Current.TypingIndicatorEnabled)
{
return false;
}
var supportsTypingState = _apiController.SystemInfoDto.SupportsTypingState;
var connected = _apiController.IsConnected;
if (!connected || !supportsTypingState)
{
if (!_serverSupportWarnLogged)
{
_logger.LogDebug("TypingDetection: server support unavailable (connected={connected}, supports={supports})", connected, supportsTypingState);
_serverSupportWarnLogged = true;
}
return false;
}
_serverSupportWarnLogged = false;
var shellModule = RaptureShellModule.Instance();
if (shellModule == null)
{
_logger.LogDebug("TypingDetection: shell module null");
return true;
}
var chatType = (XivChatType)shellModule->ChatType;
switch (chatType)
{
case XivChatType.Say:
case XivChatType.Shout:
case XivChatType.Yell:
return true;
case XivChatType.Party:
case XivChatType.CrossParty:
var eligible = PartyContainsPairedMember();
return eligible;
case XivChatType.Debug:
return true;
default:
_logger.LogTrace("TypingDetection: channel {type} rejected", chatType);
return false;
}
}
catch (Exception ex)
{
_logger.LogTrace(ex, "ChatTypingDetectionService: failed to evaluate chat channel");
}
return true;
}
private bool PartyContainsPairedMember()
{
try
{
var pairedNames = new HashSet<string>(StringComparer.OrdinalIgnoreCase);
foreach (var pair in _pairManager.GetOnlineUserPairs())
{
if (!string.IsNullOrEmpty(pair.PlayerName))
pairedNames.Add(pair.PlayerName);
}
if (pairedNames.Count == 0)
{
_logger.LogDebug("TypingDetection: no paired names online");
return false;
}
foreach (var member in _partyList)
{
var name = member?.Name?.TextValue;
if (string.IsNullOrEmpty(name))
continue;
if (pairedNames.Contains(name))
{
return true;
}
}
}
catch (Exception ex)
{
_logger.LogDebug(ex, "ChatTypingDetectionService: failed to check party composition");
}
_logger.LogDebug("TypingDetection: no paired members in party");
return false;
}
[MethodImpl(MethodImplOptions.AggressiveInlining)]
private unsafe bool TryGetChatInput(out string chatText)
{
chatText = string.Empty;
var addon = _gameGui.GetAddonByName("ChatLog", 1);
if (addon.Address == nint.Zero)
return false;
var chatLog = (AtkUnitBase*)addon.Address;
if (chatLog == null || !chatLog->IsVisible)
return false;
var textInputNode = chatLog->UldManager.NodeList[16];
if (textInputNode == null)
return false;
var componentNode = textInputNode->GetAsAtkComponentNode();
if (componentNode == null || componentNode->Component == null)
return false;
var cursorNode = componentNode->Component->UldManager.NodeList[14];
if (cursorNode == null)
return false;
var cursorVisible = cursorNode->IsVisible();
if (!cursorVisible)
{
return false;
}
var chatInputNode = componentNode->Component->UldManager.NodeList[1];
if (chatInputNode == null)
return false;
var textNode = chatInputNode->GetAsAtkTextNode();
if (textNode == null)
return false;
var rawText = textNode->GetText();
if (rawText == null)
return false;
chatText = rawText.AsDalamudSeString().ToString();
return true;
}
private void UpdateRemoteNotificationLogState(bool notifyRemote)
{
if (notifyRemote && !_remoteNotificationsEnabled)
{
_remoteNotificationsEnabled = true;
_logger.LogInformation("TypingDetection: remote notifications enabled");
}
else if (!notifyRemote && _remoteNotificationsEnabled)
{
_remoteNotificationsEnabled = false;
_logger.LogInformation("TypingDetection: remote notifications disabled");
}
}
}

View File

@@ -9,15 +9,15 @@ using MareSynchronos.UI;
using MareSynchronos.WebAPI;
using System.Globalization;
using System.Text;
using System.Numerics;
namespace MareSynchronos.Services;
public sealed class CommandManagerService : IDisposable
{
private const string _commandName = "/sync";
private const string _commandName2 = "/umbra";
private const string _ssCommandPrefix = "/ss";
private const string _commandName = "/usync";
private const string _autoDetectCommand = "/autodetect";
private const string _ssCommandPrefix = "/ums";
private readonly ApiController _apiController;
private readonly ICommandManager _commandManager;
@@ -42,11 +42,12 @@ public sealed class CommandManagerService : IDisposable
_mareConfigService = mareConfigService;
_commandManager.AddHandler(_commandName, new CommandInfo(OnCommand)
{
HelpMessage = "Opens the Umbra UI"
HelpMessage = "Opens the UmbraSync UI"
});
_commandManager.AddHandler(_commandName2, new CommandInfo(OnCommand)
_commandManager.AddHandler(_autoDetectCommand, new CommandInfo(OnAutoDetectCommand)
{
HelpMessage = "Opens the Umbra UI"
HelpMessage = "Opens the AutoDetect window"
});
// Lazy registration of all possible /ss# commands which tbf is what the game does for linkshells anyway
@@ -62,12 +63,21 @@ public sealed class CommandManagerService : IDisposable
public void Dispose()
{
_commandManager.RemoveHandler(_commandName);
_commandManager.RemoveHandler(_commandName2);
_commandManager.RemoveHandler(_autoDetectCommand);
for (int i = 1; i <= ChatService.CommandMaxNumber; ++i)
_commandManager.RemoveHandler($"{_ssCommandPrefix}{i}");
}
private void OnAutoDetectCommand(string command, string args)
{
UiSharedService.AccentColor = new Vector4(0x8D / 255f, 0x37 / 255f, 0xC0 / 255f, 1f);
UiSharedService.AccentHoverColor = new Vector4(0x3A / 255f, 0x15 / 255f, 0x50 / 255f, 1f);
UiSharedService.AccentActiveColor = UiSharedService.AccentHoverColor;
_mediator.Publish(new UiToggleMessage(typeof(AutoDetectUi)));
}
private void OnCommand(string command, string args)
{
var splitArgs = args.ToLowerInvariant().Trim().Split(" ", StringSplitOptions.RemoveEmptyEntries);
@@ -147,9 +157,8 @@ public sealed class CommandManagerService : IDisposable
}
else
{
// FIXME: Chat content seems to already be stripped of any special characters here?
byte[] chatBytes = Encoding.UTF8.GetBytes(args);
_chatService.SendChatShell(shellNumber, chatBytes);
}
}
}
}

View File

@@ -20,6 +20,8 @@ using Microsoft.Extensions.Logging;
using System.Numerics;
using System.Runtime.CompilerServices;
using System.Text;
using System;
using System.Collections.Generic;
using GameObject = FFXIVClientStructs.FFXIV.Client.Game.Object.GameObject;
using DalamudGameObject = Dalamud.Game.ClientState.Objects.Types.IGameObject;
@@ -52,6 +54,7 @@ public class DalamudUtilService : IHostedService, IMediatorSubscriber
private readonly ILogger<DalamudUtilService> _logger;
private readonly IObjectTable _objectTable;
private readonly PerformanceCollectorService _performanceCollector;
private readonly Dictionary<string, ConditionFlag> _conditionLookup = new(StringComparer.OrdinalIgnoreCase);
private uint? _classJobId = 0;
private DateTime _delayedFrameworkUpdateCheck = DateTime.UtcNow;
private string _lastGlobalBlockPlayer = string.Empty;
@@ -172,6 +175,20 @@ public class DalamudUtilService : IHostedService, IMediatorSubscriber
public bool IsInCombatOrPerforming { get; private set; } = false;
public bool HasModifiedGameFiles => _gameData.HasModifiedGameDataFiles;
public bool IsConditionActive(string flagName)
{
if (_conditionLookup.TryGetValue(flagName, out var cachedFlag))
return _condition[cachedFlag];
if (Enum.TryParse<ConditionFlag>(flagName, true, out var flag))
{
_conditionLookup[flagName] = flag;
return _condition[flag];
}
return false;
}
public Lazy<Dictionary<ushort, string>> WorldData { get; private set; }
public Lazy<Dictionary<int, Lumina.Excel.Sheets.UIColor>> UiColors { get; private set; }
public Lazy<Dictionary<uint, string>> TerritoryData { get; private set; }

View File

@@ -1,18 +1,21 @@
using System;
using Dalamud.Game.Gui.NamePlate;
using Dalamud.Game.Text.SeStringHandling;
using Dalamud.Game.Text.SeStringHandling.Payloads;
using Dalamud.Plugin.Services;
using MareSynchronos.API.Dto.User;
using MareSynchronos.MareConfiguration;
using MareSynchronos.PlayerData.Pairs;
using MareSynchronos.Services.Mediator;
using MareSynchronos.UI;
using MareSynchronos.WebAPI;
using Microsoft.Extensions.Logging;
using Dalamud.Game.Text;
namespace MareSynchronos.Services;
public class GuiHookService : DisposableMediatorSubscriberBase
{
private readonly ILogger<GuiHookService> _logger;
private readonly DalamudUtilService _dalamudUtil;
private readonly MareConfigService _configService;
private readonly INamePlateGui _namePlateGui;
@@ -27,7 +30,6 @@ public class GuiHookService : DisposableMediatorSubscriberBase
INamePlateGui namePlateGui, IGameConfig gameConfig, IPartyList partyList, PairManager pairManager)
: base(logger, mediator)
{
_logger = logger;
_dalamudUtil = dalamudUtil;
_configService = configService;
_namePlateGui = namePlateGui;
@@ -41,11 +43,14 @@ public class GuiHookService : DisposableMediatorSubscriberBase
Mediator.Subscribe<DelayedFrameworkUpdateMessage>(this, (_) => GameSettingsCheck());
Mediator.Subscribe<PairHandlerVisibleMessage>(this, (_) => RequestRedraw());
Mediator.Subscribe<NameplateRedrawMessage>(this, (_) => RequestRedraw());
Mediator.Subscribe<UserTypingStateMessage>(this, (_) => RequestRedraw());
}
public void RequestRedraw(bool force = false)
{
if (!_configService.Current.UseNameColors)
var useColors = _configService.Current.UseNameColors;
if (!useColors)
{
if (!_isModified && !force)
return;
@@ -69,7 +74,8 @@ public class GuiHookService : DisposableMediatorSubscriberBase
private void OnNamePlateUpdate(INamePlateUpdateContext context, IReadOnlyList<INamePlateUpdateHandler> handlers)
{
if (!_configService.Current.UseNameColors)
var applyColors = _configService.Current.UseNameColors;
if (!applyColors)
return;
var visibleUsers = _pairManager.GetOnlineUserPairs().Where(u => u.IsVisible && u.PlayerCharacterId != uint.MaxValue);
@@ -89,13 +95,18 @@ public class GuiHookService : DisposableMediatorSubscriberBase
if (_namePlateRoleColorsEnabled && partyMembers.Contains(handler.GameObject?.Address ?? nint.MaxValue))
continue;
var pair = visibleUsersDict[handler.GameObjectId];
var colors = !pair.IsApplicationBlocked ? _configService.Current.NameColors : _configService.Current.BlockedNameColors;
handler.NameParts.TextWrap = (
BuildColorStartSeString(colors),
BuildColorEndSeString(colors)
);
_isModified = true;
if (applyColors)
{
var colors = !pair.IsApplicationBlocked ? _configService.Current.NameColors : _configService.Current.BlockedNameColors;
handler.NameParts.TextWrap = (
BuildColorStartSeString(colors),
BuildColorEndSeString(colors)
);
_isModified = true;
}
}
}
}

View File

@@ -2,7 +2,6 @@
using MareSynchronos.API.Data.Comparer;
using MareSynchronos.MareConfiguration;
using MareSynchronos.Services.Mediator;
using MareSynchronos.Services.ServerConfiguration;
using MareSynchronos.WebAPI;
using Microsoft.Extensions.Logging;
using System.Collections.Concurrent;
@@ -15,7 +14,6 @@ public class MareProfileManager : MediatorSubscriberBase
private const string _nsfw = "Profile not displayed - NSFW";
private readonly ApiController _apiController;
private readonly MareConfigService _mareConfigService;
private readonly ServerConfigurationManager _serverConfigurationManager;
private readonly ConcurrentDictionary<UserData, MareProfileData> _mareProfiles = new(UserDataComparer.Instance);
private readonly MareProfileData _defaultProfileData = new(IsFlagged: false, IsNSFW: false, string.Empty, _noDescription);
@@ -23,11 +21,10 @@ public class MareProfileManager : MediatorSubscriberBase
private readonly MareProfileData _nsfwProfileData = new(IsFlagged: false, IsNSFW: false, string.Empty, _nsfw);
public MareProfileManager(ILogger<MareProfileManager> logger, MareConfigService mareConfigService,
MareMediator mediator, ApiController apiController, ServerConfigurationManager serverConfigurationManager) : base(logger, mediator)
MareMediator mediator, ApiController apiController) : base(logger, mediator)
{
_mareConfigService = mareConfigService;
_apiController = apiController;
_serverConfigurationManager = serverConfigurationManager;
Mediator.Subscribe<ClearProfileDataMessage>(this, (msg) =>
{
@@ -75,4 +72,4 @@ public class MareProfileManager : MediatorSubscriberBase
_mareProfiles[data] = _defaultProfileData;
}
}
}
}

View File

@@ -7,7 +7,7 @@ using System.Text;
namespace MareSynchronos.Services.Mediator;
public sealed class MareMediator : IHostedService
public sealed class MareMediator : IHostedService, IDisposable
{
private readonly Lock _addRemoveLock = new();
private readonly ConcurrentDictionary<SubscriberAction, DateTime> _lastErrorTime = [];
@@ -109,6 +109,26 @@ public sealed class MareMediator : IHostedService
}
}
private bool _disposed;
public void Dispose()
{
if (_disposed) return;
_disposed = true;
if (!_loopCts.IsCancellationRequested)
{
try
{
_loopCts.Cancel();
}
catch (ObjectDisposedException)
{
// already disposed, swallow
}
}
_loopCts.Dispose();
}
public void SubscribeKeyed<T>(IMediatorSubscriber subscriber, string key, Action<T> action) where T : MessageBase
{
lock (_addRemoveLock)
@@ -219,4 +239,4 @@ public sealed class MareMediator : IHostedService
public object Action { get; }
public IMediatorSubscriber Subscriber { get; }
}
}
}

View File

@@ -3,6 +3,7 @@ using MareSynchronos.API.Data;
using MareSynchronos.API.Dto;
using MareSynchronos.API.Dto.CharaData;
using MareSynchronos.API.Dto.Group;
using MareSynchronos.API.Dto.User;
using MareSynchronos.MareConfiguration.Models;
using MareSynchronos.PlayerData.Handlers;
using MareSynchronos.PlayerData.Pairs;
@@ -12,7 +13,7 @@ using System.Numerics;
namespace MareSynchronos.Services.Mediator;
#pragma warning disable MA0048 // File name must match type name
#pragma warning disable MA0048
#pragma warning disable S2094
public record SwitchToIntroUiMessage : MessageBase;
public record SwitchToMainUiMessage : MessageBase;
@@ -52,6 +53,7 @@ public record HaltScanMessage(string Source) : MessageBase;
public record ResumeScanMessage(string Source) : MessageBase;
public record NotificationMessage
(string Title, string Message, NotificationType Type, TimeSpan? TimeShownOnScreen = null) : MessageBase;
public record DualNotificationMessage(string Title, string Message, NotificationType Type, TimeSpan? ToastDuration = null) : MessageBase;
public record CreateCacheForObjectMessage(GameObjectHandler ObjectToCreateFor) : MessageBase;
public record ClearCacheForObjectMessage(GameObjectHandler ObjectToCreateFor) : MessageBase;
public record CharacterDataCreatedMessage(CharacterData CharacterData) : SameThreadMessage;
@@ -90,6 +92,7 @@ public record PenumbraDirectoryChangedMessage(string? ModDirectory) : MessageBas
public record PenumbraRedrawCharacterMessage(ICharacter Character) : SameThreadMessage;
public record UserChatMsgMessage(SignedChatMessage ChatMsg) : MessageBase;
public record GroupChatMsgMessage(GroupDto GroupInfo, SignedChatMessage ChatMsg) : MessageBase;
public record UserTypingStateMessage(TypingStateDto Typing) : MessageBase;
public record RecalculatePerformanceMessage(string? UID) : MessageBase;
public record NameplateRedrawMessage : MessageBase;
public record HoldPairApplicationMessage(string UID, string Source) : KeyedMessage(UID);
@@ -108,6 +111,20 @@ public record GPoseLobbyReceiveCharaData(CharaDataDownloadDto CharaDataDownloadD
public record GPoseLobbyReceivePoseData(UserData UserData, PoseData PoseData) : MessageBase;
public record GPoseLobbyReceiveWorldData(UserData UserData, WorldData WorldData) : MessageBase;
public record NearbyEntry(string Name, ushort WorldId, float Distance, bool IsMatch, string? Token, string? DisplayName, string? Uid, bool AcceptPairRequests = true);
public record DiscoveryListUpdated(List<NearbyEntry> Entries) : MessageBase;
public record NearbyDetectionToggled(bool Enabled) : MessageBase;
public record AllowPairRequestsToggled(bool Enabled) : MessageBase;
public record SyncshellDiscoveryUpdated(List<SyncshellDiscoveryEntryDto> Entries) : MessageBase;
public record SyncshellAutoDetectStateChanged(string Gid, bool Visible, bool PasswordTemporarilyDisabled) : MessageBase;
public record ManualPairInviteMessage(string SourceUid, string SourceAlias, string TargetUid, string? DisplayName, string InviteId) : MessageBase;
public record ApplyDefaultPairPermissionsMessage(UserPairDto Pair) : MessageBase;
public record ApplyDefaultGroupPermissionsMessage(GroupPairFullInfoDto GroupPair) : MessageBase;
public record ApplyDefaultsToAllSyncsMessage(string? Context = null, bool? Disabled = null) : MessageBase;
public record PairSyncOverrideChanged(string Uid, bool? DisableSounds, bool? DisableAnimations, bool? DisableVfx) : MessageBase;
public record GroupSyncOverrideChanged(string Gid, bool? DisableSounds, bool? DisableAnimations, bool? DisableVfx) : MessageBase;
public record NotificationStateChanged(int TotalCount) : MessageBase;
public record PluginChangeMessage(string InternalName, Version Version, bool IsLoaded) : KeyedMessage(InternalName);
#pragma warning restore S2094
#pragma warning restore MA0048 // File name must match type name
#pragma warning restore MA0048

View File

@@ -0,0 +1,144 @@
using System;
using System.Collections.Generic;
using System.Linq;
using MareSynchronos.Services.Mediator;
using MareSynchronos.MareConfiguration;
using MareSynchronos.MareConfiguration.Configurations;
using MareSynchronos.MareConfiguration.Models;
namespace MareSynchronos.Services.Notifications;
public enum NotificationCategory
{
AutoDetect,
Syncshell,
}
public sealed record NotificationEntry(NotificationCategory Category, string Id, string Title, string? Description, DateTime CreatedAt)
{
public static NotificationEntry AutoDetect(string uid, string displayName)
=> new(NotificationCategory.AutoDetect, uid, displayName, "Nouvelle demande d'appairage via AutoDetect.", DateTime.UtcNow);
public static NotificationEntry SyncshellPublic(string gid, string aliasOrGid)
=> new(NotificationCategory.Syncshell, gid, $"Syncshell publique: {aliasOrGid}", "La Syncshell est désormais visible via AutoDetect.", DateTime.UtcNow);
public static NotificationEntry SyncshellNotPublic(string gid, string aliasOrGid)
=> new(NotificationCategory.Syncshell, gid, $"Syncshell non publique: {aliasOrGid}", "La Syncshell n'est plus visible via AutoDetect.", DateTime.UtcNow);
}
public sealed class NotificationTracker
{
private const int MaxStored = 100;
private readonly MareMediator _mediator;
private readonly NotificationsConfigService _configService;
private readonly Dictionary<(NotificationCategory Category, string Id), NotificationEntry> _entries = new();
private readonly object _lock = new();
public NotificationTracker(MareMediator mediator, NotificationsConfigService configService)
{
_mediator = mediator;
_configService = configService;
LoadPersisted();
PublishState();
}
public void Upsert(NotificationEntry entry)
{
lock (_lock)
{
_entries[(entry.Category, entry.Id)] = entry;
TrimIfNecessary_NoLock();
Persist_NoLock();
}
PublishState();
}
public void Remove(NotificationCategory category, string id)
{
lock (_lock)
{
_entries.Remove((category, id));
Persist_NoLock();
}
PublishState();
}
public IReadOnlyList<NotificationEntry> GetEntries()
{
lock (_lock)
{
return _entries.Values
.OrderBy(e => e.CreatedAt)
.ToList();
}
}
public int Count
{
get
{
lock (_lock)
{
return _entries.Count;
}
}
}
private void PublishState()
{
_mediator.Publish(new NotificationStateChanged(Count));
}
private void LoadPersisted()
{
try
{
var list = _configService.Current.Notifications ?? new List<StoredNotification>();
foreach (var s in list)
{
if (!Enum.TryParse<NotificationCategory>(s.Category, out var cat)) continue;
var entry = new NotificationEntry(cat, s.Id, s.Title, s.Description, s.CreatedAtUtc);
_entries[(entry.Category, entry.Id)] = entry;
}
TrimIfNecessary_NoLock();
}
catch
{
// ignore load errors, start empty
}
}
private void Persist_NoLock()
{
try
{
var stored = _entries.Values
.OrderBy(e => e.CreatedAt)
.Select(e => new StoredNotification
{
Category = e.Category.ToString(),
Id = e.Id,
Title = e.Title,
Description = e.Description,
CreatedAtUtc = e.CreatedAt
})
.ToList();
_configService.Current.Notifications = stored;
_configService.Save();
}
catch
{
// ignore persistence errors
}
}
private void TrimIfNecessary_NoLock()
{
if (_entries.Count <= MaxStored) return;
foreach (var kv in _entries.Values.OrderByDescending(v => v.CreatedAt).Skip(MaxStored).ToList())
{
_entries.Remove((kv.Category, kv.Id));
}
}
}

View File

@@ -1,4 +1,6 @@
using Dalamud.Game.Text.SeStringHandling;
using System;
using System.Linq;
using Dalamud.Game.Text.SeStringHandling;
using Dalamud.Interface.ImGuiNotification;
using Dalamud.Plugin.Services;
using MareSynchronos.MareConfiguration;
@@ -16,21 +18,29 @@ public class NotificationService : DisposableMediatorSubscriberBase, IHostedServ
private readonly INotificationManager _notificationManager;
private readonly IChatGui _chatGui;
private readonly MareConfigService _configurationService;
private readonly Services.Notifications.NotificationTracker _notificationTracker;
private readonly PlayerData.Pairs.PairManager _pairManager;
public NotificationService(ILogger<NotificationService> logger, MareMediator mediator,
DalamudUtilService dalamudUtilService,
INotificationManager notificationManager,
IChatGui chatGui, MareConfigService configurationService) : base(logger, mediator)
IChatGui chatGui, MareConfigService configurationService,
Services.Notifications.NotificationTracker notificationTracker,
PlayerData.Pairs.PairManager pairManager) : base(logger, mediator)
{
_dalamudUtilService = dalamudUtilService;
_notificationManager = notificationManager;
_chatGui = chatGui;
_configurationService = configurationService;
_notificationTracker = notificationTracker;
_pairManager = pairManager;
}
public Task StartAsync(CancellationToken cancellationToken)
{
Mediator.Subscribe<NotificationMessage>(this, ShowNotification);
Mediator.Subscribe<DualNotificationMessage>(this, ShowDualNotification);
Mediator.Subscribe<Services.Mediator.SyncshellAutoDetectStateChanged>(this, OnSyncshellAutoDetectStateChanged);
return Task.CompletedTask;
}
@@ -81,41 +91,128 @@ public class NotificationService : DisposableMediatorSubscriberBase, IHostedServ
if (!_dalamudUtilService.IsLoggedIn) return;
switch (msg.Type)
bool appendInstruction;
bool forceChat = ShouldForceChat(msg, out appendInstruction);
var effectiveMessage = forceChat && appendInstruction ? AppendAutoDetectInstruction(msg.Message) : msg.Message;
var adjustedMsg = forceChat && appendInstruction ? msg with { Message = effectiveMessage } : msg;
switch (adjustedMsg.Type)
{
case NotificationType.Info:
ShowNotificationLocationBased(msg, _configurationService.Current.InfoNotification);
ShowNotificationLocationBased(adjustedMsg, _configurationService.Current.InfoNotification, forceChat);
break;
case NotificationType.Warning:
ShowNotificationLocationBased(msg, _configurationService.Current.WarningNotification);
ShowNotificationLocationBased(adjustedMsg, _configurationService.Current.WarningNotification, forceChat);
break;
case NotificationType.Error:
ShowNotificationLocationBased(msg, _configurationService.Current.ErrorNotification);
ShowNotificationLocationBased(adjustedMsg, _configurationService.Current.ErrorNotification, forceChat);
break;
}
}
private void ShowNotificationLocationBased(NotificationMessage msg, NotificationLocation location)
private void ShowDualNotification(DualNotificationMessage message)
{
switch (location)
if (!_dalamudUtilService.IsLoggedIn) return;
var baseMsg = new NotificationMessage(message.Title, message.Message, message.Type, message.ToastDuration);
ShowToast(baseMsg);
ShowChat(baseMsg);
}
private void OnSyncshellAutoDetectStateChanged(SyncshellAutoDetectStateChanged msg)
{
try
{
case NotificationLocation.Toast:
ShowToast(msg);
break;
if (msg.Visible) return; // only handle transition to not visible
case NotificationLocation.Chat:
ShowChat(msg);
break;
var gid = msg.Gid;
// Try to resolve alias from PairManager snapshot; fallback to gid
var alias = _pairManager.Groups.Values.FirstOrDefault(g => string.Equals(g.GID, gid, StringComparison.OrdinalIgnoreCase))?.GroupAliasOrGID ?? gid;
case NotificationLocation.Both:
ShowToast(msg);
ShowChat(msg);
break;
var title = $"Syncshell non publique: {alias}";
var message = "La Syncshell n'est plus visible via AutoDetect.";
case NotificationLocation.Nowhere:
break;
// Show toast + chat
ShowDualNotification(new DualNotificationMessage(title, message, NotificationType.Info, TimeSpan.FromSeconds(4)));
// Persist into notification center
_notificationTracker.Upsert(Services.Notifications.NotificationEntry.SyncshellNotPublic(gid, alias));
}
catch
{
// ignore failures
}
}
private static bool ShouldForceChat(NotificationMessage msg, out bool appendInstruction)
{
appendInstruction = false;
bool IsNearbyRequestText(string? text)
{
if (string.IsNullOrEmpty(text)) return false;
return text.Contains("Nearby request", StringComparison.OrdinalIgnoreCase)
|| text.Contains("Nearby Request", StringComparison.Ordinal);
}
bool IsNearbyAcceptText(string? text)
{
if (string.IsNullOrEmpty(text)) return false;
return text.Contains("Nearby Accept", StringComparison.OrdinalIgnoreCase);
}
bool isAccept = IsNearbyAcceptText(msg.Title) || IsNearbyAcceptText(msg.Message);
if (isAccept)
return false;
bool isRequest = IsNearbyRequestText(msg.Title) || IsNearbyRequestText(msg.Message);
if (isRequest)
{
appendInstruction = !IsRequestSentConfirmation(msg);
return true;
}
return false;
}
private static bool IsRequestSentConfirmation(NotificationMessage msg)
{
if (string.Equals(msg.Title, "Nearby request sent", StringComparison.OrdinalIgnoreCase))
return true;
if (!string.IsNullOrEmpty(msg.Message) && msg.Message.Contains("The other user will receive a request notification.", StringComparison.OrdinalIgnoreCase))
return true;
return false;
}
private static string AppendAutoDetectInstruction(string? message)
{
const string suffix = " | Ouvrez /autodetect pour gérer l'invitation.";
if (string.IsNullOrWhiteSpace(message))
return suffix.TrimStart(' ', '|');
if (message.Contains("/autodetect", StringComparison.OrdinalIgnoreCase))
return message;
return message.TrimEnd() + suffix;
}
private void ShowNotificationLocationBased(NotificationMessage msg, NotificationLocation location, bool forceChat)
{
bool showToast = location is NotificationLocation.Toast or NotificationLocation.Both;
bool showChat = forceChat || location is NotificationLocation.Chat or NotificationLocation.Both;
if (showToast)
{
ShowToast(msg);
}
if (showChat)
{
ShowChat(msg);
}
}
@@ -138,4 +235,4 @@ public class NotificationService : DisposableMediatorSubscriberBase, IHostedServ
InitialDuration = msg.TimeShownOnScreen ?? TimeSpan.FromSeconds(3)
});
}
}
}

View File

@@ -1,4 +1,5 @@
using MareSynchronos.API.Data;
using System;
using MareSynchronos.API.Data;
using MareSynchronos.API.Data.Enum;
using MareSynchronos.FileCache;
using MareSynchronos.PlayerData.Pairs;
@@ -14,7 +15,7 @@ public sealed class PairAnalyzer : DisposableMediatorSubscriberBase
private readonly FileCacheManager _fileCacheManager;
private readonly XivDataAnalyzer _xivDataAnalyzer;
private CancellationTokenSource? _analysisCts;
private CancellationTokenSource _baseAnalysisCts = new();
private CancellationTokenSource? _baseAnalysisCts = new();
private string _lastDataHash = string.Empty;
public PairAnalyzer(ILogger<PairAnalyzer> logger, Pair pair, MareMediator mediator, FileCacheManager fileCacheManager, XivDataAnalyzer modelAnalyzer)
@@ -24,8 +25,8 @@ public sealed class PairAnalyzer : DisposableMediatorSubscriberBase
#if DEBUG
Mediator.SubscribeKeyed<PairDataAppliedMessage>(this, pair.UserData.UID, (msg) =>
{
_baseAnalysisCts = _baseAnalysisCts.CancelRecreate();
var token = _baseAnalysisCts.Token;
var tokenSource = EnsureFreshCts(ref _baseAnalysisCts);
var token = tokenSource.Token;
if (msg.CharacterData != null)
{
_ = BaseAnalysis(msg.CharacterData, token);
@@ -56,17 +57,15 @@ public sealed class PairAnalyzer : DisposableMediatorSubscriberBase
public void CancelAnalyze()
{
_analysisCts?.CancelDispose();
_analysisCts = null;
CancelAndDispose(ref _analysisCts);
}
public async Task ComputeAnalysis(bool print = true, bool recalculate = false)
{
Logger.LogDebug("=== Calculating Character Analysis ===");
_analysisCts = _analysisCts?.CancelRecreate() ?? new();
var cancelToken = _analysisCts.Token;
var analysisCts = EnsureFreshCts(ref _analysisCts);
var cancelToken = analysisCts.Token;
var allFiles = LastAnalysis.SelectMany(v => v.Value.Select(d => d.Value)).ToList();
if (allFiles.Exists(c => !c.IsComputed || recalculate))
@@ -102,8 +101,7 @@ public sealed class PairAnalyzer : DisposableMediatorSubscriberBase
LastPlayerName = Pair.PlayerName ?? string.Empty;
Mediator.Publish(new PairDataAnalyzedMessage(Pair.UserData.UID));
_analysisCts.CancelDispose();
_analysisCts = null;
CancelAndDispose(ref _analysisCts);
if (print) PrintAnalysis();
}
@@ -114,8 +112,8 @@ public sealed class PairAnalyzer : DisposableMediatorSubscriberBase
if (!disposing) return;
_analysisCts?.CancelDispose();
_baseAnalysisCts.CancelDispose();
CancelAndDispose(ref _analysisCts);
CancelAndDispose(ref _baseAnalysisCts);
}
private async Task BaseAnalysis(CharacterData charaData, CancellationToken token)
@@ -211,4 +209,26 @@ public sealed class PairAnalyzer : DisposableMediatorSubscriberBase
UiSharedService.ByteToString(LastAnalysis.Values.Sum(c => c.Values.Sum(v => v.OriginalSize))),
UiSharedService.ByteToString(LastAnalysis.Values.Sum(c => c.Values.Sum(v => v.CompressedSize))));
}
}
private static CancellationTokenSource EnsureFreshCts(ref CancellationTokenSource? cts)
{
CancelAndDispose(ref cts);
cts = new CancellationTokenSource();
return cts;
}
private static void CancelAndDispose(ref CancellationTokenSource? cts)
{
if (cts == null) return;
try
{
cts.Cancel();
}
catch (ObjectDisposedException)
{
}
cts.Dispose();
cts = null;
}
}

View File

@@ -0,0 +1,77 @@
using MareSynchronos.MareConfiguration;
using System.Collections.Generic;
using MareSynchronos.PlayerData.Pairs;
using System;
using System.Linq;
using Dalamud.Game.Text;
using Dalamud.Game.Text.SeStringHandling;
using Dalamud.Game.Text.SeStringHandling.Payloads;
using Dalamud.Plugin.Services;
using MareSynchronos.API.Dto.User;
using MareSynchronos.Services.Mediator;
using Microsoft.Extensions.Logging;
namespace MareSynchronos.Services;
public class PartyListTypingService : DisposableMediatorSubscriberBase
{
private readonly ILogger<PartyListTypingService> _logger;
private readonly IPartyList _partyList;
private readonly MareConfigService _configService;
private readonly PairManager _pairManager;
private readonly TypingIndicatorStateService _typingStateService;
private static readonly TimeSpan TypingDisplayTime = TimeSpan.FromSeconds(2);
private static readonly TimeSpan TypingDisplayFade = TypingDisplayTime;
public PartyListTypingService(ILogger<PartyListTypingService> logger,
MareMediator mediator,
IPartyList partyList,
PairManager pairManager,
MareConfigService configService,
TypingIndicatorStateService typingStateService)
: base(logger, mediator)
{
_logger = logger;
_partyList = partyList;
_pairManager = pairManager;
_configService = configService;
_typingStateService = typingStateService;
}
public void Draw()
{
if (!_configService.Current.TypingIndicatorEnabled) return;
if (!_configService.Current.TypingIndicatorShowOnPartyList) return;
var visibleByAlias = new Dictionary<string, string>(StringComparer.OrdinalIgnoreCase);
try
{
var visibleUsers = _pairManager.GetVisibleUsers();
foreach (var u in visibleUsers)
{
var alias = string.IsNullOrEmpty(u.AliasOrUID) ? u.UID : u.AliasOrUID;
if (!visibleByAlias.ContainsKey(alias)) visibleByAlias[alias] = u.UID;
}
}
catch (Exception ex)
{
_logger.LogDebug(ex, "PartyListTypingService: failed to get visible users");
}
var activeTypers = _typingStateService.GetActiveTypers(TypingDisplayTime);
var now = DateTime.UtcNow;
foreach (var member in _partyList)
{
if (string.IsNullOrEmpty(member.Name?.TextValue)) continue;
var displayName = member.Name.TextValue;
if (visibleByAlias.TryGetValue(displayName, out var uid)
&& activeTypers.TryGetValue(uid, out var entry)
&& (now - entry.LastUpdate) <= TypingDisplayFade)
{
_logger.LogDebug("PartyListTypingService: bubble would be shown for {name}", displayName);
}
}
}
}

View File

@@ -23,7 +23,6 @@ public class PlayerPerformanceService : DisposableMediatorSubscriberBase
private readonly MareMediator _mediator;
private readonly ServerConfigurationManager _serverConfigurationManager;
private readonly PlayerPerformanceConfigService _playerPerformanceConfigService;
private readonly Dictionary<string, bool> _warnedForPlayers = new(StringComparer.Ordinal);
public PlayerPerformanceService(ILogger<PlayerPerformanceService> logger, MareMediator mediator,
ServerConfigurationManager serverConfigurationManager,
@@ -327,4 +326,4 @@ public class PlayerPerformanceService : DisposableMediatorSubscriberBase
return shrunken;
}
}
}

View File

@@ -0,0 +1,358 @@
using System;
using System.Collections.Generic;
using System.Linq;
using System.Threading.Tasks;
using MareSynchronos.API.Data;
using MareSynchronos.API.Data.Enum;
using MareSynchronos.API.Data.Extensions;
using MareSynchronos.API.Dto.Group;
using MareSynchronos.API.Dto.User;
using MareSynchronos.MareConfiguration;
using MareSynchronos.MareConfiguration.Configurations;
using MareSynchronos.MareConfiguration.Models;
using MareSynchronos.PlayerData.Pairs;
using MareSynchronos.Services.Mediator;
using MareSynchronos.WebAPI;
using Microsoft.Extensions.Logging;
using NotificationType = MareSynchronos.MareConfiguration.Models.NotificationType;
namespace MareSynchronos.Services;
public sealed class SyncDefaultsService : DisposableMediatorSubscriberBase
{
private readonly ApiController _apiController;
private readonly MareConfigService _configService;
private readonly PairManager _pairManager;
public SyncDefaultsService(ILogger<SyncDefaultsService> logger, MareMediator mediator,
MareConfigService configService, ApiController apiController, PairManager pairManager) : base(logger, mediator)
{
_configService = configService;
_apiController = apiController;
_pairManager = pairManager;
Mediator.Subscribe<ApplyDefaultPairPermissionsMessage>(this, OnApplyPairDefaults);
Mediator.Subscribe<ApplyDefaultGroupPermissionsMessage>(this, OnApplyGroupDefaults);
Mediator.Subscribe<ApplyDefaultsToAllSyncsMessage>(this, msg => ApplyDefaultsToAll(msg));
Mediator.Subscribe<PairSyncOverrideChanged>(this, OnPairOverrideChanged);
Mediator.Subscribe<GroupSyncOverrideChanged>(this, OnGroupOverrideChanged);
}
private void OnApplyPairDefaults(ApplyDefaultPairPermissionsMessage message)
{
var config = _configService.Current;
var permissions = message.Pair.OwnPermissions;
var overrides = TryGetPairOverride(message.Pair.User.UID);
if (!ApplyDefaults(ref permissions, config, overrides))
return;
_ = _apiController.UserSetPairPermissions(new UserPermissionsDto(message.Pair.User, permissions));
}
private void OnApplyGroupDefaults(ApplyDefaultGroupPermissionsMessage message)
{
if (!string.Equals(message.GroupPair.User.UID, _apiController.UID, StringComparison.Ordinal))
return;
var config = _configService.Current;
var permissions = message.GroupPair.GroupUserPermissions;
var overrides = TryGetGroupOverride(message.GroupPair.Group.GID);
if (!ApplyDefaults(ref permissions, config, overrides))
return;
_ = _apiController.GroupChangeIndividualPermissionState(new GroupPairUserPermissionDto(message.GroupPair.Group, message.GroupPair.User, permissions));
}
private async Task ApplyDefaultsToAllAsync(ApplyDefaultsToAllSyncsMessage message)
{
try
{
var config = _configService.Current;
var tasks = new List<Task>();
int updatedPairs = 0;
int updatedGroups = 0;
foreach (var pair in _pairManager.DirectPairs.Where(p => p.UserPair != null).ToList())
{
var permissions = pair.UserPair!.OwnPermissions;
var overrides = TryGetPairOverride(pair.UserData.UID);
if (!ApplyDefaults(ref permissions, config, overrides))
continue;
updatedPairs++;
tasks.Add(_apiController.UserSetPairPermissions(new UserPermissionsDto(pair.UserData, permissions)));
}
var selfUser = new UserData(_apiController.UID);
foreach (var groupInfo in _pairManager.Groups.Values.ToList())
{
var permissions = groupInfo.GroupUserPermissions;
var overrides = TryGetGroupOverride(groupInfo.Group.GID);
if (!ApplyDefaults(ref permissions, config, overrides))
continue;
updatedGroups++;
tasks.Add(_apiController.GroupChangeIndividualPermissionState(new GroupPairUserPermissionDto(groupInfo.Group, selfUser, permissions)));
}
if (tasks.Count > 0)
{
try
{
await Task.WhenAll(tasks).ConfigureAwait(false);
}
catch (Exception ex)
{
Logger.LogWarning(ex, "Failed applying default sync settings to all pairs/groups");
}
}
var summary = BuildSummaryMessage(updatedPairs, updatedGroups);
var primary = BuildPrimaryMessage(message);
var combined = string.IsNullOrEmpty(primary) ? summary : string.Concat(primary, ' ', summary);
Mediator.Publish(new DualNotificationMessage("Préférences appliquées", combined, NotificationType.Info));
}
catch (Exception ex)
{
Logger.LogError(ex, "Unexpected error while applying default sync settings to all pairs/groups");
Mediator.Publish(new DualNotificationMessage("Préférences appliquées", "Une erreur est survenue lors de l'application des paramètres par défaut.", NotificationType.Error));
}
}
private void ApplyDefaultsToAll(ApplyDefaultsToAllSyncsMessage message) => _ = ApplyDefaultsToAllAsync(message);
private static string? BuildPrimaryMessage(ApplyDefaultsToAllSyncsMessage message)
{
if (string.IsNullOrEmpty(message.Context) || message.Disabled == null)
return null;
var state = message.Disabled.Value ? "désactivée" : "activée";
return $"Synchronisation {message.Context} par défaut {state}.";
}
private static string BuildSummaryMessage(int pairs, int groups)
{
if (pairs == 0 && groups == 0)
return "Aucun pair ou syncshell n'avait besoin d'être modifié.";
if (pairs > 0 && groups > 0)
return $"Mise à jour de {pairs} pair(s) et {groups} syncshell(s).";
if (pairs > 0)
return $"Mise à jour de {pairs} pair(s).";
return $"Mise à jour de {groups} syncshell(s).";
}
private void OnPairOverrideChanged(PairSyncOverrideChanged message)
{
var overrides = _configService.Current.PairSyncOverrides ??= new(StringComparer.Ordinal);
var entry = overrides.TryGetValue(message.Uid, out var existing) ? existing : new SyncOverrideEntry();
bool changed = false;
if (message.DisableSounds.HasValue)
{
var val = message.DisableSounds.Value;
var defaultVal = _configService.Current.DefaultDisableSounds;
var newValue = val == defaultVal ? (bool?)null : val;
if (entry.DisableSounds != newValue)
{
entry.DisableSounds = newValue;
changed = true;
}
}
if (message.DisableAnimations.HasValue)
{
var val = message.DisableAnimations.Value;
var defaultVal = _configService.Current.DefaultDisableAnimations;
var newValue = val == defaultVal ? (bool?)null : val;
if (entry.DisableAnimations != newValue)
{
entry.DisableAnimations = newValue;
changed = true;
}
}
if (message.DisableVfx.HasValue)
{
var val = message.DisableVfx.Value;
var defaultVal = _configService.Current.DefaultDisableVfx;
var newValue = val == defaultVal ? (bool?)null : val;
if (entry.DisableVfx != newValue)
{
entry.DisableVfx = newValue;
changed = true;
}
}
if (!changed) return;
if (entry.IsEmpty)
overrides.Remove(message.Uid);
else
overrides[message.Uid] = entry;
_configService.Save();
}
private void OnGroupOverrideChanged(GroupSyncOverrideChanged message)
{
var overrides = _configService.Current.GroupSyncOverrides ??= new(StringComparer.Ordinal);
var entry = overrides.TryGetValue(message.Gid, out var existing) ? existing : new SyncOverrideEntry();
bool changed = false;
if (message.DisableSounds.HasValue)
{
var val = message.DisableSounds.Value;
var defaultVal = _configService.Current.DefaultDisableSounds;
var newValue = val == defaultVal ? (bool?)null : val;
if (entry.DisableSounds != newValue)
{
entry.DisableSounds = newValue;
changed = true;
}
}
if (message.DisableAnimations.HasValue)
{
var val = message.DisableAnimations.Value;
var defaultVal = _configService.Current.DefaultDisableAnimations;
var newValue = val == defaultVal ? (bool?)null : val;
if (entry.DisableAnimations != newValue)
{
entry.DisableAnimations = newValue;
changed = true;
}
}
if (message.DisableVfx.HasValue)
{
var val = message.DisableVfx.Value;
var defaultVal = _configService.Current.DefaultDisableVfx;
var newValue = val == defaultVal ? (bool?)null : val;
if (entry.DisableVfx != newValue)
{
entry.DisableVfx = newValue;
changed = true;
}
}
if (!changed) return;
if (entry.IsEmpty)
overrides.Remove(message.Gid);
else
overrides[message.Gid] = entry;
_configService.Save();
}
private SyncOverrideEntry? TryGetPairOverride(string uid)
{
var overrides = _configService.Current.PairSyncOverrides;
return overrides != null && overrides.TryGetValue(uid, out var entry) ? entry : null;
}
private SyncOverrideEntry? TryGetGroupOverride(string gid)
{
var overrides = _configService.Current.GroupSyncOverrides;
return overrides != null && overrides.TryGetValue(gid, out var entry) ? entry : null;
}
private static bool ApplyDefaults(ref UserPermissions permissions, MareConfig config, SyncOverrideEntry? overrides)
{
bool changed = false;
if (overrides?.DisableSounds is bool overrideSounds)
{
if (permissions.IsDisableSounds() != overrideSounds)
{
permissions.SetDisableSounds(overrideSounds);
changed = true;
}
}
else if (permissions.IsDisableSounds() != config.DefaultDisableSounds)
{
permissions.SetDisableSounds(config.DefaultDisableSounds);
changed = true;
}
if (overrides?.DisableAnimations is bool overrideAnims)
{
if (permissions.IsDisableAnimations() != overrideAnims)
{
permissions.SetDisableAnimations(overrideAnims);
changed = true;
}
}
else if (permissions.IsDisableAnimations() != config.DefaultDisableAnimations)
{
permissions.SetDisableAnimations(config.DefaultDisableAnimations);
changed = true;
}
if (overrides?.DisableVfx is bool overrideVfx)
{
if (permissions.IsDisableVFX() != overrideVfx)
{
permissions.SetDisableVFX(overrideVfx);
changed = true;
}
}
else if (permissions.IsDisableVFX() != config.DefaultDisableVfx)
{
permissions.SetDisableVFX(config.DefaultDisableVfx);
changed = true;
}
return changed;
}
private static bool ApplyDefaults(ref GroupUserPermissions permissions, MareConfig config, SyncOverrideEntry? overrides)
{
bool changed = false;
if (overrides?.DisableSounds is bool overrideSounds)
{
if (permissions.IsDisableSounds() != overrideSounds)
{
permissions.SetDisableSounds(overrideSounds);
changed = true;
}
}
else if (permissions.IsDisableSounds() != config.DefaultDisableSounds)
{
permissions.SetDisableSounds(config.DefaultDisableSounds);
changed = true;
}
if (overrides?.DisableAnimations is bool overrideAnims)
{
if (permissions.IsDisableAnimations() != overrideAnims)
{
permissions.SetDisableAnimations(overrideAnims);
changed = true;
}
}
else if (permissions.IsDisableAnimations() != config.DefaultDisableAnimations)
{
permissions.SetDisableAnimations(config.DefaultDisableAnimations);
changed = true;
}
if (overrides?.DisableVfx is bool overrideVfx)
{
if (permissions.IsDisableVFX() != overrideVfx)
{
permissions.SetDisableVFX(overrideVfx);
changed = true;
}
}
else if (permissions.IsDisableVFX() != config.DefaultDisableVfx)
{
permissions.SetDisableVFX(config.DefaultDisableVfx);
changed = true;
}
return changed;
}
}

View File

@@ -0,0 +1,225 @@
using System.Globalization;
using System.Threading;
using MareSynchronos.API.Dto.Group;
using MareSynchronos.MareConfiguration.Models;
using MareSynchronos.PlayerData.Pairs;
using MareSynchronos.Services.Mediator;
using MareSynchronos.WebAPI;
using Microsoft.Extensions.Hosting;
using Microsoft.Extensions.Logging;
namespace MareSynchronos.Services;
public sealed class TemporarySyncshellNotificationService : MediatorSubscriberBase, IHostedService
{
private static readonly int[] NotificationThresholdMinutes = [30, 15, 5, 1];
private readonly ApiController _apiController;
private readonly PairManager _pairManager;
private readonly Lock _stateLock = new();
private readonly Dictionary<string, TrackedGroup> _trackedGroups = new(StringComparer.Ordinal);
private CancellationTokenSource? _loopCts;
private Task? _loopTask;
public TemporarySyncshellNotificationService(ILogger<TemporarySyncshellNotificationService> logger, MareMediator mediator, PairManager pairManager, ApiController apiController)
: base(logger, mediator)
{
_pairManager = pairManager;
_apiController = apiController;
}
public Task StartAsync(CancellationToken cancellationToken)
{
_loopCts = new CancellationTokenSource();
Mediator.Subscribe<ConnectedMessage>(this, _ => ResetTrackedGroups());
Mediator.Subscribe<DisconnectedMessage>(this, _ => ResetTrackedGroups());
_loopTask = Task.Run(() => MonitorLoopAsync(_loopCts.Token), _loopCts.Token);
return Task.CompletedTask;
}
public async Task StopAsync(CancellationToken cancellationToken)
{
Mediator.UnsubscribeAll(this);
if (_loopCts == null)
{
return;
}
try
{
_loopCts.Cancel();
if (_loopTask != null)
{
await _loopTask.ConfigureAwait(false);
}
}
catch (OperationCanceledException)
{
}
finally
{
_loopTask = null;
_loopCts.Dispose();
_loopCts = null;
}
}
private async Task MonitorLoopAsync(CancellationToken ct)
{
var delay = TimeSpan.FromSeconds(30);
while (!ct.IsCancellationRequested)
{
try
{
CheckGroups();
}
catch (Exception ex)
{
Logger.LogDebug(ex, "Failed to check temporary syncshell expirations");
}
try
{
await Task.Delay(delay, ct).ConfigureAwait(false);
}
catch (OperationCanceledException)
{
break;
}
}
}
private void CheckGroups()
{
var nowUtc = DateTime.UtcNow;
var groupsSnapshot = _pairManager.Groups.Values.ToList();
var notifications = new List<NotificationPayload>();
var expiredGroups = new List<GroupFullInfoDto>();
var seenTemporaryGids = new HashSet<string>(StringComparer.Ordinal);
using (var guard = _stateLock.EnterScope())
{
foreach (var group in groupsSnapshot)
{
if (!group.IsTemporary || group.ExpiresAt == null)
{
continue;
}
if (string.IsNullOrEmpty(_apiController.UID) || !string.Equals(group.OwnerUID, _apiController.UID, StringComparison.Ordinal))
{
continue;
}
var gid = group.Group.GID;
seenTemporaryGids.Add(gid);
var expiresAtUtc = NormalizeToUtc(group.ExpiresAt.Value);
var remaining = expiresAtUtc - nowUtc;
if (!_trackedGroups.TryGetValue(gid, out var state))
{
state = new TrackedGroup(expiresAtUtc);
_trackedGroups[gid] = state;
}
else if (state.ExpiresAtUtc != expiresAtUtc)
{
state.UpdateExpiresAt(expiresAtUtc);
}
if (remaining <= TimeSpan.Zero)
{
_trackedGroups.Remove(gid);
expiredGroups.Add(group);
continue;
}
if (!state.LastRemaining.HasValue)
{
state.UpdateRemaining(remaining);
continue;
}
var previousRemaining = state.LastRemaining.Value;
foreach (var thresholdMinutes in NotificationThresholdMinutes)
{
var threshold = TimeSpan.FromMinutes(thresholdMinutes);
if (previousRemaining > threshold && remaining <= threshold)
{
notifications.Add(new NotificationPayload(group, thresholdMinutes, expiresAtUtc));
}
}
state.UpdateRemaining(remaining);
}
var toRemove = _trackedGroups.Keys.Where(k => !seenTemporaryGids.Contains(k)).ToList();
foreach (var gid in toRemove)
{
_trackedGroups.Remove(gid);
}
}
foreach (var expiredGroup in expiredGroups)
{
Logger.LogInformation("Temporary syncshell {gid} expired locally; removing", expiredGroup.Group.GID);
_pairManager.RemoveGroup(expiredGroup.Group);
}
foreach (var notification in notifications)
{
PublishNotification(notification.Group, notification.ThresholdMinutes, notification.ExpiresAtUtc);
}
}
private void PublishNotification(GroupFullInfoDto group, int thresholdMinutes, DateTime expiresAtUtc)
{
string displayName = string.IsNullOrWhiteSpace(group.GroupAlias) ? group.Group.GID : group.GroupAlias!;
string threshold = thresholdMinutes == 1 ? "1 minute" : $"{thresholdMinutes} minutes";
string expiresLocal = expiresAtUtc.ToLocalTime().ToString("t", CultureInfo.CurrentCulture);
string message = $"La Syncshell temporaire \"{displayName}\" sera supprimee dans {threshold} (a {expiresLocal}).";
Mediator.Publish(new NotificationMessage("Syncshell temporaire", message, NotificationType.Warning, TimeSpan.FromSeconds(6)));
}
private static DateTime NormalizeToUtc(DateTime expiresAt)
{
return expiresAt.Kind switch
{
DateTimeKind.Utc => expiresAt,
DateTimeKind.Local => expiresAt.ToUniversalTime(),
_ => DateTime.SpecifyKind(expiresAt, DateTimeKind.Utc)
};
}
private void ResetTrackedGroups()
{
using (var guard = _stateLock.EnterScope())
{
_trackedGroups.Clear();
}
}
private sealed class TrackedGroup
{
public TrackedGroup(DateTime expiresAtUtc)
{
ExpiresAtUtc = expiresAtUtc;
}
public DateTime ExpiresAtUtc { get; private set; }
public TimeSpan? LastRemaining { get; private set; }
public void UpdateExpiresAt(DateTime expiresAtUtc)
{
ExpiresAtUtc = expiresAtUtc;
LastRemaining = null;
}
public void UpdateRemaining(TimeSpan remaining)
{
LastRemaining = remaining;
}
}
private sealed record NotificationPayload(GroupFullInfoDto Group, int ThresholdMinutes, DateTime ExpiresAtUtc);
}

View File

@@ -0,0 +1,131 @@
using System;
using System.Collections.Concurrent;
using System.Linq;
using MareSynchronos.Services.Mediator;
using MareSynchronos.WebAPI;
using Microsoft.Extensions.Logging;
using MareSynchronos.API.Data;
using MareSynchronos.MareConfiguration;
namespace MareSynchronos.Services;
public sealed class TypingIndicatorStateService : IMediatorSubscriber, IDisposable
{
private sealed record TypingEntry(UserData User, DateTime FirstSeen, DateTime LastUpdate, MareSynchronos.API.Data.Enum.TypingScope Scope);
private readonly ConcurrentDictionary<string, TypingEntry> _typingUsers = new(StringComparer.Ordinal);
private readonly ApiController _apiController;
private readonly ILogger<TypingIndicatorStateService> _logger;
private readonly MareConfigService _configService;
private DateTime _selfTypingLast = DateTime.MinValue;
private DateTime _selfTypingStart = DateTime.MinValue;
private bool _selfTypingActive;
public TypingIndicatorStateService(ILogger<TypingIndicatorStateService> logger, MareMediator mediator, ApiController apiController, MareConfigService configService)
{
_logger = logger;
_apiController = apiController;
_configService = configService;
Mediator = mediator;
mediator.Subscribe<UserTypingStateMessage>(this, OnTypingState);
}
public void Dispose()
{
Mediator.UnsubscribeAll(this);
}
public MareMediator Mediator { get; }
public void SetSelfTypingLocal(bool isTyping)
{
if (isTyping)
{
if (!_selfTypingActive)
_selfTypingStart = DateTime.UtcNow;
_selfTypingLast = DateTime.UtcNow;
}
else
{
_selfTypingStart = DateTime.MinValue;
}
_selfTypingActive = isTyping;
}
public void ClearAll()
{
_typingUsers.Clear();
_selfTypingActive = false;
_selfTypingStart = DateTime.MinValue;
_selfTypingLast = DateTime.MinValue;
_logger.LogDebug("TypingIndicatorStateService: cleared all typing state");
}
private void OnTypingState(UserTypingStateMessage msg)
{
if (!_configService.Current.TypingIndicatorEnabled)
return;
var uid = msg.Typing.User.UID;
var now = DateTime.UtcNow;
if (string.Equals(uid, _apiController.UID, StringComparison.Ordinal))
{
_selfTypingActive = msg.Typing.IsTyping;
if (_selfTypingActive)
{
if (_selfTypingStart == DateTime.MinValue)
_selfTypingStart = now;
_selfTypingLast = now;
}
else
{
_selfTypingStart = DateTime.MinValue;
}
_logger.LogInformation("Typing state self -> {state}", _selfTypingActive);
}
else if (msg.Typing.IsTyping)
{
_typingUsers.AddOrUpdate(uid,
_ => new TypingEntry(msg.Typing.User, now, now, msg.Typing.Scope),
(_, existing) => new TypingEntry(msg.Typing.User, existing.FirstSeen, now, msg.Typing.Scope));
}
else
{
_typingUsers.TryRemove(uid, out _);
}
}
public bool TryGetSelfTyping(TimeSpan maxAge, out DateTime startTyping, out DateTime lastTyping)
{
startTyping = _selfTypingStart;
lastTyping = _selfTypingLast;
if (!_selfTypingActive)
return false;
var now = DateTime.UtcNow;
if ((now - _selfTypingLast) >= maxAge)
{
_selfTypingActive = false;
_selfTypingStart = DateTime.MinValue;
return false;
}
return true;
}
public IReadOnlyDictionary<string, (UserData User, DateTime FirstSeen, DateTime LastUpdate, MareSynchronos.API.Data.Enum.TypingScope Scope)> GetActiveTypers(TimeSpan maxAge)
{
var now = DateTime.UtcNow;
foreach (var kvp in _typingUsers.ToArray())
{
if ((now - kvp.Value.LastUpdate) >= maxAge)
{
_typingUsers.TryRemove(kvp.Key, out _);
}
}
return _typingUsers.ToDictionary(k => k.Key, v => (v.Value.User, v.Value.FirstSeen, v.Value.LastUpdate, v.Value.Scope), StringComparer.Ordinal);
}
}

View File

@@ -1,7 +1,9 @@
using MareSynchronos.API.Dto.Group;
using MareSynchronos.PlayerData.Pairs;
using MareSynchronos.Services.AutoDetect;
using MareSynchronos.Services.Mediator;
using MareSynchronos.Services.ServerConfiguration;
using MareSynchronos.Services.Notifications;
using MareSynchronos.UI;
using MareSynchronos.UI.Components.Popup;
using MareSynchronos.WebAPI;
@@ -19,31 +21,35 @@ public class UiFactory
private readonly ServerConfigurationManager _serverConfigManager;
private readonly MareProfileManager _mareProfileManager;
private readonly PerformanceCollectorService _performanceCollectorService;
private readonly SyncshellDiscoveryService _syncshellDiscoveryService;
private readonly NotificationTracker _notificationTracker;
public UiFactory(ILoggerFactory loggerFactory, MareMediator mareMediator, ApiController apiController,
UiSharedService uiSharedService, PairManager pairManager, ServerConfigurationManager serverConfigManager,
MareProfileManager mareProfileManager, PerformanceCollectorService performanceCollectorService)
UiSharedService uiSharedService, PairManager pairManager, SyncshellDiscoveryService syncshellDiscoveryService, ServerConfigurationManager serverConfigManager,
MareProfileManager mareProfileManager, PerformanceCollectorService performanceCollectorService, NotificationTracker notificationTracker)
{
_loggerFactory = loggerFactory;
_mareMediator = mareMediator;
_apiController = apiController;
_uiSharedService = uiSharedService;
_pairManager = pairManager;
_syncshellDiscoveryService = syncshellDiscoveryService;
_serverConfigManager = serverConfigManager;
_mareProfileManager = mareProfileManager;
_performanceCollectorService = performanceCollectorService;
_notificationTracker = notificationTracker;
}
public SyncshellAdminUI CreateSyncshellAdminUi(GroupFullInfoDto dto)
{
return new SyncshellAdminUI(_loggerFactory.CreateLogger<SyncshellAdminUI>(), _mareMediator,
_apiController, _uiSharedService, _pairManager, dto, _performanceCollectorService);
_apiController, _uiSharedService, _pairManager, _syncshellDiscoveryService, dto, _performanceCollectorService, _notificationTracker);
}
public StandaloneProfileUi CreateStandaloneProfileUi(Pair pair)
{
return new StandaloneProfileUi(_loggerFactory.CreateLogger<StandaloneProfileUi>(), _mareMediator,
_uiSharedService, _serverConfigManager, _mareProfileManager, _pairManager, pair, _performanceCollectorService);
_uiSharedService, _serverConfigManager, _mareProfileManager, pair, _performanceCollectorService);
}
public PermissionWindowUI CreatePermissionPopupUi(Pair pair)

View File

@@ -0,0 +1,532 @@
using System;
using System.Collections.Generic;
using System.Linq;
using System.Threading;
using System.Threading.Tasks;
using Dalamud.Bindings.ImGui;
using Dalamud.Interface.Colors;
using Dalamud.Interface.Utility;
using Dalamud.Interface.Utility.Raii;
using MareSynchronos.API.Dto.Group;
using MareSynchronos.MareConfiguration;
using MareSynchronos.PlayerData.Pairs;
using MareSynchronos.Services.Mediator;
using Microsoft.Extensions.Logging;
using System.Numerics;
using System.Globalization;
using System.Text;
using MareSynchronos.Services;
using MareSynchronos.Services.AutoDetect;
using NotificationType = MareSynchronos.MareConfiguration.Models.NotificationType;
namespace MareSynchronos.UI;
public class AutoDetectUi : WindowMediatorSubscriberBase
{
private readonly MareConfigService _configService;
private readonly DalamudUtilService _dalamud;
private readonly AutoDetectRequestService _requestService;
private readonly NearbyDiscoveryService _discoveryService;
private readonly NearbyPendingService _pendingService;
private readonly PairManager _pairManager;
private List<Services.Mediator.NearbyEntry> _entries;
private readonly HashSet<string> _acceptInFlight = new(StringComparer.Ordinal);
private readonly SyncshellDiscoveryService _syncshellDiscoveryService;
private List<SyncshellDiscoveryEntryDto> _syncshellEntries = [];
private bool _syncshellInitialized;
private readonly HashSet<string> _syncshellJoinInFlight = new(StringComparer.OrdinalIgnoreCase);
private string? _syncshellLastError;
public AutoDetectUi(ILogger<AutoDetectUi> logger, MareMediator mediator,
MareConfigService configService, DalamudUtilService dalamudUtilService,
AutoDetectRequestService requestService, NearbyPendingService pendingService, PairManager pairManager,
NearbyDiscoveryService discoveryService, SyncshellDiscoveryService syncshellDiscoveryService,
PerformanceCollectorService performanceCollectorService)
: base(logger, mediator, "AutoDetect", performanceCollectorService)
{
_configService = configService;
_dalamud = dalamudUtilService;
_requestService = requestService;
_pendingService = pendingService;
_pairManager = pairManager;
_discoveryService = discoveryService;
_syncshellDiscoveryService = syncshellDiscoveryService;
Mediator.Subscribe<Services.Mediator.DiscoveryListUpdated>(this, OnDiscoveryUpdated);
Mediator.Subscribe<SyncshellDiscoveryUpdated>(this, OnSyncshellDiscoveryUpdated);
_entries = _discoveryService.SnapshotEntries();
Flags |= ImGuiWindowFlags.NoScrollbar;
SizeConstraints = new WindowSizeConstraints()
{
MinimumSize = new Vector2(350, 220),
MaximumSize = new Vector2(600, 600),
};
}
public override bool DrawConditions()
{
return true;
}
protected override void DrawInternal()
{
using var idScope = ImRaii.PushId("autodetect-ui");
var incomingInvites = _pendingService.Pending.ToList();
var outgoingInvites = _requestService.GetPendingRequestsSnapshot();
Vector4 accent = UiSharedService.AccentColor;
if (accent.W <= 0f) accent = ImGuiColors.ParsedPurple;
Vector4 inactiveTab = new(accent.X * 0.45f, accent.Y * 0.45f, accent.Z * 0.45f, Math.Clamp(accent.W + 0.15f, 0f, 1f));
Vector4 hoverTab = UiSharedService.AccentHoverColor;
using var tabs = ImRaii.TabBar("AutoDetectTabs");
if (!tabs.Success) return;
var incomingCount = incomingInvites.Count;
DrawStyledTab($"Invitations ({incomingCount})", accent, inactiveTab, hoverTab, () =>
{
DrawInvitationsTab(incomingInvites, outgoingInvites);
});
DrawStyledTab("Proximité", accent, inactiveTab, hoverTab, DrawNearbyTab);
DrawStyledTab("Syncshell", accent, inactiveTab, hoverTab, DrawSyncshellTab);
}
public void DrawInline()
{
DrawInternal();
}
private static void DrawStyledTab(string label, Vector4 accent, Vector4 inactive, Vector4 hover, Action draw, bool disabled = false)
{
var tabColor = disabled ? ImGuiColors.DalamudGrey3 : inactive;
var tabHover = disabled ? ImGuiColors.DalamudGrey3 : hover;
var tabActive = disabled ? ImGuiColors.DalamudGrey2 : accent;
using var baseColor = ImRaii.PushColor(ImGuiCol.Tab, tabColor);
using var hoverColor = ImRaii.PushColor(ImGuiCol.TabHovered, tabHover);
using var activeColor = ImRaii.PushColor(ImGuiCol.TabActive, tabActive);
using var activeText = ImRaii.PushColor(ImGuiCol.Text, disabled ? ImGuiColors.DalamudGrey2 : Vector4.One, false);
using var tab = ImRaii.TabItem(label);
if (tab.Success)
{
draw();
}
}
private void DrawInvitationsTab(List<KeyValuePair<string, string>> incomingInvites, IReadOnlyCollection<AutoDetectRequestService.PendingRequestInfo> outgoingInvites)
{
if (incomingInvites.Count == 0 && outgoingInvites.Count == 0)
{
UiSharedService.ColorTextWrapped("Aucune invitation en attente. Cette page regroupera les demandes reçues et celles que vous avez envoyées.", ImGuiColors.DalamudGrey3);
return;
}
if (incomingInvites.Count == 0)
{
UiSharedService.ColorTextWrapped("Vous n'avez aucune invitation de pair en attente pour le moment.", ImGuiColors.DalamudGrey3);
}
ImGuiHelpers.ScaledDummy(4);
float leftWidth = Math.Max(220f * ImGuiHelpers.GlobalScale, ImGui.CalcTextSize("Invitations reçues (00)").X + ImGui.GetStyle().FramePadding.X * 4f);
var avail = ImGui.GetContentRegionAvail();
ImGui.BeginChild("incoming-requests", new Vector2(leftWidth, avail.Y), true);
ImGui.TextColored(ImGuiColors.DalamudOrange, $"Invitations reçues ({incomingInvites.Count})");
ImGui.Separator();
if (incomingInvites.Count == 0)
{
ImGui.TextDisabled("Aucune invitation reçue.");
}
else
{
foreach (var (uid, name) in incomingInvites.OrderBy(k => k.Value, StringComparer.OrdinalIgnoreCase))
{
using var id = ImRaii.PushId(uid);
bool processing = _acceptInFlight.Contains(uid);
ImGui.TextUnformatted(name);
ImGui.TextDisabled(uid);
if (processing)
{
ImGui.TextDisabled("Traitement en cours...");
}
else
{
if (ImGui.Button("Accepter"))
{
TriggerAccept(uid);
}
ImGui.SameLine();
if (ImGui.Button("Refuser"))
{
_pendingService.Remove(uid);
}
}
ImGui.Separator();
}
}
ImGui.EndChild();
ImGui.SameLine();
ImGui.BeginChild("outgoing-requests", new Vector2(0, avail.Y), true);
ImGui.TextColored(ImGuiColors.DalamudOrange, $"Invitations envoyées ({outgoingInvites.Count})");
ImGui.Separator();
if (outgoingInvites.Count == 0)
{
ImGui.TextDisabled("Aucune invitation envoyée en attente.");
ImGui.EndChild();
return;
}
foreach (var info in outgoingInvites.OrderByDescending(i => i.SentAt))
{
using var id = ImRaii.PushId(info.Key);
ImGui.TextUnformatted(info.TargetDisplayName);
if (!string.IsNullOrEmpty(info.Uid))
{
ImGui.TextDisabled(info.Uid);
}
ImGui.TextDisabled($"Envoyée il y a {FormatDuration(DateTime.UtcNow - info.SentAt)}");
if (ImGui.Button("Retirer"))
{
_requestService.RemovePendingRequestByKey(info.Key);
}
UiSharedService.AttachToolTip("Retire uniquement cette entrée locale de suivi.");
ImGui.Separator();
}
ImGui.EndChild();
}
private void DrawNearbyTab()
{
if (!_configService.Current.EnableAutoDetectDiscovery)
{
UiSharedService.ColorTextWrapped("AutoDetect est désactivé. Activez-le dans les paramètres pour détecter les utilisateurs Umbra à proximité.", ImGuiColors.DalamudYellow);
ImGuiHelpers.ScaledDummy(6);
}
int maxDist = Math.Clamp(_configService.Current.AutoDetectMaxDistanceMeters, 5, 100);
ImGui.AlignTextToFramePadding();
ImGui.TextUnformatted("Max distance (m)");
ImGui.SameLine();
ImGui.SetNextItemWidth(120 * ImGuiHelpers.GlobalScale);
if (ImGui.SliderInt("##autodetect-dist", ref maxDist, 5, 100))
{
_configService.Current.AutoDetectMaxDistanceMeters = maxDist;
_configService.Save();
}
ImGuiHelpers.ScaledDummy(6);
var sourceEntries = _entries.Count > 0 ? _entries : _discoveryService.SnapshotEntries();
var orderedEntries = sourceEntries
.Where(e => e.IsMatch)
.OrderBy(e => float.IsNaN(e.Distance) ? float.MaxValue : e.Distance)
.ToList();
if (orderedEntries.Count == 0)
{
UiSharedService.ColorTextWrapped("Aucune présence UmbraSync détectée à proximité pour le moment.", ImGuiColors.DalamudGrey3);
return;
}
if (!ImGui.BeginTable("autodetect-nearby", 5, ImGuiTableFlags.SizingStretchProp | ImGuiTableFlags.RowBg))
{
return;
}
ImGui.TableSetupColumn("Nom");
ImGui.TableSetupColumn("Monde");
ImGui.TableSetupColumn("Distance");
ImGui.TableSetupColumn("Statut");
ImGui.TableSetupColumn("Action");
ImGui.TableHeadersRow();
for (int i = 0; i < orderedEntries.Count; i++)
{
var entry = orderedEntries[i];
bool alreadyPaired = IsAlreadyPairedByUidOrAlias(entry);
bool overDistance = !float.IsNaN(entry.Distance) && entry.Distance > maxDist;
bool canRequest = entry.AcceptPairRequests && !string.IsNullOrEmpty(entry.Token) && !alreadyPaired;
string displayName = entry.DisplayName ?? entry.Name;
string worldName = entry.WorldId == 0
? "-"
: (_dalamud.WorldData.Value.TryGetValue(entry.WorldId, out var mappedWorld) ? mappedWorld : entry.WorldId.ToString(CultureInfo.InvariantCulture));
string distanceText = float.IsNaN(entry.Distance) ? "-" : $"{entry.Distance:0.0} m";
string status = alreadyPaired
? "Déjà appairé"
: overDistance
? $"Hors portée (> {maxDist} m)"
: !entry.AcceptPairRequests
? "Invitations refusées"
: string.IsNullOrEmpty(entry.Token)
? "Indisponible"
: "Disponible";
ImGui.TableNextColumn();
ImGui.TextUnformatted(displayName);
ImGui.TableNextColumn();
ImGui.TextUnformatted(worldName);
ImGui.TableNextColumn();
ImGui.TextUnformatted(distanceText);
ImGui.TableNextColumn();
ImGui.TextUnformatted(status);
ImGui.TableNextColumn();
using (ImRaii.PushId(i))
{
if (canRequest && !overDistance)
{
if (ImGui.Button("Envoyer invitation"))
{
_ = _requestService.SendRequestAsync(entry.Token!, entry.Uid, entry.DisplayName);
}
UiSharedService.AttachToolTip("Envoie une demande d'appairage via AutoDetect.");
}
else
{
string reason = alreadyPaired
? "Vous êtes déjà appairé avec ce joueur."
: overDistance
? $"Ce joueur est au-delà de la distance maximale configurée ({maxDist} m)."
: !entry.AcceptPairRequests
? "Ce joueur a désactivé la réception automatique des invitations."
: string.IsNullOrEmpty(entry.Token)
? "Impossible d'obtenir un jeton d'invitation pour ce joueur."
: string.Empty;
ImGui.TextDisabled(status);
if (!string.IsNullOrEmpty(reason))
{
UiSharedService.AttachToolTip(reason);
}
}
}
}
ImGui.EndTable();
}
private async Task JoinSyncshellAsync(SyncshellDiscoveryEntryDto entry)
{
if (!_syncshellJoinInFlight.Add(entry.GID))
{
return;
}
try
{
var joined = await _syncshellDiscoveryService.JoinAsync(entry.GID, CancellationToken.None).ConfigureAwait(false);
if (joined)
{
Mediator.Publish(new NotificationMessage("AutoDetect Syncshell", $"Rejoint {entry.Alias ?? entry.GID}.", NotificationType.Info, TimeSpan.FromSeconds(5)));
await _syncshellDiscoveryService.RefreshAsync(CancellationToken.None).ConfigureAwait(false);
}
else
{
_syncshellLastError = $"Impossible de rejoindre {entry.Alias ?? entry.GID}.";
Mediator.Publish(new NotificationMessage("AutoDetect Syncshell", _syncshellLastError, NotificationType.Warning, TimeSpan.FromSeconds(5)));
}
}
catch (Exception ex)
{
_syncshellLastError = $"Erreur lors de l'adhésion : {ex.Message}";
Mediator.Publish(new NotificationMessage("AutoDetect Syncshell", _syncshellLastError, NotificationType.Error, TimeSpan.FromSeconds(5)));
}
finally
{
_syncshellJoinInFlight.Remove(entry.GID);
}
}
private void DrawSyncshellTab()
{
if (!_syncshellInitialized)
{
_syncshellInitialized = true;
_ = _syncshellDiscoveryService.RefreshAsync(CancellationToken.None);
}
bool isRefreshing = _syncshellDiscoveryService.IsRefreshing;
var serviceError = _syncshellDiscoveryService.LastError;
if (ImGui.Button("Actualiser la liste"))
{
_ = _syncshellDiscoveryService.RefreshAsync(CancellationToken.None);
}
UiSharedService.AttachToolTip("Met à jour la liste des Syncshells ayant activé l'AutoDetect.");
if (isRefreshing)
{
ImGui.SameLine();
ImGui.TextDisabled("Actualisation...");
}
ImGuiHelpers.ScaledDummy(4);
UiSharedService.TextWrapped("Les Syncshells affichées ont temporairement désactivé leur mot de passe pour permettre un accès direct via AutoDetect. Rejoignez-les uniquement si vous faites confiance aux administrateurs.");
if (!string.IsNullOrEmpty(serviceError))
{
UiSharedService.ColorTextWrapped(serviceError, ImGuiColors.DalamudRed);
}
else if (!string.IsNullOrEmpty(_syncshellLastError))
{
UiSharedService.ColorTextWrapped(_syncshellLastError!, ImGuiColors.DalamudOrange);
}
var entries = _syncshellEntries.Count > 0 ? _syncshellEntries : _syncshellDiscoveryService.Entries.ToList();
if (entries.Count == 0)
{
ImGuiHelpers.ScaledDummy(4);
UiSharedService.ColorTextWrapped("Aucune Syncshell n'est actuellement visible dans AutoDetect.", ImGuiColors.DalamudGrey3);
return;
}
if (!ImGui.BeginTable("autodetect-syncshells", 5, ImGuiTableFlags.SizingStretchProp | ImGuiTableFlags.RowBg))
{
return;
}
ImGui.TableSetupColumn("Nom");
ImGui.TableSetupColumn("Propriétaire");
ImGui.TableSetupColumn("Membres");
ImGui.TableSetupColumn("Invitations");
ImGui.TableSetupColumn("Action");
ImGui.TableHeadersRow();
foreach (var entry in entries.OrderBy(e => e.Alias ?? e.GID, StringComparer.OrdinalIgnoreCase))
{
bool alreadyMember = _pairManager.Groups.Keys.Any(g => string.Equals(g.GID, entry.GID, StringComparison.OrdinalIgnoreCase));
bool joining = _syncshellJoinInFlight.Contains(entry.GID);
ImGui.TableNextColumn();
ImGui.TextUnformatted(string.IsNullOrEmpty(entry.Alias) ? entry.GID : $"{entry.Alias} ({entry.GID})");
ImGui.TableNextColumn();
ImGui.TextUnformatted(string.IsNullOrEmpty(entry.OwnerAlias) ? entry.OwnerUID : $"{entry.OwnerAlias} ({entry.OwnerUID})");
ImGui.TableNextColumn();
ImGui.TextUnformatted(entry.MemberCount.ToString(CultureInfo.InvariantCulture));
ImGui.TableNextColumn();
string inviteMode = entry.AutoAcceptPairs ? "Auto" : "Manuel";
ImGui.TextUnformatted(inviteMode);
if (!entry.AutoAcceptPairs)
{
UiSharedService.AttachToolTip("L'administrateur doit approuver manuellement les nouveaux membres.");
}
ImGui.TableNextColumn();
using (ImRaii.Disabled(alreadyMember || joining))
{
if (alreadyMember)
{
ImGui.TextDisabled("Déjà membre");
}
else if (joining)
{
ImGui.TextDisabled("Connexion...");
}
else if (ImGui.Button("Rejoindre"))
{
_syncshellLastError = null;
_ = JoinSyncshellAsync(entry);
}
}
}
ImGui.EndTable();
}
private void OnDiscoveryUpdated(Services.Mediator.DiscoveryListUpdated msg)
{
_entries = msg.Entries;
}
private void OnSyncshellDiscoveryUpdated(SyncshellDiscoveryUpdated msg)
{
_syncshellEntries = msg.Entries;
}
private bool IsAlreadyPairedByUidOrAlias(Services.Mediator.NearbyEntry e)
{
try
{
// 1) Match by UID when available (authoritative)
if (!string.IsNullOrEmpty(e.Uid))
{
foreach (var p in _pairManager.DirectPairs)
{
if (string.Equals(p.UserData.UID, e.Uid, StringComparison.Ordinal))
return true;
}
}
var key = NormalizeKey(e.DisplayName ?? e.Name);
if (string.IsNullOrEmpty(key)) return false;
foreach (var p in _pairManager.DirectPairs)
{
if (NormalizeKey(p.UserData.AliasOrUID) == key) return true;
if (!string.IsNullOrEmpty(p.UserData.Alias) && NormalizeKey(p.UserData.Alias!) == key) return true;
}
}
catch
{
}
return false;
}
private static string NormalizeKey(string? input)
{
if (string.IsNullOrWhiteSpace(input)) return string.Empty;
var formD = input.Normalize(NormalizationForm.FormD);
var sb = new StringBuilder(formD.Length);
foreach (var ch in formD)
{
var cat = CharUnicodeInfo.GetUnicodeCategory(ch);
if (cat != UnicodeCategory.NonSpacingMark)
sb.Append(char.ToLowerInvariant(ch));
}
return sb.ToString();
}
private void TriggerAccept(string uid)
{
if (!_acceptInFlight.Add(uid)) return;
Task.Run(async () =>
{
try
{
bool ok = await _pendingService.AcceptAsync(uid).ConfigureAwait(false);
if (!ok)
{
Mediator.Publish(new NotificationMessage("AutoDetect", $"Impossible d'accepter l'invitation {uid}.", NotificationType.Warning, TimeSpan.FromSeconds(5)));
}
}
finally
{
_acceptInFlight.Remove(uid);
}
});
}
private static string FormatDuration(TimeSpan span)
{
if (span.TotalMinutes >= 1)
{
var minutes = Math.Max(1, (int)Math.Round(span.TotalMinutes));
return minutes == 1 ? "1 minute" : $"{minutes} minutes";
}
var seconds = Math.Max(1, (int)Math.Round(span.TotalSeconds));
return seconds == 1 ? "1 seconde" : $"{seconds} secondes";
}
}

View File

@@ -0,0 +1,245 @@
using System;
using System.Collections.Generic;
using System.Numerics;
using System.Reflection;
using Dalamud.Bindings.ImGui;
using Dalamud.Interface;
using Dalamud.Interface.Colors;
using Dalamud.Interface.Utility.Raii;
using MareSynchronos.MareConfiguration;
using MareSynchronos.Services;
using MareSynchronos.Services.Mediator;
using Microsoft.Extensions.Logging;
namespace MareSynchronos.UI;
public sealed class ChangelogUi : WindowMediatorSubscriberBase
{
private const int AlwaysExpandedEntryCount = 2;
private readonly MareConfigService _configService;
private readonly UiSharedService _uiShared;
private readonly Version _currentVersion;
private readonly string _currentVersionLabel;
private readonly IReadOnlyList<ChangelogEntry> _entries;
private bool _showAllEntries;
private bool _hasAcknowledgedVersion;
public ChangelogUi(ILogger<ChangelogUi> logger, UiSharedService uiShared, MareConfigService configService,
MareMediator mediator, PerformanceCollectorService performanceCollectorService)
: base(logger, mediator, "Umbra Sync - Notes de version", performanceCollectorService)
{
_uiShared = uiShared;
_configService = configService;
_currentVersion = Assembly.GetExecutingAssembly().GetName().Version ?? new Version(0, 0, 0, 0);
_currentVersionLabel = _currentVersion.ToString();
_entries = BuildEntries();
_hasAcknowledgedVersion = string.Equals(_configService.Current.LastChangelogVersionSeen, _currentVersionLabel, StringComparison.Ordinal);
RespectCloseHotkey = true;
SizeConstraints = new()
{
MinimumSize = new(520, 360),
MaximumSize = new(900, 1200)
};
Flags |= ImGuiWindowFlags.NoResize;
ShowCloseButton = true;
if (!string.Equals(_configService.Current.LastChangelogVersionSeen, _currentVersionLabel, StringComparison.Ordinal))
{
IsOpen = true;
}
}
public override void OnClose()
{
MarkCurrentVersionAsReadIfNeeded();
base.OnClose();
}
protected override void DrawInternal()
{
_ = _uiShared.DrawOtherPluginState();
DrawHeader();
DrawEntries();
DrawFooter();
}
private void DrawHeader()
{
using (_uiShared.UidFont.Push())
{
ImGui.TextUnformatted("Notes de version");
}
ImGui.TextColored(ImGuiColors.DalamudGrey, $"Version chargée : {_currentVersionLabel}");
ImGui.Separator();
}
private void DrawEntries()
{
bool expandedOldVersions = false;
for (int index = 0; index < _entries.Count; index++)
{
var entry = _entries[index];
if (!_showAllEntries && index >= AlwaysExpandedEntryCount)
{
if (!expandedOldVersions)
{
expandedOldVersions = ImGui.CollapsingHeader("Historique complet");
}
if (!expandedOldVersions)
{
continue;
}
}
DrawEntry(entry);
}
}
private void DrawEntry(ChangelogEntry entry)
{
using (ImRaii.PushId(entry.VersionLabel))
{
ImGui.Spacing();
UiSharedService.ColorText(entry.VersionLabel, entry.Version == _currentVersion
? ImGuiColors.HealerGreen
: ImGuiColors.DalamudWhite);
ImGui.Spacing();
foreach (var line in entry.Lines)
{
DrawLine(line);
}
ImGui.Spacing();
ImGui.Separator();
}
}
private static void DrawLine(ChangelogLine line)
{
using var indent = line.IndentLevel > 0 ? ImRaii.PushIndent(line.IndentLevel) : null;
if (line.Color != null)
{
ImGui.TextColored(line.Color.Value, $"- {line.Text}");
}
else
{
ImGui.TextUnformatted($"- {line.Text}");
}
}
private void DrawFooter()
{
ImGui.Spacing();
if (!_showAllEntries && _entries.Count > AlwaysExpandedEntryCount)
{
if (ImGui.Button("Tout afficher"))
{
_showAllEntries = true;
}
ImGui.SameLine();
}
if (ImGui.Button("Marquer comme lu"))
{
MarkCurrentVersionAsReadIfNeeded();
IsOpen = false;
}
}
private void MarkCurrentVersionAsReadIfNeeded()
{
if (_hasAcknowledgedVersion)
return;
_configService.Current.LastChangelogVersionSeen = _currentVersionLabel;
_configService.Save();
_hasAcknowledgedVersion = true;
}
private static IReadOnlyList<ChangelogEntry> BuildEntries()
{
return new List<ChangelogEntry>
{
new(new Version(0, 1, 9, 5), "0.1.9.5", new List<ChangelogLine>
{
new("Fix l'affichage de la bulle dans la liste du groupe."),
new("Amélioration de l'ajout des utilisateurs via le bouton +."),
new("Possibilité de mettre en pause individuellement des utilisateurs d'une syncshell."),
new("Amélioration de la stabilité du plugin en cas de petite connexion / petite configuration."),
new("Divers fix de l'interface."),
}),
new(new Version(0, 1, 9, 4), "0.1.9.4", new List<ChangelogLine>
{
new("Réécriture complète de la bulle de frappe avec la possibilité de choisir la taille de la bulle."),
new("Désactivation de l'AutoDetect en zone instanciée."),
new("Réécriture interface AutoDetect pour acceuillir les invitations en attente et préparer les synchsells publiques."),
new("Amélioration de la compréhension des activations / désactivations des préférences de synchronisation par défaut."),
new("Mise en avant du Self Analyse avec une alerte lorsqu'un seuil de donnée a été atteint."),
new("Ajout de l'alerte de la non-compatibilité du plugin Chat2."),
new("Divers fix de l'interface."),
}),
new(new Version(0, 1, 9, 3), "0.1.9.3", new List<ChangelogLine>
{
new("Correctif de l'affichage de la bulle de frappe quand l'interface est à + de 100%."),
}),
new(new Version(0, 1, 9, 2), "0.1.9.2", new List<ChangelogLine>
{
new("Correctif de l'affichage de la bulle de frappe."),
}),
new(new Version(0, 1, 9, 1), "0.1.9.1", new List<ChangelogLine>
{
new("Début correctif pour la bulle de frappe."),
new("Les bascules de synchronisation n'affichent plus qu'une seule notification résumée."),
}),
new(new Version(0, 1, 9, 0), "0.1.9.0", new List<ChangelogLine>
{
new("Il est désormais possible de configurer par défaut nos choix de synchronisation (VFX, Music, Animation)."),
new("La catégorie 'En attente' ne s'affiche uniquement que si une invitation est en attente"),
new("(EN PRÉ VERSION) Il est désormais possible de voir quand une personne appairée est en train d'écrire avec une bulle qui s'affiche."),
new("(EN PRÉ VERSION) La bulle de frappe s'affiche également sur votre propre plaque de nom lorsque vous écrivez."),
new("Les bascules de synchronisation n'affichent plus qu'une seule notification résumée."),
new("Correctif : Désormais, les invitation entrantes ne s'affichent qu'une seule fois au lieu de deux."),
}),
new(new Version(0, 1, 8, 2), "0.1.8.2", new List<ChangelogLine>
{
new("Détection Nearby : la liste rapide ne montre plus que les joueurs réellement invitables."),
new("Sont filtrés automatiquement les personnes refusées ou déjà appairées."),
new("Invitations Nearby : anti-spam de 5 minutes par personne, blocage 15 minutes après trois refus."),
new("Affichage : Correction de l'affichage des notes par défaut plutôt que de l'ID si disponible."),
new("Les notifications de blocage sont envoyées directement dans le tchat."),
new("Overlay DTR : affiche le nombre d'invitations Nearby disponibles dans le titre et l'infobulle."),
new("Poses Nearby : le filtre re-fonctionne avec vos notes locales pour retrouver les entrées correspondantes."),
}),
new(new Version(0, 1, 8, 1), "0.1.8.1", new List<ChangelogLine>
{
new("Correctif 'Vu sous' : l'infobulle affiche désormais le dernier personnage observé."),
new("Invitations AutoDetect : triées en tête de liste pour mieux les repérer."),
new("Invitations AutoDetect : conservées entre les redémarrages du plugin ou du jeu."),
new("Barre de statut serveur : couleur violette adoptée par défaut."),
}),
new(new Version(0, 1, 8, 0), "0.1.8.0", new List<ChangelogLine>
{
new("AutoDetect : détection automatique des joueurs Umbra autour de vous et propositions d'appairage."),
new("AutoDetect : désactivé par défaut pour préserver la confidentialité.", 1, ImGuiColors.DalamudGrey),
new("AutoDetect : activez-le dans 'Transfers' avec les options Nearby detection et Allow pair requests.", 1, ImGuiColors.DalamudGrey),
new("Syncshell temporaire : durée configurable de 1 h à 7 jours, expiration automatique."),
new("Syncshell permanente : possibilité de nommer et d'organiser vos groupes sur la durée."),
new("Interface : palette UmbraSync harmonisée et menus allégés pour l'usage RP."),
}),
};
}
private readonly record struct ChangelogEntry(Version Version, string VersionLabel, IReadOnlyList<ChangelogLine> Lines);
private readonly record struct ChangelogLine(string Text, int IndentLevel = 0, System.Numerics.Vector4? Color = null);
}

View File

@@ -6,7 +6,7 @@ using System.Text;
namespace MareSynchronos.UI;
internal sealed partial class CharaDataHubUi
public sealed partial class CharaDataHubUi
{
private static string GetAccessTypeString(AccessTypeDto dto) => dto switch
{

View File

@@ -7,7 +7,7 @@ using MareSynchronos.Services.CharaData.Models;
namespace MareSynchronos.UI;
internal sealed partial class CharaDataHubUi
public sealed partial class CharaDataHubUi
{
private string _joinLobbyId = string.Empty;
private void DrawGposeTogether()
@@ -15,14 +15,14 @@ internal sealed partial class CharaDataHubUi
if (!_charaDataManager.BrioAvailable)
{
ImGuiHelpers.ScaledDummy(5);
UiSharedService.DrawGroupedCenteredColorText("BRIO IS MANDATORY FOR GPOSE TOGETHER.", ImGuiColors.DalamudRed);
UiSharedService.DrawGroupedCenteredColorText("BRIO IS MANDATORY FOR GPOSE TOGETHER.", UiSharedService.AccentColor);
ImGuiHelpers.ScaledDummy(5);
}
if (!_uiSharedService.ApiController.IsConnected)
{
ImGuiHelpers.ScaledDummy(5);
UiSharedService.DrawGroupedCenteredColorText("CANNOT USE GPOSE TOGETHER WHILE DISCONNECTED FROM THE SERVER.", ImGuiColors.DalamudRed);
UiSharedService.DrawGroupedCenteredColorText("CANNOT USE GPOSE TOGETHER WHILE DISCONNECTED FROM THE SERVER.", UiSharedService.AccentColor);
ImGuiHelpers.ScaledDummy(5);
}
@@ -90,7 +90,7 @@ internal sealed partial class CharaDataHubUi
if (!_uiSharedService.IsInGpose)
{
ImGuiHelpers.ScaledDummy(5);
UiSharedService.DrawGroupedCenteredColorText("Assigning users to characters is only available in GPose.", ImGuiColors.DalamudYellow, 300);
UiSharedService.DrawGroupedCenteredColorText("Assigning users to characters is only available in GPose.", UiSharedService.AccentColor, 300);
}
UiSharedService.DistanceSeparator();
ImGui.TextUnformatted("Users In Lobby");
@@ -104,7 +104,7 @@ internal sealed partial class CharaDataHubUi
if (!_charaDataGposeTogetherManager.UsersInLobby.Any() && !string.IsNullOrEmpty(_charaDataGposeTogetherManager.CurrentGPoseLobbyId))
{
UiSharedService.DrawGroupedCenteredColorText("No other users in current GPose lobby", ImGuiColors.DalamudYellow);
UiSharedService.DrawGroupedCenteredColorText("No other users in current GPose lobby", UiSharedService.AccentColor);
}
else
{
@@ -165,7 +165,7 @@ internal sealed partial class CharaDataHubUi
UiSharedService.AttachToolTip(user.WorldDataDescriptor + UiSharedService.TooltipSeparator);
ImGui.SameLine();
_uiSharedService.IconText(FontAwesomeIcon.Map, sameMapAndServer.SameMap ? ImGuiColors.ParsedGreen : ImGuiColors.DalamudRed);
_uiSharedService.IconText(FontAwesomeIcon.Map, sameMapAndServer.SameMap ? ImGuiColors.ParsedGreen : UiSharedService.AccentColor);
if (ImGui.IsItemClicked(ImGuiMouseButton.Left) && user.WorldData != null)
{
_dalamudUtilService.SetMarkerAndOpenMap(new(user.WorldData.Value.PositionX, user.WorldData.Value.PositionY, user.WorldData.Value.PositionZ), user.Map);
@@ -175,12 +175,12 @@ internal sealed partial class CharaDataHubUi
+ "Note: For GPose synchronization to work properly, you must be on the same map.");
ImGui.SameLine();
_uiSharedService.IconText(FontAwesomeIcon.Globe, sameMapAndServer.SameServer ? ImGuiColors.ParsedGreen : ImGuiColors.DalamudRed);
_uiSharedService.IconText(FontAwesomeIcon.Globe, sameMapAndServer.SameServer ? ImGuiColors.ParsedGreen : UiSharedService.AccentColor);
UiSharedService.AttachToolTip((sameMapAndServer.SameMap ? "You are on the same server." : "You are not on the same server.") + UiSharedService.TooltipSeparator
+ "Note: GPose synchronization is not dependent on the current server, but you will have to spawn a character for the other lobby users.");
ImGui.SameLine();
_uiSharedService.IconText(FontAwesomeIcon.Running, sameMapAndServer.SameEverything ? ImGuiColors.ParsedGreen : ImGuiColors.DalamudRed);
_uiSharedService.IconText(FontAwesomeIcon.Running, sameMapAndServer.SameEverything ? ImGuiColors.ParsedGreen : UiSharedService.AccentColor);
UiSharedService.AttachToolTip(sameMapAndServer.SameEverything ? "You are in the same instanced area." : "You are not the same instanced area." + UiSharedService.TooltipSeparator +
"Note: Users not in your instance, but on the same map, will be drawn as floating wisps." + Environment.NewLine
+ "Note: GPose synchronization is not dependent on the current instance, but you will have to spawn a character for the other lobby users.");
@@ -217,7 +217,7 @@ internal sealed partial class CharaDataHubUi
if (_uiSharedService.IsInGpose && user.Address == nint.Zero)
{
ImGui.SameLine();
_uiSharedService.IconText(FontAwesomeIcon.ExclamationTriangle, ImGuiColors.DalamudRed);
_uiSharedService.IconText(FontAwesomeIcon.ExclamationTriangle, UiSharedService.AccentColor);
UiSharedService.AttachToolTip("No valid character assigned for this user. Pose data will not be applied.");
}
}

View File

@@ -9,7 +9,7 @@ using System.Numerics;
namespace MareSynchronos.UI;
internal sealed partial class CharaDataHubUi
public sealed partial class CharaDataHubUi
{
private void DrawEditCharaData(CharaDataFullExtendedDto? dataDto)
{
@@ -18,7 +18,7 @@ internal sealed partial class CharaDataHubUi
if (dataDto == null)
{
ImGuiHelpers.ScaledDummy(5);
UiSharedService.DrawGroupedCenteredColorText("Select an entry above to edit its data.", ImGuiColors.DalamudYellow);
UiSharedService.DrawGroupedCenteredColorText("Select an entry above to edit its data.", UiSharedService.AccentColor);
return;
}
@@ -26,7 +26,7 @@ internal sealed partial class CharaDataHubUi
if (updateDto == null)
{
UiSharedService.DrawGroupedCenteredColorText("Something went awfully wrong and there's no update DTO. Try updating Character Data via the button above.", ImGuiColors.DalamudYellow);
UiSharedService.DrawGroupedCenteredColorText("Something went awfully wrong and there's no update DTO. Try updating Character Data via the button above.", UiSharedService.AccentColor);
return;
}
@@ -45,7 +45,7 @@ internal sealed partial class CharaDataHubUi
if (canUpdate)
{
ImGui.AlignTextToFramePadding();
UiSharedService.ColorTextWrapped("Warning: You have unsaved changes!", ImGuiColors.DalamudRed);
UiSharedService.ColorTextWrapped("Warning: You have unsaved changes!", UiSharedService.AccentColor);
ImGui.SameLine();
using (ImRaii.Disabled(_charaDataManager.CharaUpdateTask != null && !_charaDataManager.CharaUpdateTask.IsCompleted))
{
@@ -61,7 +61,7 @@ internal sealed partial class CharaDataHubUi
}
if (_charaDataManager.CharaUpdateTask != null && !_charaDataManager.CharaUpdateTask.IsCompleted)
{
UiSharedService.ColorTextWrapped("Updating data on server, please wait.", ImGuiColors.DalamudYellow);
UiSharedService.ColorTextWrapped("Updating data on server, please wait.", UiSharedService.AccentColor);
}
}
@@ -71,7 +71,7 @@ internal sealed partial class CharaDataHubUi
{
if (_charaDataManager.UploadProgress != null)
{
UiSharedService.ColorTextWrapped(_charaDataManager.UploadProgress.Value ?? string.Empty, ImGuiColors.DalamudYellow);
UiSharedService.ColorTextWrapped(_charaDataManager.UploadProgress.Value ?? string.Empty, UiSharedService.AccentColor);
}
if ((!_charaDataManager.UploadTask?.IsCompleted ?? false) && _uiSharedService.IconTextButton(FontAwesomeIcon.Ban, "Cancel Upload"))
{
@@ -216,7 +216,7 @@ internal sealed partial class CharaDataHubUi
}
else
{
UiSharedService.ColorTextWrapped($"{dataDto.MissingFiles.DistinctBy(k => k.HashOrFileSwap).Count()} files to download this character data are missing on the server", ImGuiColors.DalamudRed);
UiSharedService.ColorTextWrapped($"{dataDto.MissingFiles.DistinctBy(k => k.HashOrFileSwap).Count()} files to download this character data are missing on the server", UiSharedService.AccentColor);
ImGui.NewLine();
ImGui.SameLine(pos);
if (_uiSharedService.IconTextButton(FontAwesomeIcon.ArrowCircleUp, "Attempt to upload missing files and restore Character Data"))
@@ -230,7 +230,7 @@ internal sealed partial class CharaDataHubUi
ImGui.SameLine();
ImGuiHelpers.ScaledDummy(20, 1);
ImGui.SameLine();
UiSharedService.ColorTextWrapped("New data was set. It may contain files that require to be uploaded (will happen on Saving to server)", ImGuiColors.DalamudYellow);
UiSharedService.ColorTextWrapped("New data was set. It may contain files that require to be uploaded (will happen on Saving to server)", UiSharedService.AccentColor);
}
ImGui.TextUnformatted("Contains Manipulation Data");
@@ -385,7 +385,7 @@ internal sealed partial class CharaDataHubUi
}
}
ImGui.SameLine();
using (ImRaii.PushColor(ImGuiCol.Text, ImGuiColors.DalamudYellow, poseCount == maxPoses))
using (ImRaii.PushColor(ImGuiCol.Text, UiSharedService.AccentColor, poseCount == maxPoses))
ImGui.TextUnformatted($"{poseCount}/{maxPoses} poses attached");
ImGuiHelpers.ScaledDummy(5);
@@ -395,13 +395,13 @@ internal sealed partial class CharaDataHubUi
if (!_uiSharedService.IsInGpose && _charaDataManager.BrioAvailable)
{
ImGuiHelpers.ScaledDummy(5);
UiSharedService.DrawGroupedCenteredColorText("To attach pose and world data you need to be in GPose.", ImGuiColors.DalamudYellow);
UiSharedService.DrawGroupedCenteredColorText("To attach pose and world data you need to be in GPose.", UiSharedService.AccentColor);
ImGuiHelpers.ScaledDummy(5);
}
else if (!_charaDataManager.BrioAvailable)
{
ImGuiHelpers.ScaledDummy(5);
UiSharedService.DrawGroupedCenteredColorText("To attach pose and world data Brio requires to be installed.", ImGuiColors.DalamudRed);
UiSharedService.DrawGroupedCenteredColorText("To attach pose and world data Brio requires to be installed.", UiSharedService.AccentColor);
ImGuiHelpers.ScaledDummy(5);
}
@@ -414,7 +414,7 @@ internal sealed partial class CharaDataHubUi
if (pose.Id == null)
{
ImGui.SameLine(50);
_uiSharedService.IconText(FontAwesomeIcon.Plus, ImGuiColors.DalamudYellow);
_uiSharedService.IconText(FontAwesomeIcon.Plus, UiSharedService.AccentColor);
UiSharedService.AttachToolTip("This pose has not been added to the server yet. Save changes to upload this Pose data.");
}
@@ -422,14 +422,14 @@ internal sealed partial class CharaDataHubUi
if (poseHasChanges)
{
ImGui.SameLine(50);
_uiSharedService.IconText(FontAwesomeIcon.ExclamationTriangle, ImGuiColors.DalamudYellow);
_uiSharedService.IconText(FontAwesomeIcon.ExclamationTriangle, UiSharedService.AccentColor);
UiSharedService.AttachToolTip("This pose has changes that have not been saved to the server yet.");
}
ImGui.SameLine(75);
if (pose.Description == null && pose.WorldData == null && pose.PoseData == null)
{
UiSharedService.ColorText("Pose scheduled for deletion", ImGuiColors.DalamudYellow);
UiSharedService.ColorText("Pose scheduled for deletion", UiSharedService.AccentColor);
}
else
{
@@ -544,7 +544,8 @@ internal sealed partial class CharaDataHubUi
{
if (_uiSharedService.IconTextButton(FontAwesomeIcon.ArrowCircleDown, "Download your Online Character Data from Server"))
{
_ = _charaDataManager.GetAllData(_disposalCts.Token);
var cts = EnsureFreshCts(ref _disposalCts);
_ = _charaDataManager.GetAllData(cts.Token);
}
}
if (_charaDataManager.DataGetTimeoutTask != null && !_charaDataManager.DataGetTimeoutTask.IsCompleted)
@@ -585,7 +586,7 @@ internal sealed partial class CharaDataHubUi
var idText = entry.FullId;
if (uDto?.HasChanges ?? false)
{
UiSharedService.ColorText(idText, ImGuiColors.DalamudYellow);
UiSharedService.ColorText(idText, UiSharedService.AccentColor);
UiSharedService.AttachToolTip("This entry has unsaved changes");
}
else
@@ -640,7 +641,7 @@ internal sealed partial class CharaDataHubUi
FontAwesomeIcon eIcon = FontAwesomeIcon.None;
if (!Equals(DateTime.MaxValue, entry.ExpiryDate))
eIcon = FontAwesomeIcon.Clock;
_uiSharedService.IconText(eIcon, ImGuiColors.DalamudYellow);
_uiSharedService.IconText(eIcon, UiSharedService.AccentColor);
if (ImGui.IsItemClicked()) SelectedDtoId = entry.Id;
if (eIcon != FontAwesomeIcon.None)
{
@@ -654,7 +655,8 @@ internal sealed partial class CharaDataHubUi
{
if (_uiSharedService.IconTextButton(FontAwesomeIcon.Plus, "New Character Data Entry"))
{
_charaDataManager.CreateCharaDataEntry(_closalCts.Token);
var cts = EnsureFreshCts(ref _closalCts);
_charaDataManager.CreateCharaDataEntry(cts.Token);
_selectNewEntry = true;
}
}
@@ -675,17 +677,17 @@ internal sealed partial class CharaDataHubUi
if (_charaDataManager.OwnCharaData.Count == _charaDataManager.MaxCreatableCharaData)
{
ImGui.AlignTextToFramePadding();
UiSharedService.ColorTextWrapped("You have reached the maximum Character Data entries and cannot create more.", ImGuiColors.DalamudYellow);
UiSharedService.ColorTextWrapped("You have reached the maximum Character Data entries and cannot create more.", UiSharedService.AccentColor);
}
}
if (_charaDataManager.DataCreationTask != null && !_charaDataManager.DataCreationTask.IsCompleted)
{
UiSharedService.ColorTextWrapped("Creating new character data entry on server...", ImGuiColors.DalamudYellow);
UiSharedService.ColorTextWrapped("Creating new character data entry on server...", UiSharedService.AccentColor);
}
else if (_charaDataManager.DataCreationTask != null && _charaDataManager.DataCreationTask.IsCompleted)
{
var color = _charaDataManager.DataCreationTask.Result.Success ? ImGuiColors.HealerGreen : ImGuiColors.DalamudRed;
var color = _charaDataManager.DataCreationTask.Result.Success ? ImGuiColors.HealerGreen : UiSharedService.AccentColor;
UiSharedService.ColorTextWrapped(_charaDataManager.DataCreationTask.Result.Output, color);
}
@@ -848,4 +850,4 @@ internal sealed partial class CharaDataHubUi
}
}
}
}
}

View File

@@ -7,7 +7,7 @@ using System.Numerics;
namespace MareSynchronos.UI;
internal partial class CharaDataHubUi
public sealed partial class CharaDataHubUi
{
private void DrawNearbyPoses()
{
@@ -57,6 +57,14 @@ internal partial class CharaDataHubUi
_configService.Save();
}
_uiSharedService.DrawHelpText("Draw floating wisps where other's poses are in the world.");
int maxWisps = _configService.Current.NearbyMaxWisps;
ImGui.SetNextItemWidth(140);
if (ImGui.SliderInt("Maximum wisps", ref maxWisps, 0, 200))
{
_configService.Current.NearbyMaxWisps = maxWisps;
_configService.Save();
}
_uiSharedService.DrawHelpText("Limit how many wisps are active at once. Set to 0 to disable wisps even when enabled above.");
int poseDetectionDistance = _configService.Current.NearbyDistanceFilter;
ImGui.SetNextItemWidth(100);
if (ImGui.SliderInt("Detection Distance", ref poseDetectionDistance, 5, 1000))
@@ -78,7 +86,7 @@ internal partial class CharaDataHubUi
if (!_uiSharedService.IsInGpose)
{
ImGuiHelpers.ScaledDummy(5);
UiSharedService.DrawGroupedCenteredColorText("Spawning and applying pose data is only available in GPose.", ImGuiColors.DalamudYellow);
UiSharedService.DrawGroupedCenteredColorText("Spawning and applying pose data is only available in GPose.", UiSharedService.AccentColor);
ImGuiHelpers.ScaledDummy(5);
}
@@ -93,7 +101,7 @@ internal partial class CharaDataHubUi
using var indent = ImRaii.PushIndent(5f);
if (_charaDataNearbyManager.NearbyData.Count == 0)
{
UiSharedService.DrawGroupedCenteredColorText("No Shared World Poses found nearby.", ImGuiColors.DalamudYellow);
UiSharedService.DrawGroupedCenteredColorText("No Shared World Poses found nearby.", UiSharedService.AccentColor);
}
bool wasAnythingHovered = false;
@@ -196,7 +204,8 @@ internal partial class CharaDataHubUi
{
if (_uiSharedService.IconTextButton(FontAwesomeIcon.ArrowCircleDown, "Update Data Shared With You"))
{
_ = _charaDataManager.GetAllSharedData(_disposalCts.Token).ContinueWith(u => UpdateFilteredItems());
var cts = EnsureFreshCts(ref _disposalCts);
_ = _charaDataManager.GetAllSharedData(cts.Token).ContinueWith(u => UpdateFilteredItems());
}
}
if (_charaDataManager.GetSharedWithYouTimeoutTask != null && !_charaDataManager.GetSharedWithYouTimeoutTask.IsCompleted)

View File

@@ -1,4 +1,6 @@
using Dalamud.Bindings.ImGui;
using System;
using System.Collections.Generic;
using Dalamud.Bindings.ImGui;
using Dalamud.Interface;
using Dalamud.Interface.Colors;
using Dalamud.Interface.ImGuiFileDialog;
@@ -15,10 +17,13 @@ using MareSynchronos.Services.Mediator;
using MareSynchronos.Services.ServerConfiguration;
using MareSynchronos.Utils;
using Microsoft.Extensions.Logging;
using System.IO;
using System.Linq;
using System.Threading;
namespace MareSynchronos.UI;
internal sealed partial class CharaDataHubUi : WindowMediatorSubscriberBase
public sealed partial class CharaDataHubUi : WindowMediatorSubscriberBase
{
private const int maxPoses = 10;
private readonly CharaDataManager _charaDataManager;
@@ -30,9 +35,10 @@ internal sealed partial class CharaDataHubUi : WindowMediatorSubscriberBase
private readonly CharaDataGposeTogetherManager _charaDataGposeTogetherManager;
private readonly ServerConfigurationManager _serverConfigurationManager;
private readonly UiSharedService _uiSharedService;
private CancellationTokenSource _closalCts = new();
private readonly McdfShareManager _mcdfShareManager;
private CancellationTokenSource? _closalCts = new();
private bool _disableUI = false;
private CancellationTokenSource _disposalCts = new();
private CancellationTokenSource? _disposalCts = new();
private string _exportDescription = string.Empty;
private string _filterCodeNote = string.Empty;
private string _filterDescription = string.Empty;
@@ -62,6 +68,15 @@ internal sealed partial class CharaDataHubUi : WindowMediatorSubscriberBase
}
}
private static string SanitizeFileName(string? candidate, string fallback)
{
var invalidChars = Path.GetInvalidFileNameChars();
if (string.IsNullOrWhiteSpace(candidate)) return fallback;
var sanitized = new string(candidate.Select(ch => invalidChars.Contains(ch) ? '_' : ch).ToArray()).Trim('_');
return string.IsNullOrWhiteSpace(sanitized) ? fallback : sanitized;
}
private string _selectedSpecificUserIndividual = string.Empty;
private string _selectedSpecificGroupIndividual = string.Empty;
private string _sharedWithYouDescriptionFilter = string.Empty;
@@ -73,12 +88,21 @@ internal sealed partial class CharaDataHubUi : WindowMediatorSubscriberBase
private string? _openComboHybridId = null;
private (string Id, string? Alias, string AliasOrId, string? Note)[]? _openComboHybridEntries = null;
private bool _comboHybridUsedLastFrame = false;
private bool _mcdfShareInitialized;
private string _mcdfShareDescription = string.Empty;
private readonly List<string> _mcdfShareAllowedIndividuals = new();
private readonly List<string> _mcdfShareAllowedSyncshells = new();
private string _mcdfShareIndividualDropdownSelection = string.Empty;
private string _mcdfShareIndividualInput = string.Empty;
private string _mcdfShareSyncshellDropdownSelection = string.Empty;
private string _mcdfShareSyncshellInput = string.Empty;
private int _mcdfShareExpireDays;
public CharaDataHubUi(ILogger<CharaDataHubUi> logger, MareMediator mediator, PerformanceCollectorService performanceCollectorService,
CharaDataManager charaDataManager, CharaDataNearbyManager charaDataNearbyManager, CharaDataConfigService configService,
UiSharedService uiSharedService, ServerConfigurationManager serverConfigurationManager,
DalamudUtilService dalamudUtilService, FileDialogManager fileDialogManager, PairManager pairManager,
CharaDataGposeTogetherManager charaDataGposeTogetherManager)
CharaDataGposeTogetherManager charaDataGposeTogetherManager, McdfShareManager mcdfShareManager)
: base(logger, mediator, "Umbra Character Data Hub###UmbraCharaDataUI", performanceCollectorService)
{
SetWindowSizeConstraints();
@@ -92,6 +116,7 @@ internal sealed partial class CharaDataHubUi : WindowMediatorSubscriberBase
_fileDialogManager = fileDialogManager;
_pairManager = pairManager;
_charaDataGposeTogetherManager = charaDataGposeTogetherManager;
_mcdfShareManager = mcdfShareManager;
Mediator.Subscribe<GposeStartMessage>(this, (_) => IsOpen |= _configService.Current.OpenMareHubOnGposeStart);
Mediator.Subscribe<OpenCharaDataHubWithFilterMessage>(this, (msg) =>
{
@@ -123,7 +148,14 @@ internal sealed partial class CharaDataHubUi : WindowMediatorSubscriberBase
return;
}
_closalCts.Cancel();
try
{
_closalCts?.Cancel();
}
catch (ObjectDisposedException)
{
}
EnsureFreshCts(ref _closalCts);
SelectedDtoId = string.Empty;
_filteredDict = null;
_sharedWithYouOwnerFilter = string.Empty;
@@ -135,21 +167,34 @@ internal sealed partial class CharaDataHubUi : WindowMediatorSubscriberBase
public override void OnOpen()
{
_closalCts = _closalCts.CancelRecreate();
EnsureFreshCts(ref _closalCts);
}
protected override void Dispose(bool disposing)
{
if (disposing)
{
_closalCts.CancelDispose();
_disposalCts.CancelDispose();
CancelAndDispose(ref _closalCts);
CancelAndDispose(ref _disposalCts);
}
base.Dispose(disposing);
}
protected override void DrawInternal()
{
DrawHubContent();
}
public void DrawInline()
{
using (ImRaii.PushId("CharaDataHubInline"))
{
DrawHubContent();
}
}
private void DrawHubContent()
{
if (!_comboHybridUsedLastFrame)
{
@@ -170,7 +215,7 @@ internal sealed partial class CharaDataHubUi : WindowMediatorSubscriberBase
if (!_charaDataManager.BrioAvailable)
{
ImGuiHelpers.ScaledDummy(3);
UiSharedService.DrawGroupedCenteredColorText("To utilize any features related to posing or spawning characters you require to have Brio installed.", ImGuiColors.DalamudRed);
UiSharedService.DrawGroupedCenteredColorText("To utilize any features related to posing or spawning characters you require to have Brio installed.", UiSharedService.AccentColor);
UiSharedService.DistanceSeparator();
}
@@ -190,125 +235,150 @@ internal sealed partial class CharaDataHubUi : WindowMediatorSubscriberBase
}
if (!string.IsNullOrEmpty(_charaDataManager.DataApplicationProgress))
{
UiSharedService.ColorTextWrapped(_charaDataManager.DataApplicationProgress, ImGuiColors.DalamudYellow);
UiSharedService.ColorTextWrapped(_charaDataManager.DataApplicationProgress, UiSharedService.AccentColor);
}
if (_charaDataManager.DataApplicationTask != null)
{
UiSharedService.ColorTextWrapped("WARNING: During the data application avoid interacting with this actor to prevent potential crashes.", ImGuiColors.DalamudRed);
UiSharedService.ColorTextWrapped("WARNING: During the data application avoid interacting with this actor to prevent potential crashes.", UiSharedService.AccentColor);
ImGuiHelpers.ScaledDummy(5);
ImGui.Separator();
}
});
using var tabs = ImRaii.TabBar("TabsTopLevel");
bool smallUi = false;
_isHandlingSelf = _charaDataManager.HandledCharaData.Any(c => c.Value.IsSelf);
if (_isHandlingSelf) _openMcdOnlineOnNextRun = false;
using (var gposeTogetherTabItem = ImRaii.TabItem("GPose Together"))
using (var topTabColor = ImRaii.PushColor(ImGuiCol.Tab, UiSharedService.AccentColor))
using (var topTabHoverColor = ImRaii.PushColor(ImGuiCol.TabHovered, UiSharedService.AccentHoverColor))
using (var topTabActiveColor = ImRaii.PushColor(ImGuiCol.TabActive, UiSharedService.AccentActiveColor))
{
if (gposeTogetherTabItem)
using var tabs = ImRaii.TabBar("TabsTopLevel");
_isHandlingSelf = _charaDataManager.HandledCharaData.Any(c => c.Value.IsSelf);
if (_isHandlingSelf) _openMcdOnlineOnNextRun = false;
using (var gposeTogetherTabItem = ImRaii.TabItem("GPose Together"))
{
smallUi = true;
DrawGposeTogether();
}
}
using (var applicationTabItem = ImRaii.TabItem("Data Application", _openDataApplicationShared ? ImGuiTabItemFlags.SetSelected : ImGuiTabItemFlags.None))
{
if (applicationTabItem)
{
smallUi = true;
using var appTabs = ImRaii.TabBar("TabsApplicationLevel");
using (ImRaii.Disabled(!_uiSharedService.IsInGpose))
if (gposeTogetherTabItem)
{
using (var gposeTabItem = ImRaii.TabItem("GPose Actors"))
smallUi = true;
DrawGposeTogether();
}
}
using (var applicationTabItem = ImRaii.TabItem("Data Application", _openDataApplicationShared ? ImGuiTabItemFlags.SetSelected : ImGuiTabItemFlags.None))
{
if (applicationTabItem)
{
smallUi = true;
using (var appTabColor = ImRaii.PushColor(ImGuiCol.Tab, UiSharedService.AccentColor))
using (var appTabHoverColor = ImRaii.PushColor(ImGuiCol.TabHovered, UiSharedService.AccentHoverColor))
using (var appTabActiveColor = ImRaii.PushColor(ImGuiCol.TabActive, UiSharedService.AccentActiveColor))
{
if (gposeTabItem)
using var appTabs = ImRaii.TabBar("TabsApplicationLevel");
using (ImRaii.Disabled(!_uiSharedService.IsInGpose))
{
using var id = ImRaii.PushId("gposeControls");
DrawGposeControls();
using (var gposeTabItem = ImRaii.TabItem("GPose Actors"))
{
if (gposeTabItem)
{
using var id = ImRaii.PushId("gposeControls");
DrawGposeControls();
}
}
}
if (!_uiSharedService.IsInGpose)
UiSharedService.AttachToolTip("Only available in GPose");
using (var nearbyPosesTabItem = ImRaii.TabItem("Poses Nearby"))
{
if (nearbyPosesTabItem)
{
using var id = ImRaii.PushId("nearbyPoseControls");
_charaDataNearbyManager.ComputeNearbyData = true;
DrawNearbyPoses();
}
else
{
_charaDataNearbyManager.ComputeNearbyData = false;
}
}
using (var gposeTabItem = ImRaii.TabItem("Apply Data", _openDataApplicationShared ? ImGuiTabItemFlags.SetSelected : ImGuiTabItemFlags.None))
{
if (gposeTabItem)
{
smallUi |= true;
using var id = ImRaii.PushId("applyData");
DrawDataApplication();
}
}
}
}
if (!_uiSharedService.IsInGpose)
UiSharedService.AttachToolTip("Only available in GPose");
using (var nearbyPosesTabItem = ImRaii.TabItem("Poses Nearby"))
else
{
if (nearbyPosesTabItem)
{
using var id = ImRaii.PushId("nearbyPoseControls");
_charaDataNearbyManager.ComputeNearbyData = true;
DrawNearbyPoses();
}
else
{
_charaDataNearbyManager.ComputeNearbyData = false;
}
}
using (var gposeTabItem = ImRaii.TabItem("Apply Data", _openDataApplicationShared ? ImGuiTabItemFlags.SetSelected : ImGuiTabItemFlags.None))
{
if (gposeTabItem)
{
smallUi |= true;
using var id = ImRaii.PushId("applyData");
DrawDataApplication();
}
_charaDataNearbyManager.ComputeNearbyData = false;
}
}
else
{
_charaDataNearbyManager.ComputeNearbyData = false;
}
}
using (ImRaii.Disabled(_isHandlingSelf))
{
ImGuiTabItemFlags flagsTopLevel = ImGuiTabItemFlags.None;
if (_openMcdOnlineOnNextRun)
using (ImRaii.Disabled(_isHandlingSelf))
{
flagsTopLevel = ImGuiTabItemFlags.SetSelected;
_openMcdOnlineOnNextRun = false;
}
using (var creationTabItem = ImRaii.TabItem("Data Creation", flagsTopLevel))
{
if (creationTabItem)
ImGuiTabItemFlags flagsTopLevel = ImGuiTabItemFlags.None;
if (_openMcdOnlineOnNextRun)
{
using var creationTabs = ImRaii.TabBar("TabsCreationLevel");
flagsTopLevel = ImGuiTabItemFlags.SetSelected;
_openMcdOnlineOnNextRun = false;
}
ImGuiTabItemFlags flags = ImGuiTabItemFlags.None;
if (_openMcdOnlineOnNextRun)
using (var creationTabItem = ImRaii.TabItem("Data Creation", flagsTopLevel))
{
if (creationTabItem)
{
flags = ImGuiTabItemFlags.SetSelected;
_openMcdOnlineOnNextRun = false;
}
using (var mcdOnlineTabItem = ImRaii.TabItem("Online Data", flags))
{
if (mcdOnlineTabItem)
using (var creationTabColor = ImRaii.PushColor(ImGuiCol.Tab, UiSharedService.AccentColor))
using (var creationTabHoverColor = ImRaii.PushColor(ImGuiCol.TabHovered, UiSharedService.AccentHoverColor))
using (var creationTabActiveColor = ImRaii.PushColor(ImGuiCol.TabActive, UiSharedService.AccentActiveColor))
{
using var id = ImRaii.PushId("mcdOnline");
DrawMcdOnline();
}
}
using var creationTabs = ImRaii.TabBar("TabsCreationLevel");
using (var mcdfTabItem = ImRaii.TabItem("MCDF Export"))
{
if (mcdfTabItem)
{
using var id = ImRaii.PushId("mcdfExport");
DrawMcdfExport();
ImGuiTabItemFlags flags = ImGuiTabItemFlags.None;
if (_openMcdOnlineOnNextRun)
{
flags = ImGuiTabItemFlags.SetSelected;
_openMcdOnlineOnNextRun = false;
}
using (var mcdOnlineTabItem = ImRaii.TabItem("Online Data", flags))
{
if (mcdOnlineTabItem)
{
using var id = ImRaii.PushId("mcdOnline");
DrawMcdOnline();
}
}
using (var mcdfTabItem = ImRaii.TabItem("MCDF Export"))
{
if (mcdfTabItem)
{
using var id = ImRaii.PushId("mcdfExport");
DrawMcdfExport();
}
}
using (var mcdfShareTabItem = ImRaii.TabItem("Partage MCDF"))
{
if (mcdfShareTabItem)
{
using var id = ImRaii.PushId("mcdfShare");
DrawMcdfShare();
}
}
}
}
}
}
}
if (_isHandlingSelf)
{
UiSharedService.AttachToolTip("Cannot use creation tools while having Character Data applied to self.");
@@ -436,14 +506,18 @@ internal sealed partial class CharaDataHubUi : WindowMediatorSubscriberBase
if (!_hasValidGposeTarget)
{
ImGuiHelpers.ScaledDummy(3);
UiSharedService.DrawGroupedCenteredColorText("Applying data is only available in GPose with a valid selected GPose target.", ImGuiColors.DalamudYellow, 350);
UiSharedService.DrawGroupedCenteredColorText("Applying data is only available in GPose with a valid selected GPose target.", UiSharedService.AccentColor, 350);
}
ImGuiHelpers.ScaledDummy(10);
using var tabs = ImRaii.TabBar("Tabs");
using (var applyTabColor = ImRaii.PushColor(ImGuiCol.Tab, UiSharedService.AccentColor))
using (var applyTabHoverColor = ImRaii.PushColor(ImGuiCol.TabHovered, UiSharedService.AccentHoverColor))
using (var applyTabActiveColor = ImRaii.PushColor(ImGuiCol.TabActive, UiSharedService.AccentActiveColor))
{
using var tabs = ImRaii.TabBar("Tabs");
using (var byFavoriteTabItem = ImRaii.TabItem("Favorites"))
using (var byFavoriteTabItem = ImRaii.TabItem("Favorites"))
{
if (byFavoriteTabItem)
{
@@ -595,7 +669,7 @@ internal sealed partial class CharaDataHubUi : WindowMediatorSubscriberBase
if (_configService.Current.FavoriteCodes.Count == 0)
{
UiSharedService.ColorTextWrapped("You have no favorites added. Add Favorites through the other tabs before you can use this tab.", ImGuiColors.DalamudYellow);
UiSharedService.ColorTextWrapped("You have no favorites added. Add Favorites through the other tabs before you can use this tab.", UiSharedService.AccentColor);
}
}
}
@@ -644,11 +718,11 @@ internal sealed partial class CharaDataHubUi : WindowMediatorSubscriberBase
ImGui.NewLine();
if (!_charaDataManager.DownloadMetaInfoTask?.IsCompleted ?? false)
{
UiSharedService.ColorTextWrapped("Downloading meta info. Please wait.", ImGuiColors.DalamudYellow);
UiSharedService.ColorTextWrapped("Downloading meta info. Please wait.", UiSharedService.AccentColor);
}
if ((_charaDataManager.DownloadMetaInfoTask?.IsCompleted ?? false) && !_charaDataManager.DownloadMetaInfoTask.Result.Success)
{
UiSharedService.ColorTextWrapped(_charaDataManager.DownloadMetaInfoTask.Result.Result, ImGuiColors.DalamudRed);
UiSharedService.ColorTextWrapped(_charaDataManager.DownloadMetaInfoTask.Result.Result, UiSharedService.AccentColor);
}
using (ImRaii.Disabled(_charaDataManager.LastDownloadedMetaInfo == null))
@@ -689,7 +763,8 @@ internal sealed partial class CharaDataHubUi : WindowMediatorSubscriberBase
{
if (_uiSharedService.IconTextButton(FontAwesomeIcon.ArrowCircleDown, "Download your Character Data"))
{
_ = _charaDataManager.GetAllData(_disposalCts.Token);
var cts = EnsureFreshCts(ref _disposalCts);
_ = _charaDataManager.GetAllData(cts.Token);
}
}
if (_charaDataManager.DataGetTimeoutTask != null && !_charaDataManager.DataGetTimeoutTask.IsCompleted)
@@ -848,17 +923,18 @@ internal sealed partial class CharaDataHubUi : WindowMediatorSubscriberBase
if ((_charaDataManager.LoadedMcdfHeader?.IsFaulted ?? false) || (_charaDataManager.McdfApplicationTask?.IsFaulted ?? false))
{
UiSharedService.ColorTextWrapped("Failure to read MCDF file. MCDF file is possibly corrupt. Re-export the MCDF file and try again.",
ImGuiColors.DalamudRed);
UiSharedService.AccentColor);
UiSharedService.ColorTextWrapped("Note: if this is your MCDF, try redrawing yourself, wait and re-export the file. " +
"If you received it from someone else have them do the same.", ImGuiColors.DalamudYellow);
"If you received it from someone else have them do the same.", UiSharedService.AccentColor);
}
}
else
{
UiSharedService.ColorTextWrapped("Loading Character...", ImGuiColors.DalamudYellow);
UiSharedService.ColorTextWrapped("Loading Character...", UiSharedService.AccentColor);
}
}
}
}
}
private void DrawMcdfExport()
@@ -883,7 +959,7 @@ internal sealed partial class CharaDataHubUi : WindowMediatorSubscriberBase
{
string defaultFileName = string.IsNullOrEmpty(_exportDescription)
? "export.mcdf"
: string.Join('_', $"{_exportDescription}.mcdf".Split(Path.GetInvalidFileNameChars()));
: SanitizeFileName(_exportDescription, "export") + ".mcdf";
_uiSharedService.FileDialogManager.SaveFileDialog("Export Character to file", ".mcdf", defaultFileName, ".mcdf", (success, path) =>
{
if (!success) return;
@@ -896,12 +972,469 @@ internal sealed partial class CharaDataHubUi : WindowMediatorSubscriberBase
}, Directory.Exists(_configService.Current.LastSavedCharaDataLocation) ? _configService.Current.LastSavedCharaDataLocation : null);
}
UiSharedService.ColorTextWrapped("Note: For best results make sure you have everything you want to be shared as well as the correct character appearance" +
" equipped and redraw your character before exporting.", ImGuiColors.DalamudYellow);
" equipped and redraw your character before exporting.", UiSharedService.AccentColor);
ImGui.Unindent();
}
}
private void DrawMcdfShare()
{
if (!_mcdfShareInitialized && !_mcdfShareManager.IsBusy)
{
_mcdfShareInitialized = true;
_ = _mcdfShareManager.RefreshAsync(CancellationToken.None);
}
if (_mcdfShareManager.IsBusy)
{
UiSharedService.ColorTextWrapped("Traitement en cours...", ImGuiColors.DalamudYellow);
}
if (!string.IsNullOrEmpty(_mcdfShareManager.LastError))
{
UiSharedService.ColorTextWrapped(_mcdfShareManager.LastError!, ImGuiColors.DalamudRed);
}
else if (!string.IsNullOrEmpty(_mcdfShareManager.LastSuccess))
{
UiSharedService.ColorTextWrapped(_mcdfShareManager.LastSuccess!, ImGuiColors.HealerGreen);
}
if (ImGui.Button("Actualiser les partages"))
{
_ = _mcdfShareManager.RefreshAsync(CancellationToken.None);
}
ImGui.Separator();
_uiSharedService.BigText("Créer un partage MCDF");
ImGui.InputTextWithHint("##mcdfShareDescription", "Description", ref _mcdfShareDescription, 128);
ImGui.InputInt("Expiration (jours, 0 = jamais)", ref _mcdfShareExpireDays);
DrawMcdfShareIndividualDropdown();
ImGui.SameLine();
ImGui.SetNextItemWidth(220f);
if (ImGui.InputTextWithHint("##mcdfShareUidInput", "UID ou vanity", ref _mcdfShareIndividualInput, 32))
{
_mcdfShareIndividualDropdownSelection = string.Empty;
}
ImGui.SameLine();
var normalizedUid = NormalizeUidCandidate(_mcdfShareIndividualInput);
using (ImRaii.Disabled(string.IsNullOrEmpty(normalizedUid)
|| _mcdfShareAllowedIndividuals.Any(p => string.Equals(p, normalizedUid, StringComparison.OrdinalIgnoreCase))))
{
if (ImGui.SmallButton("Ajouter"))
{
_mcdfShareAllowedIndividuals.Add(normalizedUid);
_mcdfShareIndividualInput = string.Empty;
_mcdfShareIndividualDropdownSelection = string.Empty;
}
}
ImGui.SameLine();
ImGui.TextUnformatted("UID synchronisé à ajouter");
_uiSharedService.DrawHelpText("Choisissez un pair synchronisé dans la liste ou saisissez un UID. Les utilisateurs listés pourront récupérer ce partage MCDF.");
foreach (var uid in _mcdfShareAllowedIndividuals.ToArray())
{
using (ImRaii.PushId("mcdfShareUid" + uid))
{
ImGui.BulletText(FormatPairLabel(uid));
ImGui.SameLine();
if (ImGui.SmallButton("Retirer"))
{
_mcdfShareAllowedIndividuals.Remove(uid);
}
}
}
DrawMcdfShareSyncshellDropdown();
ImGui.SameLine();
ImGui.SetNextItemWidth(220f);
if (ImGui.InputTextWithHint("##mcdfShareSyncshellInput", "GID ou alias", ref _mcdfShareSyncshellInput, 32))
{
_mcdfShareSyncshellDropdownSelection = string.Empty;
}
ImGui.SameLine();
var normalizedSyncshell = NormalizeSyncshellCandidate(_mcdfShareSyncshellInput);
using (ImRaii.Disabled(string.IsNullOrEmpty(normalizedSyncshell)
|| _mcdfShareAllowedSyncshells.Any(p => string.Equals(p, normalizedSyncshell, StringComparison.OrdinalIgnoreCase))))
{
if (ImGui.SmallButton("Ajouter"))
{
_mcdfShareAllowedSyncshells.Add(normalizedSyncshell);
_mcdfShareSyncshellInput = string.Empty;
_mcdfShareSyncshellDropdownSelection = string.Empty;
}
}
ImGui.SameLine();
ImGui.TextUnformatted("Syncshell à ajouter");
_uiSharedService.DrawHelpText("Sélectionnez une syncshell synchronisée ou saisissez un identifiant. Les syncshells listées auront accès au partage.");
foreach (var shell in _mcdfShareAllowedSyncshells.ToArray())
{
using (ImRaii.PushId("mcdfShareShell" + shell))
{
ImGui.BulletText(FormatSyncshellLabel(shell));
ImGui.SameLine();
if (ImGui.SmallButton("Retirer"))
{
_mcdfShareAllowedSyncshells.Remove(shell);
}
}
}
using (ImRaii.Disabled(_mcdfShareManager.IsBusy))
{
if (ImGui.Button("Créer"))
{
DateTime? expiresAt = _mcdfShareExpireDays <= 0 ? null : DateTime.UtcNow.AddDays(_mcdfShareExpireDays);
_ = _mcdfShareManager.CreateShareAsync(_mcdfShareDescription, _mcdfShareAllowedIndividuals.ToList(), _mcdfShareAllowedSyncshells.ToList(), expiresAt, CancellationToken.None);
_mcdfShareDescription = string.Empty;
_mcdfShareAllowedIndividuals.Clear();
_mcdfShareAllowedSyncshells.Clear();
_mcdfShareIndividualInput = string.Empty;
_mcdfShareIndividualDropdownSelection = string.Empty;
_mcdfShareSyncshellInput = string.Empty;
_mcdfShareSyncshellDropdownSelection = string.Empty;
_mcdfShareExpireDays = 0;
}
}
ImGui.Separator();
_uiSharedService.BigText("Mes partages : ");
if (_mcdfShareManager.OwnShares.Count == 0)
{
ImGui.TextDisabled("Aucun partage MCDF créé.");
}
else if (ImGui.BeginTable("mcdf-own-shares", 6, ImGuiTableFlags.SizingStretchProp | ImGuiTableFlags.RowBg | ImGuiTableFlags.BordersOuter))
{
ImGui.TableSetupColumn("Description");
ImGui.TableSetupColumn("Créé le");
ImGui.TableSetupColumn("Expire");
ImGui.TableSetupColumn("Téléchargements");
ImGui.TableSetupColumn("Accès");
var style = ImGui.GetStyle();
float BtnWidth(string label) => ImGui.CalcTextSize(label).X + style.FramePadding.X * 2f;
float ownActionsWidth = BtnWidth("Appliquer en GPose") + style.ItemSpacing.X + BtnWidth("Enregistrer") + style.ItemSpacing.X + BtnWidth("Supprimer") + 2f; // small margin
ImGui.TableSetupColumn("Actions", ImGuiTableColumnFlags.WidthFixed, ownActionsWidth);
ImGui.TableHeadersRow();
foreach (var entry in _mcdfShareManager.OwnShares)
{
ImGui.TableNextRow();
ImGui.TableNextColumn();
ImGui.TextUnformatted(string.IsNullOrEmpty(entry.Description) ? entry.Id.ToString() : entry.Description);
ImGui.TableNextColumn();
ImGui.TextUnformatted(entry.CreatedUtc.ToLocalTime().ToString("g"));
ImGui.TableNextColumn();
ImGui.TextUnformatted(entry.ExpiresAtUtc.HasValue ? entry.ExpiresAtUtc.Value.ToLocalTime().ToString("g") : "Jamais");
ImGui.TableNextColumn();
ImGui.TextUnformatted(entry.DownloadCount.ToString());
ImGui.TableNextColumn();
ImGui.TextUnformatted($"UID : {entry.AllowedIndividuals.Count}, Syncshells : {entry.AllowedSyncshells.Count}");
if (ImGui.IsItemHovered())
{
ImGui.BeginTooltip();
if (entry.AllowedIndividuals.Count > 0)
{
ImGui.TextUnformatted("UID autorisés:");
foreach (var uid in entry.AllowedIndividuals)
ImGui.BulletText(FormatUidWithName(uid));
}
else
{
ImGui.TextDisabled("Aucun UID autorisé");
}
ImGui.Separator();
if (entry.AllowedSyncshells.Count > 0)
{
ImGui.TextUnformatted("Syncshells autorisées:");
foreach (var gid in entry.AllowedSyncshells)
ImGui.BulletText(FormatSyncshellLabel(gid));
}
else
{
ImGui.TextDisabled("Aucune syncshell autorisée");
}
ImGui.EndTooltip();
}
ImGui.TableNextColumn();
using (ImRaii.PushId("ownShare" + entry.Id))
{
if (ImGui.SmallButton("Appliquer en GPose"))
{
_ = _mcdfShareManager.ApplyShareAsync(entry.Id, CancellationToken.None);
}
ImGui.SameLine();
if (ImGui.SmallButton("Enregistrer"))
{
var baseName = SanitizeFileName(entry.Description, entry.Id.ToString());
var defaultName = baseName + ".mcdf";
_fileDialogManager.SaveFileDialog("Enregistrer le partage MCDF", ".mcdf", defaultName, ".mcdf", async (success, path) =>
{
if (!success || string.IsNullOrEmpty(path)) return;
await _mcdfShareManager.ExportShareAsync(entry.Id, path, CancellationToken.None).ConfigureAwait(false);
});
}
ImGui.SameLine();
if (ImGui.SmallButton("Supprimer"))
{
_ = _mcdfShareManager.DeleteShareAsync(entry.Id);
}
}
}
ImGui.EndTable();
}
ImGui.Separator();
_uiSharedService.BigText("Partagés avec moi : ");
if (_mcdfShareManager.SharedShares.Count == 0)
{
ImGui.TextDisabled("Aucun partage MCDF reçu.");
}
else if (ImGui.BeginTable("mcdf-shared-shares", 5, ImGuiTableFlags.SizingStretchProp | ImGuiTableFlags.RowBg | ImGuiTableFlags.BordersOuter))
{
ImGui.TableSetupColumn("Description");
ImGui.TableSetupColumn("Propriétaire");
ImGui.TableSetupColumn("Expire");
ImGui.TableSetupColumn("Téléchargements");
var style2 = ImGui.GetStyle();
float BtnWidth2(string label) => ImGui.CalcTextSize(label).X + style2.FramePadding.X * 2f;
float sharedActionsWidth = BtnWidth2("Appliquer") + style2.ItemSpacing.X + BtnWidth2("Enregistrer") + 2f;
ImGui.TableSetupColumn("Actions", ImGuiTableColumnFlags.WidthFixed, sharedActionsWidth);
ImGui.TableHeadersRow();
foreach (var entry in _mcdfShareManager.SharedShares)
{
ImGui.TableNextRow();
ImGui.TableNextColumn();
ImGui.TextUnformatted(string.IsNullOrEmpty(entry.Description) ? entry.Id.ToString() : entry.Description);
ImGui.TableNextColumn();
ImGui.TextUnformatted(string.IsNullOrEmpty(entry.OwnerAlias) ? entry.OwnerUid : entry.OwnerAlias);
if (ImGui.IsItemHovered())
{
ImGui.BeginTooltip();
ImGui.TextUnformatted($"UID propriétaire: {entry.OwnerUid}");
if (!string.IsNullOrEmpty(entry.OwnerAlias))
{
ImGui.Separator();
ImGui.TextUnformatted($"Alias: {entry.OwnerAlias}");
}
ImGui.EndTooltip();
}
ImGui.TableNextColumn();
ImGui.TextUnformatted(entry.ExpiresAtUtc.HasValue ? entry.ExpiresAtUtc.Value.ToLocalTime().ToString("g") : "Jamais");
ImGui.TableNextColumn();
ImGui.TextUnformatted(entry.DownloadCount.ToString());
ImGui.TableNextColumn();
using (ImRaii.PushId("sharedShare" + entry.Id))
{
if (ImGui.SmallButton("Appliquer"))
{
_ = _mcdfShareManager.ApplyShareAsync(entry.Id, CancellationToken.None);
}
ImGui.SameLine();
if (ImGui.SmallButton("Enregistrer"))
{
var baseName = SanitizeFileName(entry.Description, entry.Id.ToString());
var defaultName = baseName + ".mcdf";
_fileDialogManager.SaveFileDialog("Enregistrer le partage MCDF", ".mcdf", defaultName, ".mcdf", async (success, path) =>
{
if (!success || string.IsNullOrEmpty(path)) return;
await _mcdfShareManager.ExportShareAsync(entry.Id, path, CancellationToken.None).ConfigureAwait(false);
});
}
}
}
ImGui.EndTable();
}
}
private void DrawMcdfShareIndividualDropdown()
{
ImGui.SetNextItemWidth(220f);
var previewSource = string.IsNullOrEmpty(_mcdfShareIndividualDropdownSelection)
? _mcdfShareIndividualInput
: _mcdfShareIndividualDropdownSelection;
var previewLabel = string.IsNullOrEmpty(previewSource)
? "Sélectionner un pair synchronisé..."
: FormatPairLabel(previewSource);
using var combo = ImRaii.Combo("##mcdfShareUidDropdown", previewLabel, ImGuiComboFlags.None);
if (!combo)
{
return;
}
foreach (var pair in _pairManager.DirectPairs
.OrderBy(p => p.GetNoteOrName() ?? p.UserData.AliasOrUID, StringComparer.OrdinalIgnoreCase))
{
var normalized = pair.UserData.UID;
var display = FormatPairLabel(normalized);
bool selected = string.Equals(normalized, _mcdfShareIndividualDropdownSelection, StringComparison.OrdinalIgnoreCase);
if (ImGui.Selectable(display, selected))
{
_mcdfShareIndividualDropdownSelection = normalized;
_mcdfShareIndividualInput = normalized;
}
}
}
private void DrawMcdfShareSyncshellDropdown()
{
ImGui.SetNextItemWidth(220f);
var previewSource = string.IsNullOrEmpty(_mcdfShareSyncshellDropdownSelection)
? _mcdfShareSyncshellInput
: _mcdfShareSyncshellDropdownSelection;
var previewLabel = string.IsNullOrEmpty(previewSource)
? "Sélectionner une syncshell..."
: FormatSyncshellLabel(previewSource);
using var combo = ImRaii.Combo("##mcdfShareSyncshellDropdown", previewLabel, ImGuiComboFlags.None);
if (!combo)
{
return;
}
foreach (var group in _pairManager.Groups.Values
.OrderBy(g => _serverConfigurationManager.GetNoteForGid(g.GID) ?? g.GroupAliasOrGID, StringComparer.OrdinalIgnoreCase))
{
var gid = group.GID;
var display = FormatSyncshellLabel(gid);
bool selected = string.Equals(gid, _mcdfShareSyncshellDropdownSelection, StringComparison.OrdinalIgnoreCase);
if (ImGui.Selectable(display, selected))
{
_mcdfShareSyncshellDropdownSelection = gid;
_mcdfShareSyncshellInput = gid;
}
}
}
private string NormalizeUidCandidate(string candidate)
{
if (string.IsNullOrWhiteSpace(candidate))
{
return string.Empty;
}
var trimmed = candidate.Trim();
foreach (var pair in _pairManager.DirectPairs)
{
var alias = pair.UserData.Alias;
var aliasOrUid = pair.UserData.AliasOrUID;
var note = pair.GetNoteOrName();
if (string.Equals(pair.UserData.UID, trimmed, StringComparison.OrdinalIgnoreCase)
|| (!string.IsNullOrEmpty(alias) && string.Equals(alias, trimmed, StringComparison.OrdinalIgnoreCase))
|| string.Equals(aliasOrUid, trimmed, StringComparison.OrdinalIgnoreCase)
|| (!string.IsNullOrEmpty(note) && string.Equals(note, trimmed, StringComparison.OrdinalIgnoreCase)))
{
return pair.UserData.UID;
}
}
return trimmed.ToUpperInvariant();
}
private string NormalizeSyncshellCandidate(string candidate)
{
if (string.IsNullOrWhiteSpace(candidate))
{
return string.Empty;
}
var trimmed = candidate.Trim();
foreach (var group in _pairManager.Groups.Values)
{
var alias = group.GroupAlias;
var aliasOrGid = group.GroupAliasOrGID;
var note = _serverConfigurationManager.GetNoteForGid(group.GID);
if (string.Equals(group.GID, trimmed, StringComparison.OrdinalIgnoreCase)
|| (!string.IsNullOrEmpty(alias) && string.Equals(alias, trimmed, StringComparison.OrdinalIgnoreCase))
|| string.Equals(aliasOrGid, trimmed, StringComparison.OrdinalIgnoreCase)
|| (!string.IsNullOrEmpty(note) && string.Equals(note, trimmed, StringComparison.OrdinalIgnoreCase)))
{
return group.GID;
}
}
return trimmed.ToUpperInvariant();
}
private string FormatUidWithName(string uid)
{
if (string.IsNullOrEmpty(uid)) return string.Empty;
var note = _serverConfigurationManager.GetNoteForUid(uid);
if (!string.IsNullOrEmpty(note)) return $"{uid} ({note})";
return uid;
}
private string FormatPairLabel(string candidate)
{
if (string.IsNullOrEmpty(candidate))
{
return string.Empty;
}
foreach (var pair in _pairManager.DirectPairs)
{
var alias = pair.UserData.Alias;
var aliasOrUid = pair.UserData.AliasOrUID;
var note = pair.GetNoteOrName();
if (string.Equals(pair.UserData.UID, candidate, StringComparison.OrdinalIgnoreCase)
|| (!string.IsNullOrEmpty(alias) && string.Equals(alias, candidate, StringComparison.OrdinalIgnoreCase))
|| string.Equals(aliasOrUid, candidate, StringComparison.OrdinalIgnoreCase)
|| (!string.IsNullOrEmpty(note) && string.Equals(note, candidate, StringComparison.OrdinalIgnoreCase)))
{
return string.IsNullOrEmpty(note) ? aliasOrUid : $"{note} ({aliasOrUid})";
}
}
return candidate;
}
private string FormatSyncshellLabel(string candidate)
{
if (string.IsNullOrEmpty(candidate))
{
return string.Empty;
}
foreach (var group in _pairManager.Groups.Values)
{
var alias = group.GroupAlias;
var aliasOrGid = group.GroupAliasOrGID;
var note = _serverConfigurationManager.GetNoteForGid(group.GID);
if (string.Equals(group.GID, candidate, StringComparison.OrdinalIgnoreCase)
|| (!string.IsNullOrEmpty(alias) && string.Equals(alias, candidate, StringComparison.OrdinalIgnoreCase))
|| string.Equals(aliasOrGid, candidate, StringComparison.OrdinalIgnoreCase)
|| (!string.IsNullOrEmpty(note) && string.Equals(note, candidate, StringComparison.OrdinalIgnoreCase)))
{
return string.IsNullOrEmpty(note) ? aliasOrGid : $"{note} ({aliasOrGid})";
}
}
return candidate;
}
private void DrawMetaInfoData(string selectedGposeActor, bool hasValidGposeTarget, CharaDataMetaInfoExtendedDto data, bool canOpen = false)
{
ImGuiHelpers.ScaledDummy(5);
@@ -1104,4 +1637,26 @@ internal sealed partial class CharaDataHubUi : WindowMediatorSubscriberBase
drawAction();
if (_disableUI) ImGui.BeginDisabled();
}
}
private static CancellationTokenSource EnsureFreshCts(ref CancellationTokenSource? cts)
{
CancelAndDispose(ref cts);
cts = new CancellationTokenSource();
return cts;
}
private static void CancelAndDispose(ref CancellationTokenSource? cts)
{
if (cts == null) return;
try
{
cts.Cancel();
}
catch (ObjectDisposedException)
{
}
cts.Dispose();
cts = null;
}
}

Some files were not shown because too many files have changed in this diff Show More