Compare commits

13 Commits

34 changed files with 2028 additions and 168 deletions

BIN
.DS_Store vendored

Binary file not shown.

3
.gitignore vendored
View File

@@ -10,6 +10,9 @@
*.userosscache *.userosscache
*.sln.docstates *.sln.docstates
.DS_Store .DS_Store
MareSynchronos/.DS_Store
*.zip
UmbraServer_extracted/
# User-specific files (MonoDevelop/Xamarin Studio) # User-specific files (MonoDevelop/Xamarin Studio)
*.userprefs *.userprefs

2
.gitmodules vendored
View File

@@ -1,6 +1,6 @@
[submodule "MareAPI"] [submodule "MareAPI"]
path = MareAPI path = MareAPI
url = https://git.umbra-sync.net/SirConstance/UmbraAPI.git url = ssh://git@git.umbra-sync.net:1222/Keda/UmbraAPI.git
branch = main branch = main
[submodule "Penumbra.Api"] [submodule "Penumbra.Api"]
path = Penumbra.Api path = Penumbra.Api

Submodule MareAPI updated: ff262bf690...fa9b7bce43

Binary file not shown.

View File

@@ -1,15 +1,72 @@
using MareSynchronos.WebAPI; using MareSynchronos.WebAPI;
using System.Text.Json;
using Microsoft.Extensions.Hosting; using Microsoft.Extensions.Hosting;
using Microsoft.Extensions.Logging; using Microsoft.Extensions.Logging;
namespace MareSynchronos.MareConfiguration; 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 ILogger<ConfigurationMigrator> _logger = logger;
private readonly MareConfigService _mareConfig = mareConfig;
public void Migrate() 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) public Task StartAsync(CancellationToken cancellationToken)

View File

@@ -59,6 +59,10 @@ public class MareConfig : IMareConfiguration
public bool ShowUploading { get; set; } = true; public bool ShowUploading { get; set; } = true;
public bool ShowUploadingBigText { get; set; } = true; public bool ShowUploadingBigText { get; set; } = true;
public bool ShowVisibleUsersSeparately { get; set; } = true; public bool ShowVisibleUsersSeparately { get; set; } = true;
public bool EnableAutoDetectDiscovery { get; set; } = false;
public bool AllowAutoDetectPairRequests { get; set; } = false;
public int AutoDetectMaxDistanceMeters { get; set; } = 40;
public int AutoDetectMuteMinutes { get; set; } = 5;
public int TimeSpanBetweenScansInSeconds { get; set; } = 30; public int TimeSpanBetweenScansInSeconds { get; set; } = 30;
public int TransferBarsHeight { get; set; } = 12; public int TransferBarsHeight { get; set; } = 12;
public bool TransferBarsShowText { get; set; } = true; public bool TransferBarsShowText { get; set; } = true;

View File

@@ -3,7 +3,7 @@
<PropertyGroup> <PropertyGroup>
<AssemblyName>UmbraSync</AssemblyName> <AssemblyName>UmbraSync</AssemblyName>
<RootNamespace>UmbraSync</RootNamespace> <RootNamespace>UmbraSync</RootNamespace>
<Version>0.1.1.0</Version> <Version>0.1.8.0</Version>
</PropertyGroup> </PropertyGroup>
<ItemGroup> <ItemGroup>

View File

@@ -210,9 +210,16 @@ public sealed class PairManager : DisposableMediatorSubscriberBase
public void SetGroupInfo(GroupInfoDto dto) public void SetGroupInfo(GroupInfoDto dto)
{ {
_allGroups[dto.Group].Group = dto.Group; if (!_allGroups.TryGetValue(dto.Group, out var groupInfo))
_allGroups[dto.Group].Owner = dto.Owner; {
_allGroups[dto.Group].GroupPermissions = dto.GroupPermissions; return;
}
groupInfo.Group = dto.Group;
groupInfo.Owner = dto.Owner;
groupInfo.GroupPermissions = dto.GroupPermissions;
groupInfo.IsTemporary = dto.IsTemporary;
groupInfo.ExpiresAt = dto.ExpiresAt;
RecreateLazy(); RecreateLazy();
} }

View File

@@ -96,6 +96,11 @@ public sealed class Plugin : IDalamudPlugin
collection.AddSingleton<HubFactory>(); collection.AddSingleton<HubFactory>();
collection.AddSingleton<FileUploadManager>(); collection.AddSingleton<FileUploadManager>();
collection.AddSingleton<FileTransferOrchestrator>(); 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<MarePlugin>(); collection.AddSingleton<MarePlugin>();
collection.AddSingleton<MareProfileManager>(); collection.AddSingleton<MareProfileManager>();
collection.AddSingleton<GameObjectHandlerFactory>(); collection.AddSingleton<GameObjectHandlerFactory>();
@@ -140,6 +145,7 @@ public sealed class Plugin : IDalamudPlugin
collection.AddSingleton<IpcCallerMare>(); collection.AddSingleton<IpcCallerMare>();
collection.AddSingleton<IpcManager>(); collection.AddSingleton<IpcManager>();
collection.AddSingleton<NotificationService>(); collection.AddSingleton<NotificationService>();
collection.AddSingleton<TemporarySyncshellNotificationService>();
collection.AddSingleton((s) => new MareConfigService(pluginInterface.ConfigDirectory.FullName)); collection.AddSingleton((s) => new MareConfigService(pluginInterface.ConfigDirectory.FullName));
collection.AddSingleton((s) => new ServerConfigService(pluginInterface.ConfigDirectory.FullName)); collection.AddSingleton((s) => new ServerConfigService(pluginInterface.ConfigDirectory.FullName));
@@ -175,6 +181,7 @@ public sealed class Plugin : IDalamudPlugin
collection.AddScoped<WindowMediatorSubscriberBase, CompactUi>(); collection.AddScoped<WindowMediatorSubscriberBase, CompactUi>();
collection.AddScoped<WindowMediatorSubscriberBase, IntroUi>(); collection.AddScoped<WindowMediatorSubscriberBase, IntroUi>();
collection.AddScoped<WindowMediatorSubscriberBase, DownloadUi>(); collection.AddScoped<WindowMediatorSubscriberBase, DownloadUi>();
collection.AddScoped<WindowMediatorSubscriberBase, AutoDetectUi>();
collection.AddScoped<WindowMediatorSubscriberBase, PopoutProfileUi>(); collection.AddScoped<WindowMediatorSubscriberBase, PopoutProfileUi>();
collection.AddScoped<WindowMediatorSubscriberBase, DataAnalysisUi>(); collection.AddScoped<WindowMediatorSubscriberBase, DataAnalysisUi>();
collection.AddScoped<WindowMediatorSubscriberBase, EventViewerUI>(); collection.AddScoped<WindowMediatorSubscriberBase, EventViewerUI>();
@@ -197,6 +204,7 @@ public sealed class Plugin : IDalamudPlugin
collection.AddHostedService(p => p.GetRequiredService<ConfigurationSaveService>()); collection.AddHostedService(p => p.GetRequiredService<ConfigurationSaveService>());
collection.AddHostedService(p => p.GetRequiredService<MareMediator>()); collection.AddHostedService(p => p.GetRequiredService<MareMediator>());
collection.AddHostedService(p => p.GetRequiredService<NotificationService>()); collection.AddHostedService(p => p.GetRequiredService<NotificationService>());
collection.AddHostedService(p => p.GetRequiredService<TemporarySyncshellNotificationService>());
collection.AddHostedService(p => p.GetRequiredService<FileCacheManager>()); collection.AddHostedService(p => p.GetRequiredService<FileCacheManager>());
collection.AddHostedService(p => p.GetRequiredService<ConfigurationMigrator>()); collection.AddHostedService(p => p.GetRequiredService<ConfigurationMigrator>());
collection.AddHostedService(p => p.GetRequiredService<DalamudUtilService>()); collection.AddHostedService(p => p.GetRequiredService<DalamudUtilService>());
@@ -205,6 +213,7 @@ public sealed class Plugin : IDalamudPlugin
collection.AddHostedService(p => p.GetRequiredService<EventAggregator>()); collection.AddHostedService(p => p.GetRequiredService<EventAggregator>());
collection.AddHostedService(p => p.GetRequiredService<MarePlugin>()); collection.AddHostedService(p => p.GetRequiredService<MarePlugin>());
collection.AddHostedService(p => p.GetRequiredService<IpcProvider>()); collection.AddHostedService(p => p.GetRequiredService<IpcProvider>());
collection.AddHostedService(p => p.GetRequiredService<MareSynchronos.Services.AutoDetect.NearbyDiscoveryService>());
}) })
.Build(); .Build();

View File

@@ -0,0 +1,83 @@
using Microsoft.Extensions.Logging;
using MareSynchronos.WebAPI.AutoDetect;
using MareSynchronos.MareConfiguration;
using MareSynchronos.Services;
using MareSynchronos.Services.Mediator;
using NotificationType = MareSynchronos.MareConfiguration.Models.NotificationType;
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;
public AutoDetectRequestService(ILogger<AutoDetectRequestService> logger, DiscoveryConfigProvider configProvider, DiscoveryApiClient client, MareConfigService configService, MareMediator mediator, DalamudUtilService dalamudUtilService)
{
_logger = logger;
_configProvider = configProvider;
_client = client;
_configService = configService;
_mediator = mediator;
_dalamud = dalamudUtilService;
}
public async Task<bool> SendRequestAsync(string token, 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;
}
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 { }
_logger.LogInformation("Nearby: sending pair request via {endpoint}", endpoint);
var ok = await _client.SendRequestAsync(endpoint!, token, displayName, ct).ConfigureAwait(false);
if (ok)
{
_mediator.Publish(new NotificationMessage("Nearby request sent", "The other user will receive a request notification.", NotificationType.Info));
}
else
{
_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);
}
}

View File

@@ -0,0 +1,173 @@
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;
private DateTimeOffset _lastLoad = DateTimeOffset.MinValue;
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;
_lastLoad = DateTimeOffset.UtcNow;
_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;
_lastLoad = DateTimeOffset.UtcNow;
_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,477 @@
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 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);
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)
{
_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);
try { _loopCts?.Cancel(); } catch { }
return Task.CompletedTask;
}
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");
}
}
_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,87 @@
using System.Collections.Concurrent;
using System.Text.RegularExpressions;
using MareSynchronos.Services.Mediator;
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 ConcurrentDictionary<string, string> _pending = new(StringComparer.Ordinal);
private static readonly Regex ReqRegex = new(@"^Nearby Request: (.+) \[(?<uid>[A-Z0-9]+)\]$", RegexOptions.Compiled);
private static readonly Regex AcceptRegex = new(@"^Nearby Accept: (.+) \[(?<uid>[A-Z0-9]+)\]$", RegexOptions.Compiled);
public NearbyPendingService(ILogger<NearbyPendingService> logger, MareMediator mediator, ApiController api, AutoDetectRequestService requestService)
{
_logger = logger;
_mediator = mediator;
_api = api;
_requestService = requestService;
_mediator.Subscribe<NotificationMessage>(this, OnNotification);
}
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 _);
_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);
}
public void Remove(string uid)
{
_pending.TryRemove(uid, out _);
}
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.SendAcceptNotifyAsync(uid);
return true;
}
catch (Exception ex)
{
_logger.LogWarning(ex, "NearbyPending: accept failed for {uid}", uid);
return false;
}
}
}

View File

@@ -14,10 +14,8 @@ namespace MareSynchronos.Services;
public sealed class CommandManagerService : IDisposable public sealed class CommandManagerService : IDisposable
{ {
private const string _commandName = "/sync"; private const string _commandName = "/usync";
private const string _commandName2 = "/usync"; private const string _ssCommandPrefix = "/ums";
private const string _ssCommandPrefix = "/ss";
private readonly ApiController _apiController; private readonly ApiController _apiController;
private readonly ICommandManager _commandManager; private readonly ICommandManager _commandManager;
@@ -42,11 +40,7 @@ public sealed class CommandManagerService : IDisposable
_mareConfigService = mareConfigService; _mareConfigService = mareConfigService;
_commandManager.AddHandler(_commandName, new CommandInfo(OnCommand) _commandManager.AddHandler(_commandName, new CommandInfo(OnCommand)
{ {
HelpMessage = "Opens the Umbra UI" HelpMessage = "Opens the UmbraSync UI"
});
_commandManager.AddHandler(_commandName2, new CommandInfo(OnCommand)
{
HelpMessage = "Opens the Umbra UI"
}); });
// Lazy registration of all possible /ss# commands which tbf is what the game does for linkshells anyway // Lazy registration of all possible /ss# commands which tbf is what the game does for linkshells anyway
@@ -62,7 +56,7 @@ public sealed class CommandManagerService : IDisposable
public void Dispose() public void Dispose()
{ {
_commandManager.RemoveHandler(_commandName); _commandManager.RemoveHandler(_commandName);
_commandManager.RemoveHandler(_commandName2);
for (int i = 1; i <= ChatService.CommandMaxNumber; ++i) for (int i = 1; i <= ChatService.CommandMaxNumber; ++i)
_commandManager.RemoveHandler($"{_ssCommandPrefix}{i}"); _commandManager.RemoveHandler($"{_ssCommandPrefix}{i}");
@@ -147,7 +141,6 @@ public sealed class CommandManagerService : IDisposable
} }
else else
{ {
// FIXME: Chat content seems to already be stripped of any special characters here?
byte[] chatBytes = Encoding.UTF8.GetBytes(args); byte[] chatBytes = Encoding.UTF8.GetBytes(args);
_chatService.SendChatShell(shellNumber, chatBytes); _chatService.SendChatShell(shellNumber, chatBytes);
} }

View File

@@ -12,7 +12,7 @@ using System.Numerics;
namespace MareSynchronos.Services.Mediator; namespace MareSynchronos.Services.Mediator;
#pragma warning disable MA0048 // File name must match type name #pragma warning disable MA0048
#pragma warning disable S2094 #pragma warning disable S2094
public record SwitchToIntroUiMessage : MessageBase; public record SwitchToIntroUiMessage : MessageBase;
public record SwitchToMainUiMessage : MessageBase; public record SwitchToMainUiMessage : MessageBase;
@@ -108,6 +108,11 @@ public record GPoseLobbyReceiveCharaData(CharaDataDownloadDto CharaDataDownloadD
public record GPoseLobbyReceivePoseData(UserData UserData, PoseData PoseData) : MessageBase; public record GPoseLobbyReceivePoseData(UserData UserData, PoseData PoseData) : MessageBase;
public record GPoseLobbyReceiveWorldData(UserData UserData, WorldData WorldData) : 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 PluginChangeMessage(string InternalName, Version Version, bool IsLoaded) : KeyedMessage(InternalName); public record PluginChangeMessage(string InternalName, Version Version, bool IsLoaded) : KeyedMessage(InternalName);
#pragma warning restore S2094 #pragma warning restore S2094
#pragma warning restore MA0048 // File name must match type name #pragma warning restore MA0048

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,197 @@
using Dalamud.Bindings.ImGui;
using Dalamud.Interface.Colors;
using Dalamud.Game.ClientState.Objects.SubKinds;
using Dalamud.Interface.Utility;
using Dalamud.Interface.Utility.Raii;
using Dalamud.Plugin.Services;
using MareSynchronos.MareConfiguration;
using MareSynchronos.Services;
using MareSynchronos.PlayerData.Pairs;
using MareSynchronos.Services.Mediator;
using Microsoft.Extensions.Logging;
using System.Numerics;
using System.Globalization;
using System.Text;
namespace MareSynchronos.UI;
public class AutoDetectUi : WindowMediatorSubscriberBase
{
private readonly MareConfigService _configService;
private readonly DalamudUtilService _dalamud;
private readonly IObjectTable _objectTable;
private readonly Services.AutoDetect.AutoDetectRequestService _requestService;
private readonly PairManager _pairManager;
private List<Services.Mediator.NearbyEntry> _entries = new();
public AutoDetectUi(ILogger<AutoDetectUi> logger, MareMediator mediator,
MareConfigService configService, DalamudUtilService dalamudUtilService, IObjectTable objectTable,
Services.AutoDetect.AutoDetectRequestService requestService, PairManager pairManager,
PerformanceCollectorService performanceCollectorService)
: base(logger, mediator, "AutoDetect", performanceCollectorService)
{
_configService = configService;
_dalamud = dalamudUtilService;
_objectTable = objectTable;
_requestService = requestService;
_pairManager = pairManager;
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");
if (!_configService.Current.EnableAutoDetectDiscovery)
{
UiSharedService.ColorTextWrapped("Nearby detection is disabled. Enable it in Settings to start detecting nearby Umbra users.", 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);
// Table header
if (ImGui.BeginTable("autodetect-nearby", 5, ImGuiTableFlags.SizingStretchProp))
{
ImGui.TableSetupColumn("Name");
ImGui.TableSetupColumn("World");
ImGui.TableSetupColumn("Distance");
ImGui.TableSetupColumn("Status");
ImGui.TableSetupColumn("Action");
ImGui.TableHeadersRow();
var data = _entries.Count > 0 ? _entries.Where(e => e.IsMatch).ToList() : new List<Services.Mediator.NearbyEntry>();
foreach (var e in data)
{
ImGui.TableNextColumn();
ImGui.TextUnformatted(e.Name);
ImGui.TableNextColumn();
ImGui.TextUnformatted(e.WorldId == 0 ? "-" : (_dalamud.WorldData.Value.TryGetValue(e.WorldId, out var w) ? w : e.WorldId.ToString()));
ImGui.TableNextColumn();
ImGui.TextUnformatted(float.IsNaN(e.Distance) ? "-" : $"{e.Distance:0.0} m");
ImGui.TableNextColumn();
bool alreadyPaired = IsAlreadyPairedByUidOrAlias(e);
string status = alreadyPaired ? "Paired" : (string.IsNullOrEmpty(e.Token) ? "Requests disabled" : "On Umbra");
ImGui.TextUnformatted(status);
ImGui.TableNextColumn();
using (ImRaii.Disabled(alreadyPaired || string.IsNullOrEmpty(e.Token)))
{
if (alreadyPaired)
{
ImGui.Button($"Already sync##{e.Name}");
}
else if (string.IsNullOrEmpty(e.Token))
{
ImGui.Button($"Requests disabled##{e.Name}");
}
else if (ImGui.Button($"Send request##{e.Name}"))
{
_ = _requestService.SendRequestAsync(e.Token!);
}
}
}
ImGui.EndTable();
}
}
public override void OnOpen()
{
base.OnOpen();
Mediator.Subscribe<Services.Mediator.DiscoveryListUpdated>(this, OnDiscoveryUpdated);
}
public override void OnClose()
{
Mediator.Unsubscribe<Services.Mediator.DiscoveryListUpdated>(this);
base.OnClose();
}
private void OnDiscoveryUpdated(Services.Mediator.DiscoveryListUpdated msg)
{
_entries = msg.Entries;
}
private List<Services.Mediator.NearbyEntry> BuildLocalSnapshot(int maxDist)
{
var list = new List<Services.Mediator.NearbyEntry>();
var local = _dalamud.GetPlayerCharacter();
var localPos = local?.Position ?? Vector3.Zero;
for (int i = 0; i < 200; i += 2)
{
var obj = _objectTable[i];
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.ToString();
ushort worldId = 0;
if (obj is IPlayerCharacter pc) worldId = (ushort)pc.HomeWorld.RowId;
list.Add(new Services.Mediator.NearbyEntry(name, worldId, dist, false, null, null, null));
}
return list;
}
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();
}
}

View File

@@ -12,6 +12,7 @@ using MareSynchronos.PlayerData.Pairs;
using MareSynchronos.Services; using MareSynchronos.Services;
using MareSynchronos.Services.Mediator; using MareSynchronos.Services.Mediator;
using MareSynchronos.Services.ServerConfiguration; using MareSynchronos.Services.ServerConfiguration;
using MareSynchronos.Services.AutoDetect;
using MareSynchronos.UI.Components; using MareSynchronos.UI.Components;
using MareSynchronos.UI.Handlers; using MareSynchronos.UI.Handlers;
using MareSynchronos.WebAPI; using MareSynchronos.WebAPI;
@@ -24,6 +25,7 @@ using System.Diagnostics;
using System.Globalization; using System.Globalization;
using System.Numerics; using System.Numerics;
using System.Reflection; using System.Reflection;
using System.Linq;
namespace MareSynchronos.UI; namespace MareSynchronos.UI;
@@ -43,6 +45,8 @@ public class CompactUi : WindowMediatorSubscriberBase
private readonly ServerConfigurationManager _serverManager; private readonly ServerConfigurationManager _serverManager;
private readonly Stopwatch _timeout = new(); private readonly Stopwatch _timeout = new();
private readonly CharaDataManager _charaDataManager; private readonly CharaDataManager _charaDataManager;
private readonly NearbyPendingService _nearbyPending;
private readonly AutoDetectRequestService _autoDetectRequestService;
private readonly UidDisplayHandler _uidDisplayHandler; private readonly UidDisplayHandler _uidDisplayHandler;
private readonly UiSharedService _uiSharedService; private readonly UiSharedService _uiSharedService;
private bool _buttonState; private bool _buttonState;
@@ -56,11 +60,15 @@ public class CompactUi : WindowMediatorSubscriberBase
private bool _showModalForUserAddition; private bool _showModalForUserAddition;
private bool _showSyncShells; private bool _showSyncShells;
private bool _wasOpen; private bool _wasOpen;
private bool _nearbyOpen = true;
private List<Services.Mediator.NearbyEntry> _nearbyEntries = new();
public CompactUi(ILogger<CompactUi> logger, UiSharedService uiShared, MareConfigService configService, ApiController apiController, PairManager pairManager, ChatService chatService, public CompactUi(ILogger<CompactUi> logger, UiSharedService uiShared, MareConfigService configService, ApiController apiController, PairManager pairManager, ChatService chatService,
ServerConfigurationManager serverManager, MareMediator mediator, FileUploadManager fileTransferManager, UidDisplayHandler uidDisplayHandler, CharaDataManager charaDataManager, ServerConfigurationManager serverManager, MareMediator mediator, FileUploadManager fileTransferManager, UidDisplayHandler uidDisplayHandler, CharaDataManager charaDataManager,
NearbyPendingService nearbyPendingService,
AutoDetectRequestService autoDetectRequestService,
PerformanceCollectorService performanceCollectorService) PerformanceCollectorService performanceCollectorService)
: base(logger, mediator, "###UmbraSyncSyncMainUI", performanceCollectorService) : base(logger, mediator, "###UmbraSyncMainUI", performanceCollectorService)
{ {
_uiSharedService = uiShared; _uiSharedService = uiShared;
_configService = configService; _configService = configService;
@@ -70,6 +78,8 @@ public class CompactUi : WindowMediatorSubscriberBase
_fileTransferManager = fileTransferManager; _fileTransferManager = fileTransferManager;
_uidDisplayHandler = uidDisplayHandler; _uidDisplayHandler = uidDisplayHandler;
_charaDataManager = charaDataManager; _charaDataManager = charaDataManager;
_nearbyPending = nearbyPendingService;
_autoDetectRequestService = autoDetectRequestService;
var tagHandler = new TagHandler(_serverManager); var tagHandler = new TagHandler(_serverManager);
_groupPanel = new(this, uiShared, _pairManager, chatService, uidDisplayHandler, _configService, _serverManager, _charaDataManager); _groupPanel = new(this, uiShared, _pairManager, chatService, uidDisplayHandler, _configService, _serverManager, _charaDataManager);
@@ -80,11 +90,11 @@ public class CompactUi : WindowMediatorSubscriberBase
#if DEBUG #if DEBUG
string dev = "Dev Build"; string dev = "Dev Build";
var ver = Assembly.GetExecutingAssembly().GetName().Version!; var ver = Assembly.GetExecutingAssembly().GetName().Version!;
WindowName = $"UmbraSync {dev} ({ver.Major}.{ver.Minor}.{ver.Build})###UmbraSyncSyncMainUIDev"; WindowName = $"UmbraSync {dev} ({ver.Major}.{ver.Minor}.{ver.Build})###UmbraSyncMainUIDev";
Toggle(); Toggle();
#else #else
var ver = Assembly.GetExecutingAssembly().GetName().Version!; var ver = Assembly.GetExecutingAssembly().GetName().Version!;
WindowName = "UmbraSync " + ver.Major + "." + ver.Minor + "." + ver.Build + "###UmbraSyncSyncMainUI"; WindowName = "UmbraSync " + ver.Major + "." + ver.Minor + "." + ver.Build + "###UmbracSyncMainUI";
#endif #endif
Mediator.Subscribe<SwitchToMainUiMessage>(this, (_) => IsOpen = true); Mediator.Subscribe<SwitchToMainUiMessage>(this, (_) => IsOpen = true);
Mediator.Subscribe<SwitchToIntroUiMessage>(this, (_) => IsOpen = false); Mediator.Subscribe<SwitchToIntroUiMessage>(this, (_) => IsOpen = false);
@@ -92,6 +102,7 @@ public class CompactUi : WindowMediatorSubscriberBase
Mediator.Subscribe<CutsceneEndMessage>(this, (_) => UiSharedService_GposeEnd()); Mediator.Subscribe<CutsceneEndMessage>(this, (_) => UiSharedService_GposeEnd());
Mediator.Subscribe<DownloadStartedMessage>(this, (msg) => _currentDownloads[msg.DownloadId] = msg.DownloadStatus); Mediator.Subscribe<DownloadStartedMessage>(this, (msg) => _currentDownloads[msg.DownloadId] = msg.DownloadStatus);
Mediator.Subscribe<DownloadFinishedMessage>(this, (msg) => _currentDownloads.TryRemove(msg.DownloadId, out _)); Mediator.Subscribe<DownloadFinishedMessage>(this, (msg) => _currentDownloads.TryRemove(msg.DownloadId, out _));
Mediator.Subscribe<DiscoveryListUpdated>(this, (msg) => _nearbyEntries = msg.Entries);
Flags |= ImGuiWindowFlags.NoDocking; Flags |= ImGuiWindowFlags.NoDocking;
@@ -104,7 +115,7 @@ public class CompactUi : WindowMediatorSubscriberBase
protected override void DrawInternal() protected override void DrawInternal()
{ {
UiSharedService.AccentColor = new Vector4(0.2f, 0.6f, 1f, 1f); // custom blue UiSharedService.AccentColor = new Vector4(0.63f, 0.25f, 1f, 1f);
ImGui.SetCursorPosY(ImGui.GetCursorPosY() - ImGui.GetStyle().WindowPadding.Y - 1f * ImGuiHelpers.GlobalScale + ImGui.GetStyle().ItemSpacing.Y); ImGui.SetCursorPosY(ImGui.GetCursorPosY() - ImGui.GetStyle().WindowPadding.Y - 1f * ImGuiHelpers.GlobalScale + ImGui.GetStyle().ItemSpacing.Y);
WindowContentWidth = UiSharedService.GetWindowContentRegionWidth(); WindowContentWidth = UiSharedService.GetWindowContentRegionWidth();
if (!_apiController.IsCurrentVersion) if (!_apiController.IsCurrentVersion)
@@ -367,6 +378,121 @@ public class CompactUi : WindowMediatorSubscriberBase
_pairGroupsUi.Draw(visibleUsers, onlineUsers, offlineUsers); _pairGroupsUi.Draw(visibleUsers, onlineUsers, offlineUsers);
// Always show a Nearby group when detection is enabled, even if empty
if (_configService.Current.EnableAutoDetectDiscovery)
{
using (ImRaii.PushId("group-Nearby"))
{
var icon = _nearbyOpen ? FontAwesomeIcon.CaretSquareDown : FontAwesomeIcon.CaretSquareRight;
_uiSharedService.IconText(icon);
if (ImGui.IsItemClicked(ImGuiMouseButton.Left)) _nearbyOpen = !_nearbyOpen;
ImGui.SameLine();
var onUmbra = _nearbyEntries?.Count(e => e.IsMatch) ?? 0;
ImGui.TextUnformatted($"Nearby ({onUmbra})");
if (ImGui.IsItemClicked(ImGuiMouseButton.Left)) _nearbyOpen = !_nearbyOpen;
var btnWidth = _uiSharedService.GetIconTextButtonSize(FontAwesomeIcon.UserPlus, "Nearby");
var headerRight = ImGui.GetWindowContentRegionMin().X + UiSharedService.GetWindowContentRegionWidth();
ImGui.SameLine();
ImGui.SetCursorPosX(headerRight - btnWidth);
if (_uiSharedService.IconTextButton(FontAwesomeIcon.UserPlus, "Nearby", btnWidth))
{
Mediator.Publish(new UiToggleMessage(typeof(AutoDetectUi)));
}
if (_nearbyOpen)
{
ImGui.Indent();
var nearby = _nearbyEntries == null
? new List<Services.Mediator.NearbyEntry>()
: _nearbyEntries.Where(e => e.IsMatch)
.OrderBy(e => e.Distance)
.ToList();
if (nearby.Count == 0)
{
UiSharedService.ColorTextWrapped("No nearby players detected.", ImGuiColors.DalamudGrey3);
}
else
{
foreach (var e in nearby)
{
var name = e.DisplayName ?? e.Name;
ImGui.AlignTextToFramePadding();
ImGui.TextUnformatted(name);
var right = ImGui.GetWindowContentRegionMin().X + UiSharedService.GetWindowContentRegionWidth();
ImGui.SameLine();
bool isPaired = false;
try
{
isPaired = _pairManager.DirectPairs.Any(p => string.Equals(p.UserData.UID, e.Uid, StringComparison.Ordinal));
}
catch
{
var key = (e.DisplayName ?? e.Name) ?? string.Empty;
isPaired = _pairManager.DirectPairs.Any(p => string.Equals(p.UserData.AliasOrUID, key, StringComparison.OrdinalIgnoreCase));
}
var statusButtonSize = _uiSharedService.GetIconButtonSize(FontAwesomeIcon.UserPlus);
ImGui.SetCursorPosX(right - statusButtonSize.X);
if (isPaired)
{
_uiSharedService.IconText(FontAwesomeIcon.Check, ImGuiColors.ParsedGreen);
UiSharedService.AttachToolTip("Déjà apparié sur Umbra");
}
else if (!e.AcceptPairRequests)
{
_uiSharedService.IconText(FontAwesomeIcon.Ban, ImGuiColors.DalamudGrey3);
UiSharedService.AttachToolTip("Les demandes sont désactivées pour ce joueur");
}
else if (!string.IsNullOrEmpty(e.Token))
{
if (_uiSharedService.IconButton(FontAwesomeIcon.UserPlus))
{
_ = _autoDetectRequestService.SendRequestAsync(e.Token!);
}
UiSharedService.AttachToolTip("Envoyer une invitation Umbra");
}
else
{
_uiSharedService.IconText(FontAwesomeIcon.QuestionCircle, ImGuiColors.DalamudGrey3);
UiSharedService.AttachToolTip("Impossible d'inviter ce joueur");
}
}
}
try
{
var inbox = _nearbyPending;
if (inbox != null && inbox.Pending.Count > 0)
{
ImGuiHelpers.ScaledDummy(6);
_uiSharedService.BigText("Incoming requests");
foreach (var kv in inbox.Pending)
{
ImGui.AlignTextToFramePadding();
ImGui.TextUnformatted($"{kv.Value} [{kv.Key}]");
ImGui.SameLine();
if (_uiSharedService.IconButton(FontAwesomeIcon.Check))
{
_ = inbox.AcceptAsync(kv.Key);
}
UiSharedService.AttachToolTip("Accept and add as pair");
ImGui.SameLine();
if (_uiSharedService.IconButton(FontAwesomeIcon.Times))
{
inbox.Remove(kv.Key);
}
UiSharedService.AttachToolTip("Dismiss request");
}
}
}
catch { }
ImGui.Unindent();
ImGui.Separator();
}
}
}
ImGui.EndChild(); ImGui.EndChild();
} }
@@ -384,7 +510,7 @@ public class CompactUi : WindowMediatorSubscriberBase
{ {
ImGui.SetCursorPosX((ImGui.GetWindowContentRegionMin().X + UiSharedService.GetWindowContentRegionWidth()) / 2 - (userSize.X + textSize.X) / 2 - ImGui.GetStyle().ItemSpacing.X / 2); ImGui.SetCursorPosX((ImGui.GetWindowContentRegionMin().X + UiSharedService.GetWindowContentRegionWidth()) / 2 - (userSize.X + textSize.X) / 2 - ImGui.GetStyle().ItemSpacing.X / 2);
if (!printShard) ImGui.AlignTextToFramePadding(); if (!printShard) ImGui.AlignTextToFramePadding();
ImGui.TextColored(ImGuiColors.ParsedBlue, userCount); ImGui.TextColored(UiSharedService.AccentColor, userCount);
ImGui.SameLine(); ImGui.SameLine();
if (!printShard) ImGui.AlignTextToFramePadding(); if (!printShard) ImGui.AlignTextToFramePadding();
ImGui.TextUnformatted("Users Online"); ImGui.TextUnformatted("Users Online");
@@ -407,8 +533,9 @@ public class CompactUi : WindowMediatorSubscriberBase
{ {
ImGui.SetCursorPosY(ImGui.GetCursorPosY() - ((userSize.Y + textSize.Y) / 2 + shardTextSize.Y) / 2 - ImGui.GetStyle().ItemSpacing.Y + buttonSize.Y / 2); ImGui.SetCursorPosY(ImGui.GetCursorPosY() - ((userSize.Y + textSize.Y) / 2 + shardTextSize.Y) / 2 - ImGui.GetStyle().ItemSpacing.Y + buttonSize.Y / 2);
} }
var color = UiSharedService.GetBoolColor(!_serverManager.CurrentServer!.FullPause); var isLinked = !_serverManager.CurrentServer!.FullPause;
var connectedIcon = !_serverManager.CurrentServer.FullPause ? FontAwesomeIcon.Link : FontAwesomeIcon.Unlink; var color = isLinked ? new Vector4(0.63f, 0.25f, 1f, 1f) : UiSharedService.GetBoolColor(isLinked);
var connectedIcon = isLinked ? FontAwesomeIcon.Link : FontAwesomeIcon.Unlink;
if (_apiController.ServerState is ServerState.Connected) if (_apiController.ServerState is ServerState.Connected)
{ {
@@ -483,22 +610,19 @@ public class CompactUi : WindowMediatorSubscriberBase
ImGui.SameLine(WindowContentWidth - textSize.X); ImGui.SameLine(WindowContentWidth - textSize.X);
ImGui.TextUnformatted(downloadText); ImGui.TextUnformatted(downloadText);
} }
var spacing = ImGui.GetStyle().ItemSpacing.X;
var bottomButtonWidth = (WindowContentWidth - ImGui.GetStyle().ItemSpacing.X) / 2; var bottomButtonWidth = (WindowContentWidth - spacing) / 2f;
if (_uiSharedService.IconTextButton(FontAwesomeIcon.PersonCircleQuestion, "Character Analysis", bottomButtonWidth)) if (_uiSharedService.IconTextButton(FontAwesomeIcon.PersonCircleQuestion, "Character Analysis", bottomButtonWidth))
{ {
Mediator.Publish(new UiToggleMessage(typeof(DataAnalysisUi))); Mediator.Publish(new UiToggleMessage(typeof(DataAnalysisUi)));
} }
ImGui.SameLine(); ImGui.SameLine();
if (_uiSharedService.IconTextButton(FontAwesomeIcon.Running, "Character Data Hub", bottomButtonWidth)) if (_uiSharedService.IconTextButton(FontAwesomeIcon.Running, "Character Data Hub", bottomButtonWidth))
{ {
Mediator.Publish(new UiToggleMessage(typeof(CharaDataHubUi))); Mediator.Publish(new UiToggleMessage(typeof(CharaDataHubUi)));
} }
ImGuiHelpers.ScaledDummy(2);
ImGui.SameLine();
} }
private void DrawUIDHeader() private void DrawUIDHeader()
@@ -524,7 +648,7 @@ public class CompactUi : WindowMediatorSubscriberBase
} }
UiSharedService.AttachToolTip("Open the UmbraSync Settings"); UiSharedService.AttachToolTip("Open the UmbraSync Settings");
ImGui.SameLine(); //Important to draw the uidText consistently ImGui.SameLine();
ImGui.SetCursorPos(originalPos); ImGui.SetCursorPos(originalPos);
if (_apiController.ServerState is ServerState.Connected) if (_apiController.ServerState is ServerState.Connected)
@@ -592,7 +716,7 @@ public class CompactUi : WindowMediatorSubscriberBase
{ {
ServerState.Connecting => ImGuiColors.DalamudYellow, ServerState.Connecting => ImGuiColors.DalamudYellow,
ServerState.Reconnecting => ImGuiColors.DalamudRed, ServerState.Reconnecting => ImGuiColors.DalamudRed,
ServerState.Connected => new Vector4(0.2f, 0.6f, 1f, 1f), // custom blue ServerState.Connected => new Vector4(0.63f, 0.25f, 1f, 1f), // custom violet
ServerState.Disconnected => ImGuiColors.DalamudYellow, ServerState.Disconnected => ImGuiColors.DalamudYellow,
ServerState.Disconnecting => ImGuiColors.DalamudYellow, ServerState.Disconnecting => ImGuiColors.DalamudYellow,
ServerState.Unauthorized => ImGuiColors.DalamudRed, ServerState.Unauthorized => ImGuiColors.DalamudRed,

View File

@@ -1,4 +1,5 @@
using Dalamud.Bindings.ImGui; using System.Numerics;
using Dalamud.Bindings.ImGui;
using Dalamud.Interface; using Dalamud.Interface;
using Dalamud.Interface.Colors; using Dalamud.Interface.Colors;
using Dalamud.Interface.Utility; using Dalamud.Interface.Utility;
@@ -38,15 +39,15 @@ public class DrawGroupPair : DrawPairBase
var entryIsMod = _fullInfoDto.GroupPairStatusInfo.IsModerator(); var entryIsMod = _fullInfoDto.GroupPairStatusInfo.IsModerator();
var entryIsOwner = string.Equals(_pair.UserData.UID, _group.OwnerUID, StringComparison.Ordinal); var entryIsOwner = string.Equals(_pair.UserData.UID, _group.OwnerUID, StringComparison.Ordinal);
var entryIsPinned = _fullInfoDto.GroupPairStatusInfo.IsPinned(); var entryIsPinned = _fullInfoDto.GroupPairStatusInfo.IsPinned();
var presenceIcon = _pair.IsVisible ? FontAwesomeIcon.Eye : (_pair.IsOnline ? FontAwesomeIcon.Link : FontAwesomeIcon.Unlink); var presenceIcon = _pair.IsVisible ? FontAwesomeIcon.Eye : FontAwesomeIcon.CloudMoon;
var presenceColor = (_pair.IsOnline || _pair.IsVisible) ? ImGuiColors.ParsedGreen : ImGuiColors.DalamudRed; var presenceColor = (_pair.IsOnline || _pair.IsVisible) ? new Vector4(0.63f, 0.25f, 1f, 1f) : ImGuiColors.DalamudGrey;
var presenceText = entryUID + " is offline"; var presenceText = entryUID + " is offline";
ImGui.SetCursorPosY(textPosY); ImGui.SetCursorPosY(textPosY);
bool drewPrefixIcon = false;
if (_pair.IsPaused) if (_pair.IsPaused)
{ {
presenceIcon = FontAwesomeIcon.Question;
presenceColor = ImGuiColors.DalamudGrey;
presenceText = entryUID + " online status is unknown (paused)"; presenceText = entryUID + " online status is unknown (paused)";
ImGui.PushFont(UiBuilder.IconFont); ImGui.PushFont(UiBuilder.IconFont);
@@ -54,24 +55,32 @@ public class DrawGroupPair : DrawPairBase
ImGui.PopFont(); ImGui.PopFont();
UiSharedService.AttachToolTip("Pairing status with " + entryUID + " is paused"); UiSharedService.AttachToolTip("Pairing status with " + entryUID + " is paused");
drewPrefixIcon = true;
} }
else else
{ {
ImGui.PushFont(UiBuilder.IconFont); bool individuallyPaired = _pair.UserPair != null;
UiSharedService.ColorText(FontAwesomeIcon.Check.ToIconString(), ImGuiColors.ParsedGreen); var violet = new Vector4(0.63f, 0.25f, 1f, 1f);
ImGui.PopFont(); if (individuallyPaired && (_pair.IsOnline || _pair.IsVisible))
{
UiSharedService.AttachToolTip("You are paired with " + entryUID); ImGui.PushFont(UiBuilder.IconFont);
UiSharedService.ColorText(FontAwesomeIcon.Moon.ToIconString(), violet);
ImGui.PopFont();
UiSharedService.AttachToolTip("You are individually paired with " + entryUID);
drewPrefixIcon = true;
}
} }
if (drewPrefixIcon)
ImGui.SameLine();
if (_pair.IsOnline && !_pair.IsVisible) presenceText = entryUID + " is online";
else if (_pair.IsOnline && _pair.IsVisible) presenceText = entryUID + " is visible: " + _pair.PlayerName + Environment.NewLine + "Click to target this player";
ImGui.SameLine();
ImGui.SetCursorPosY(textPosY); ImGui.SetCursorPosY(textPosY);
ImGui.PushFont(UiBuilder.IconFont); ImGui.PushFont(UiBuilder.IconFont);
UiSharedService.ColorText(presenceIcon.ToIconString(), presenceColor); UiSharedService.ColorText(presenceIcon.ToIconString(), presenceColor);
ImGui.PopFont(); ImGui.PopFont();
if (_pair.IsOnline && !_pair.IsVisible) presenceText = entryUID + " is online";
else if (_pair.IsOnline && _pair.IsVisible) presenceText = entryUID + " is visible: " + _pair.PlayerName + Environment.NewLine + "Click to target this player";
if (_pair.IsVisible) if (_pair.IsVisible)
{ {
if (ImGui.IsItemClicked()) if (ImGui.IsItemClicked())
@@ -94,6 +103,7 @@ public class DrawGroupPair : DrawPairBase
} }
} }
} }
UiSharedService.AttachToolTip(presenceText); UiSharedService.AttachToolTip(presenceText);
if (entryIsOwner) if (entryIsOwner)

View File

@@ -16,6 +16,7 @@ namespace MareSynchronos.UI.Components;
public class DrawUserPair : DrawPairBase public class DrawUserPair : DrawPairBase
{ {
private static readonly Vector4 Violet = new(0.63f, 0.25f, 1f, 1f);
protected readonly MareMediator _mediator; protected readonly MareMediator _mediator;
private readonly SelectGroupForPairUi _selectGroupForPairUi; private readonly SelectGroupForPairUi _selectGroupForPairUi;
private readonly CharaDataManager _charaDataManager; private readonly CharaDataManager _charaDataManager;
@@ -38,39 +39,40 @@ public class DrawUserPair : DrawPairBase
protected override void DrawLeftSide(float textPosY, float originalY) protected override void DrawLeftSide(float textPosY, float originalY)
{ {
FontAwesomeIcon connectionIcon; var online = _pair.IsOnline;
Vector4 connectionColor; var offlineGrey = ImGuiColors.DalamudGrey3;
string connectionText;
if (!(_pair.UserPair!.OwnPermissions.IsPaired() && _pair.UserPair!.OtherPermissions.IsPaired()))
{
connectionIcon = FontAwesomeIcon.ArrowUp;
connectionText = _pair.UserData.AliasOrUID + " has not added you back";
connectionColor = ImGuiColors.DalamudRed;
}
else if (_pair.UserPair!.OwnPermissions.IsPaused() || _pair.UserPair!.OtherPermissions.IsPaused())
{
connectionIcon = FontAwesomeIcon.PauseCircle;
connectionText = "Pairing status with " + _pair.UserData.AliasOrUID + " is paused";
connectionColor = ImGuiColors.DalamudYellow;
}
else
{
connectionIcon = FontAwesomeIcon.Check;
connectionText = "You are paired with " + _pair.UserData.AliasOrUID;
connectionColor = ImGuiColors.ParsedGreen;
}
ImGui.SetCursorPosY(textPosY); ImGui.SetCursorPosY(textPosY);
ImGui.PushFont(UiBuilder.IconFont); ImGui.PushFont(UiBuilder.IconFont);
UiSharedService.ColorText(connectionIcon.ToIconString(), connectionColor); UiSharedService.ColorText(FontAwesomeIcon.Moon.ToIconString(), online ? Violet : offlineGrey);
ImGui.PopFont(); ImGui.PopFont();
UiSharedService.AttachToolTip(connectionText); UiSharedService.AttachToolTip(online
? "User is online"
: "User is offline");
if (!(_pair.UserPair!.OwnPermissions.IsPaired() && _pair.UserPair!.OtherPermissions.IsPaired()))
{
ImGui.SameLine();
ImGui.SetCursorPosY(textPosY);
ImGui.PushFont(UiBuilder.IconFont);
UiSharedService.ColorText(FontAwesomeIcon.ArrowUp.ToIconString(), ImGuiColors.DalamudRed);
ImGui.PopFont();
UiSharedService.AttachToolTip(_pair.UserData.AliasOrUID + " has not added you back");
}
else if (_pair.UserPair!.OwnPermissions.IsPaused() || _pair.UserPair!.OtherPermissions.IsPaused())
{
ImGui.SameLine();
ImGui.SetCursorPosY(textPosY);
ImGui.PushFont(UiBuilder.IconFont);
UiSharedService.ColorText(FontAwesomeIcon.PauseCircle.ToIconString(), ImGuiColors.DalamudYellow);
ImGui.PopFont();
UiSharedService.AttachToolTip("Pairing with " + _pair.UserData.AliasOrUID + " is paused");
}
if (_pair is { IsOnline: true, IsVisible: true }) if (_pair is { IsOnline: true, IsVisible: true })
{ {
ImGui.SameLine(); ImGui.SameLine();
ImGui.SetCursorPosY(textPosY); ImGui.SetCursorPosY(textPosY);
ImGui.PushFont(UiBuilder.IconFont); ImGui.PushFont(UiBuilder.IconFont);
UiSharedService.ColorText(FontAwesomeIcon.Eye.ToIconString(), ImGuiColors.ParsedGreen); UiSharedService.ColorText(FontAwesomeIcon.Eye.ToIconString(), Violet);
if (ImGui.IsItemClicked()) if (ImGui.IsItemClicked())
{ {
_mediator.Publish(new TargetPairMessage(_pair)); _mediator.Publish(new TargetPairMessage(_pair));
@@ -141,8 +143,6 @@ public class DrawUserPair : DrawPairBase
var individualSoundsDisabled = (_pair.UserPair?.OwnPermissions.IsDisableSounds() ?? false) || (_pair.UserPair?.OtherPermissions.IsDisableSounds() ?? false); var individualSoundsDisabled = (_pair.UserPair?.OwnPermissions.IsDisableSounds() ?? false) || (_pair.UserPair?.OtherPermissions.IsDisableSounds() ?? false);
var individualAnimDisabled = (_pair.UserPair?.OwnPermissions.IsDisableAnimations() ?? false) || (_pair.UserPair?.OtherPermissions.IsDisableAnimations() ?? false); var individualAnimDisabled = (_pair.UserPair?.OwnPermissions.IsDisableAnimations() ?? false) || (_pair.UserPair?.OtherPermissions.IsDisableAnimations() ?? false);
var individualVFXDisabled = (_pair.UserPair?.OwnPermissions.IsDisableVFX() ?? false) || (_pair.UserPair?.OtherPermissions.IsDisableVFX() ?? false); var individualVFXDisabled = (_pair.UserPair?.OwnPermissions.IsDisableVFX() ?? false) || (_pair.UserPair?.OtherPermissions.IsDisableVFX() ?? false);
// Icon for individually applied permissions
if (individualSoundsDisabled || individualAnimDisabled || individualVFXDisabled) if (individualSoundsDisabled || individualAnimDisabled || individualVFXDisabled)
{ {
var icon = FontAwesomeIcon.ExclamationTriangle; var icon = FontAwesomeIcon.ExclamationTriangle;
@@ -197,8 +197,6 @@ public class DrawUserPair : DrawPairBase
} }
} }
} }
// Icon for shared character data
if (_charaDataManager.SharedWithYouData.TryGetValue(_pair.UserData, out var sharedData)) if (_charaDataManager.SharedWithYouData.TryGetValue(_pair.UserData, out var sharedData))
{ {
var icon = FontAwesomeIcon.Running; var icon = FontAwesomeIcon.Running;

View File

@@ -1,4 +1,4 @@
using Dalamud.Bindings.ImGui; using Dalamud.Bindings.ImGui;
using Dalamud.Interface; using Dalamud.Interface;
using Dalamud.Interface.Colors; using Dalamud.Interface.Colors;
using Dalamud.Interface.Utility; using Dalamud.Interface.Utility;
@@ -17,6 +17,7 @@ using MareSynchronos.Services.ServerConfiguration;
using MareSynchronos.UI.Components; using MareSynchronos.UI.Components;
using MareSynchronos.UI.Handlers; using MareSynchronos.UI.Handlers;
using MareSynchronos.WebAPI; using MareSynchronos.WebAPI;
using System;
using System.Globalization; using System.Globalization;
using System.Numerics; using System.Numerics;
@@ -40,6 +41,7 @@ internal sealed class GroupPanel
private string _editGroupComment = string.Empty; private string _editGroupComment = string.Empty;
private string _editGroupEntry = string.Empty; private string _editGroupEntry = string.Empty;
private bool _errorGroupCreate = false; private bool _errorGroupCreate = false;
private string _errorGroupCreateMessage = string.Empty;
private bool _errorGroupJoin; private bool _errorGroupJoin;
private bool _isPasswordValid; private bool _isPasswordValid;
private GroupPasswordDto? _lastCreatedGroup = null; private GroupPasswordDto? _lastCreatedGroup = null;
@@ -52,6 +54,21 @@ internal sealed class GroupPanel
private bool _showModalChangePassword; private bool _showModalChangePassword;
private bool _showModalCreateGroup; private bool _showModalCreateGroup;
private bool _showModalEnterPassword; private bool _showModalEnterPassword;
private string _newSyncShellAlias = string.Empty;
private bool _createIsTemporary = false;
private int _tempSyncshellDurationHours = 24;
private readonly int[] _temporaryDurationOptions = new[]
{
1,
12,
24,
48,
72,
96,
120,
144,
168
};
private string _syncShellPassword = string.Empty; private string _syncShellPassword = string.Empty;
private string _syncShellToJoin = string.Empty; private string _syncShellToJoin = string.Empty;
@@ -82,7 +99,7 @@ internal sealed class GroupPanel
{ {
var buttonSize = _uiShared.GetIconButtonSize(FontAwesomeIcon.Plus); var buttonSize = _uiShared.GetIconButtonSize(FontAwesomeIcon.Plus);
ImGui.SetNextItemWidth(UiSharedService.GetWindowContentRegionWidth() - ImGui.GetWindowContentRegionMin().X - buttonSize.X); ImGui.SetNextItemWidth(UiSharedService.GetWindowContentRegionWidth() - ImGui.GetWindowContentRegionMin().X - buttonSize.X);
ImGui.InputTextWithHint("##syncshellid", "Syncshell GID/Alias (leave empty to create)", ref _syncShellToJoin, 20); ImGui.InputTextWithHint("##syncshellid", "Syncshell GID/Alias (leave empty to create)", ref _syncShellToJoin, 50);
ImGui.SameLine(ImGui.GetWindowContentRegionMin().X + UiSharedService.GetWindowContentRegionWidth() - buttonSize.X); ImGui.SameLine(ImGui.GetWindowContentRegionMin().X + UiSharedService.GetWindowContentRegionWidth() - buttonSize.X);
bool userCanJoinMoreGroups = _pairManager.GroupPairs.Count < ApiController.ServerInfo.MaxGroupsJoinedByUser; bool userCanJoinMoreGroups = _pairManager.GroupPairs.Count < ApiController.ServerInfo.MaxGroupsJoinedByUser;
@@ -108,6 +125,10 @@ internal sealed class GroupPanel
{ {
_lastCreatedGroup = null; _lastCreatedGroup = null;
_errorGroupCreate = false; _errorGroupCreate = false;
_newSyncShellAlias = string.Empty;
_createIsTemporary = false;
_tempSyncshellDurationHours = 24;
_errorGroupCreateMessage = string.Empty;
_showModalCreateGroup = true; _showModalCreateGroup = true;
ImGui.OpenPopup("Create Syncshell"); ImGui.OpenPopup("Create Syncshell");
} }
@@ -150,18 +171,97 @@ internal sealed class GroupPanel
if (ImGui.BeginPopupModal("Create Syncshell", ref _showModalCreateGroup, UiSharedService.PopupWindowFlags)) if (ImGui.BeginPopupModal("Create Syncshell", ref _showModalCreateGroup, UiSharedService.PopupWindowFlags))
{ {
UiSharedService.TextWrapped("Press the button below to create a new Syncshell."); UiSharedService.TextWrapped("Choisissez le type de Syncshell à créer.");
bool showPermanent = !_createIsTemporary;
if (ImGui.RadioButton("Permanente", showPermanent))
{
_createIsTemporary = false;
}
ImGui.SameLine();
if (ImGui.RadioButton("Temporaire", _createIsTemporary))
{
_createIsTemporary = true;
_newSyncShellAlias = string.Empty;
}
if (!_createIsTemporary)
{
UiSharedService.TextWrapped("Donnez un nom à votre Syncshell (optionnel) puis créez-la.");
ImGui.SetNextItemWidth(-1);
ImGui.InputTextWithHint("##syncshellalias", "Nom du Syncshell", ref _newSyncShellAlias, 50);
}
else
{
_newSyncShellAlias = string.Empty;
}
if (_createIsTemporary)
{
UiSharedService.TextWrapped("Durée maximale d'une Syncshell temporaire : 7 jours.");
if (_tempSyncshellDurationHours > 168) _tempSyncshellDurationHours = 168;
for (int i = 0; i < _temporaryDurationOptions.Length; i++)
{
var option = _temporaryDurationOptions[i];
var isSelected = _tempSyncshellDurationHours == option;
string label = option switch
{
>= 24 when option % 24 == 0 => option == 24 ? "24h" : $"{option / 24}j",
_ => option + "h"
};
if (ImGui.RadioButton(label, isSelected))
{
_tempSyncshellDurationHours = option;
}
// Start a new line after every 3 buttons
if ((i + 1) % 3 == 0)
{
ImGui.NewLine();
}
else
{
ImGui.SameLine();
}
}
var expiresLocal = DateTime.Now.AddHours(_tempSyncshellDurationHours);
UiSharedService.TextWrapped($"Expiration le {expiresLocal:g} (heure locale).");
}
UiSharedService.TextWrapped("Appuyez sur le bouton ci-dessous pour créer une nouvelle Syncshell.");
ImGui.SetNextItemWidth(200 * ImGuiHelpers.GlobalScale); ImGui.SetNextItemWidth(200 * ImGuiHelpers.GlobalScale);
if (ImGui.Button("Create Syncshell")) if (ImGui.Button("Create Syncshell"))
{ {
try try
{ {
_lastCreatedGroup = ApiController.GroupCreate().Result; if (_createIsTemporary)
{
var expiresAtUtc = DateTime.UtcNow.AddHours(_tempSyncshellDurationHours);
_lastCreatedGroup = ApiController.GroupCreateTemporary(expiresAtUtc).Result;
}
else
{
var aliasInput = string.IsNullOrWhiteSpace(_newSyncShellAlias) ? null : _newSyncShellAlias.Trim();
_lastCreatedGroup = ApiController.GroupCreate(aliasInput).Result;
if (_lastCreatedGroup != null)
{
_newSyncShellAlias = string.Empty;
}
}
} }
catch catch (Exception ex)
{ {
_lastCreatedGroup = null; _lastCreatedGroup = null;
_errorGroupCreate = true; _errorGroupCreate = true;
if (ex.Message.Contains("name is already in use", StringComparison.OrdinalIgnoreCase))
{
_errorGroupCreateMessage = "Le nom de la Syncshell est déjà utilisé.";
}
else
{
_errorGroupCreateMessage = ex.Message;
}
} }
} }
@@ -169,6 +269,11 @@ internal sealed class GroupPanel
{ {
ImGui.Separator(); ImGui.Separator();
_errorGroupCreate = false; _errorGroupCreate = false;
_errorGroupCreateMessage = string.Empty;
if (!string.IsNullOrWhiteSpace(_lastCreatedGroup.Group.Alias))
{
ImGui.TextUnformatted("Syncshell Name: " + _lastCreatedGroup.Group.Alias);
}
ImGui.TextUnformatted("Syncshell ID: " + _lastCreatedGroup.Group.GID); ImGui.TextUnformatted("Syncshell ID: " + _lastCreatedGroup.Group.GID);
ImGui.AlignTextToFramePadding(); ImGui.AlignTextToFramePadding();
ImGui.TextUnformatted("Syncshell Password: " + _lastCreatedGroup.Password); ImGui.TextUnformatted("Syncshell Password: " + _lastCreatedGroup.Password);
@@ -178,12 +283,19 @@ internal sealed class GroupPanel
ImGui.SetClipboardText(_lastCreatedGroup.Password); ImGui.SetClipboardText(_lastCreatedGroup.Password);
} }
UiSharedService.TextWrapped("You can change the Syncshell password later at any time."); UiSharedService.TextWrapped("You can change the Syncshell password later at any time.");
if (_lastCreatedGroup.IsTemporary && _lastCreatedGroup.ExpiresAt != null)
{
var expiresLocal = _lastCreatedGroup.ExpiresAt.Value.ToLocalTime();
UiSharedService.TextWrapped($"Cette Syncshell expirera le {expiresLocal:g} (heure locale).");
}
} }
if (_errorGroupCreate) if (_errorGroupCreate)
{ {
UiSharedService.ColorTextWrapped("You are already owner of the maximum amount of Syncshells (3) or joined the maximum amount of Syncshells (6). Relinquish ownership of your own Syncshells to someone else or leave existing Syncshells.", var msg = string.IsNullOrWhiteSpace(_errorGroupCreateMessage)
new Vector4(1, 0, 0, 1)); ? "You are already owner of the maximum amount of Syncshells (3) or joined the maximum amount of Syncshells (6). Relinquish ownership of your own Syncshells to someone else or leave existing Syncshells."
: _errorGroupCreateMessage;
UiSharedService.ColorTextWrapped(msg, new Vector4(1, 0, 0, 1));
} }
UiSharedService.SetScaledWindowSize(350); UiSharedService.SetScaledWindowSize(350);
@@ -243,11 +355,13 @@ internal sealed class GroupPanel
if (!string.Equals(_editGroupEntry, groupDto.GID, StringComparison.Ordinal)) if (!string.Equals(_editGroupEntry, groupDto.GID, StringComparison.Ordinal))
{ {
var shellConfig = _serverConfigurationManager.GetShellConfigForGid(groupDto.GID); var shellConfig = _serverConfigurationManager.GetShellConfigForGid(groupDto.GID);
if (!_mareConfig.Current.DisableSyncshellChat && shellConfig.Enabled) var totalMembers = pairsInGroup.Count + 1;
{ var connectedMembers = pairsInGroup.Count(p => p.IsOnline) + 1;
ImGui.TextUnformatted($"[{shellNumber}]"); var maxCapacity = ApiController.ServerInfo.MaxGroupUserCount;
UiSharedService.AttachToolTip("Chat command prefix: /ss" + shellNumber); ImGui.TextUnformatted($"{connectedMembers}/{totalMembers}");
} UiSharedService.AttachToolTip("Membres connectés / membres totaux" + Environment.NewLine +
$"Capacité maximale : {maxCapacity}" + Environment.NewLine +
"Syncshell ID: " + groupDto.Group.GID);
if (textIsGid) ImGui.PushFont(UiBuilder.MonoFont); if (textIsGid) ImGui.PushFont(UiBuilder.MonoFont);
ImGui.SameLine(); ImGui.SameLine();
ImGui.TextUnformatted(groupName); ImGui.TextUnformatted(groupName);
@@ -255,6 +369,20 @@ internal sealed class GroupPanel
UiSharedService.AttachToolTip("Left click to switch between GID display and comment" + Environment.NewLine + UiSharedService.AttachToolTip("Left click to switch between GID display and comment" + Environment.NewLine +
"Right click to change comment for " + groupName + Environment.NewLine + Environment.NewLine "Right click to change comment for " + groupName + Environment.NewLine + Environment.NewLine
+ "Users: " + (pairsInGroup.Count + 1) + ", Owner: " + groupDto.OwnerAliasOrUID); + "Users: " + (pairsInGroup.Count + 1) + ", Owner: " + groupDto.OwnerAliasOrUID);
if (groupDto.IsTemporary)
{
ImGui.SameLine();
UiSharedService.ColorText("(Temp)", ImGuiColors.DalamudOrange);
if (groupDto.ExpiresAt != null)
{
var tempExpireLocal = groupDto.ExpiresAt.Value.ToLocalTime();
UiSharedService.AttachToolTip($"Expire le {tempExpireLocal:g}");
}
else
{
UiSharedService.AttachToolTip("Syncshell temporaire");
}
}
if (ImGui.IsItemClicked(ImGuiMouseButton.Left)) if (ImGui.IsItemClicked(ImGuiMouseButton.Left))
{ {
var prevState = textIsGid; var prevState = textIsGid;
@@ -464,7 +592,9 @@ internal sealed class GroupPanel
if (offlineUsers.Count > 0) if (offlineUsers.Count > 0)
{ {
ImGui.PushStyleColor(ImGuiCol.Text, ImGuiColors.DalamudGrey);
ImGui.TextUnformatted("Offline/Unknown"); ImGui.TextUnformatted("Offline/Unknown");
ImGui.PopStyleColor();
ImGui.Separator(); ImGui.Separator();
if (hideOfflineUsers) if (hideOfflineUsers)
{ {

View File

@@ -54,17 +54,12 @@ public class PairGroupsUi
ImGui.SameLine(buttonPauseOffset); ImGui.SameLine(buttonPauseOffset);
if (_uiSharedService.IconButton(pauseButton)) if (_uiSharedService.IconButton(pauseButton))
{ {
// If all of the currently visible pairs (after applying filters to the pairs)
// are paused we display a resume button to resume all currently visible (after filters)
// pairs. Otherwise, we just pause all the remaining pairs.
if (allArePaused) if (allArePaused)
{ {
// If all are paused => resume all
ResumeAllPairs(availablePairsInThisTag); ResumeAllPairs(availablePairsInThisTag);
} }
else else
{ {
// otherwise pause all remaining
PauseRemainingPairs(availablePairsInThisTag); PauseRemainingPairs(availablePairsInThisTag);
} }
} }
@@ -120,7 +115,6 @@ public class PairGroupsUi
} }
else else
{ {
// Avoid uncomfortably close group names
if (!_tagHandler.IsTagOpen(tag)) if (!_tagHandler.IsTagOpen(tag))
{ {
var size = ImGui.CalcTextSize("").Y + ImGui.GetStyle().FramePadding.Y * 2f; var size = ImGui.CalcTextSize("").Y + ImGui.GetStyle().FramePadding.Y * 2f;
@@ -157,14 +151,12 @@ public class PairGroupsUi
{ {
TagHandler.CustomUnpairedTag => "Unpaired", TagHandler.CustomUnpairedTag => "Unpaired",
TagHandler.CustomOfflineTag => "Offline", TagHandler.CustomOfflineTag => "Offline",
TagHandler.CustomOnlineTag => _mareConfig.Current.ShowOfflineUsersSeparately ? "Online/Paused" : "Contacts", TagHandler.CustomOnlineTag => _mareConfig.Current.ShowOfflineUsersSeparately ? "Online" : "Contacts",
TagHandler.CustomVisibleTag => "Visible", TagHandler.CustomVisibleTag => "Visible",
_ => tag _ => tag
}; };
string resultFolderName = !isSpecialTag ? $"{displayedName} ({visible}/{online}/{total} Pairs)" : $"{displayedName} ({online} Pairs)"; string resultFolderName = !isSpecialTag ? $"{displayedName} ({visible}/{online}/{total} Pairs)" : $"{displayedName} ({online} Pairs)";
// FontAwesomeIcon.CaretSquareDown : FontAwesomeIcon.CaretSquareRight
var icon = _tagHandler.IsTagOpen(tag) ? FontAwesomeIcon.CaretSquareDown : FontAwesomeIcon.CaretSquareRight; var icon = _tagHandler.IsTagOpen(tag) ? FontAwesomeIcon.CaretSquareDown : FontAwesomeIcon.CaretSquareRight;
_uiSharedService.IconText(icon); _uiSharedService.IconText(icon);
if (ImGui.IsItemClicked(ImGuiMouseButton.Left)) if (ImGui.IsItemClicked(ImGuiMouseButton.Left))

View File

@@ -15,20 +15,8 @@ public class SelectGroupForPairUi
private readonly UidDisplayHandler _uidDisplayHandler; private readonly UidDisplayHandler _uidDisplayHandler;
private readonly UiSharedService _uiSharedService; private readonly UiSharedService _uiSharedService;
/// <summary>
/// The group UI is always open for a specific pair. This defines which pair the UI is open for.
/// </summary>
/// <returns></returns>
private Pair? _pair; private Pair? _pair;
/// <summary>
/// Should the panel show, yes/no
/// </summary>
private bool _show; private bool _show;
/// <summary>
/// For the add category option, this stores the currently typed in tag name
/// </summary>
private string _tagNameToAdd = ""; private string _tagNameToAdd = "";
public SelectGroupForPairUi(TagHandler tagHandler, UidDisplayHandler uidDisplayHandler, UiSharedService uiSharedService) public SelectGroupForPairUi(TagHandler tagHandler, UidDisplayHandler uidDisplayHandler, UiSharedService uiSharedService)
@@ -49,7 +37,6 @@ public class SelectGroupForPairUi
var name = PairName(_pair); var name = PairName(_pair);
var popupName = $"Choose Groups for {name}"; var popupName = $"Choose Groups for {name}";
// Is the popup supposed to show but did not open yet? Open it
if (_show) if (_show)
{ {
ImGui.OpenPopup(popupName); ImGui.OpenPopup(popupName);
@@ -91,10 +78,6 @@ public class SelectGroupForPairUi
public void Open(Pair pair) public void Open(Pair pair)
{ {
_pair = pair; _pair = pair;
// Using "_show" here to de-couple the opening of the popup
// The popup name is derived from the name the user currently sees, which is
// based on the showUidForEntry dictionary.
// We'd have to derive the name here to open it popup modal here, when the Open() is called
_show = true; _show = true;
} }

View File

@@ -163,13 +163,13 @@ public class DownloadUi : WindowMediatorSubscriberBase
UiSharedService.Color(0, 0, 0, transparency), 1); UiSharedService.Color(0, 0, 0, transparency), 1);
drawList.AddRectFilled(dlBarStart with { X = dlBarStart.X - dlBarBorder, Y = dlBarStart.Y - dlBarBorder }, drawList.AddRectFilled(dlBarStart with { X = dlBarStart.X - dlBarBorder, Y = dlBarStart.Y - dlBarBorder },
dlBarEnd with { X = dlBarEnd.X + dlBarBorder, Y = dlBarEnd.Y + dlBarBorder }, dlBarEnd with { X = dlBarEnd.X + dlBarBorder, Y = dlBarEnd.Y + dlBarBorder },
UiSharedService.Color(220, 220, 255, transparency), 1); UiSharedService.Color(230, 200, 255, transparency), 1);
drawList.AddRectFilled(dlBarStart, dlBarEnd, drawList.AddRectFilled(dlBarStart, dlBarEnd,
UiSharedService.Color(0, 0, 0, transparency), 1); UiSharedService.Color(0, 0, 0, transparency), 1);
var dlProgressPercent = transferredBytes / (double)totalBytes; var dlProgressPercent = transferredBytes / (double)totalBytes;
drawList.AddRectFilled(dlBarStart, drawList.AddRectFilled(dlBarStart,
dlBarEnd with { X = dlBarStart.X + (float)(dlProgressPercent * dlBarWidth) }, dlBarEnd with { X = dlBarStart.X + (float)(dlProgressPercent * dlBarWidth) },
UiSharedService.Color(100, 100, 255, transparency), 1); UiSharedService.Color(160, 64, 255, transparency), 1);
if (_configService.Current.TransferBarsShowText) if (_configService.Current.TransferBarsShowText)
{ {

View File

@@ -2,6 +2,7 @@
using Dalamud.Game.Text.SeStringHandling; using Dalamud.Game.Text.SeStringHandling;
using Dalamud.Game.Text.SeStringHandling.Payloads; using Dalamud.Game.Text.SeStringHandling.Payloads;
using Dalamud.Plugin.Services; using Dalamud.Plugin.Services;
using Dalamud.Interface;
using MareSynchronos.MareConfiguration; using MareSynchronos.MareConfiguration;
using MareSynchronos.MareConfiguration.Configurations; using MareSynchronos.MareConfiguration.Configurations;
using MareSynchronos.PlayerData.Pairs; using MareSynchronos.PlayerData.Pairs;
@@ -15,6 +16,7 @@ namespace MareSynchronos.UI;
public sealed class DtrEntry : IDisposable, IHostedService public sealed class DtrEntry : IDisposable, IHostedService
{ {
public const string DefaultGlyph = "\u25CB";
private enum DtrStyle private enum DtrStyle
{ {
Default, Default,
@@ -196,7 +198,8 @@ public sealed class DtrEntry : IDisposable, IHostedService
{ {
var style = (DtrStyle)styleNum; var style = (DtrStyle)styleNum;
return style switch { return style switch
{
DtrStyle.Style1 => $"\xE039 {text}", DtrStyle.Style1 => $"\xE039 {text}",
DtrStyle.Style2 => $"\xE0BC {text}", DtrStyle.Style2 => $"\xE0BC {text}",
DtrStyle.Style3 => $"\xE0BD {text}", DtrStyle.Style3 => $"\xE0BD {text}",
@@ -206,7 +209,7 @@ public sealed class DtrEntry : IDisposable, IHostedService
DtrStyle.Style7 => $"\xE05D {text}", DtrStyle.Style7 => $"\xE05D {text}",
DtrStyle.Style8 => $"\xE03C{text}", DtrStyle.Style8 => $"\xE03C{text}",
DtrStyle.Style9 => $"\xE040 {text} \xE041", DtrStyle.Style9 => $"\xE040 {text} \xE041",
_ => $"\uE044 {text}" _ => DefaultGlyph + " " + text
}; };
} }

View File

@@ -50,6 +50,7 @@ public class SettingsUi : WindowMediatorSubscriberBase
private readonly AccountRegistrationService _registerService; private readonly AccountRegistrationService _registerService;
private readonly ServerConfigurationManager _serverConfigurationManager; private readonly ServerConfigurationManager _serverConfigurationManager;
private readonly UiSharedService _uiShared; private readonly UiSharedService _uiShared;
private static readonly string DtrDefaultPreviewText = DtrEntry.DefaultGlyph + " 123";
private bool _deleteAccountPopupModalShown = false; private bool _deleteAccountPopupModalShown = false;
private string _lastTab = string.Empty; private string _lastTab = string.Empty;
private bool? _notesSuccessfullyApplied = null; private bool? _notesSuccessfullyApplied = null;
@@ -211,6 +212,61 @@ public class SettingsUi : WindowMediatorSubscriberBase
_configService.Save(); _configService.Save();
} }
ImGui.Separator();
_uiShared.BigText("AutoDetect");
bool enableDiscovery = _configService.Current.EnableAutoDetectDiscovery;
if (ImGui.Checkbox("Enable Nearby detection (beta)", ref enableDiscovery))
{
_configService.Current.EnableAutoDetectDiscovery = enableDiscovery;
_configService.Save();
// notify services of toggle
Mediator.Publish(new NearbyDetectionToggled(enableDiscovery));
// if Nearby is turned OFF, force Allow Pair Requests OFF as well
if (!enableDiscovery && _configService.Current.AllowAutoDetectPairRequests)
{
_configService.Current.AllowAutoDetectPairRequests = false;
_configService.Save();
Mediator.Publish(new AllowPairRequestsToggled(false));
}
}
// Allow Pair Requests is disabled when Nearby is OFF
using (ImRaii.Disabled(!enableDiscovery))
{
bool allowRequests = _configService.Current.AllowAutoDetectPairRequests;
if (ImGui.Checkbox("Allow pair requests", ref allowRequests))
{
_configService.Current.AllowAutoDetectPairRequests = allowRequests;
_configService.Save();
// notify services of toggle
Mediator.Publish(new AllowPairRequestsToggled(allowRequests));
// user-facing info toast
Mediator.Publish(new NotificationMessage(
"Nearby Detection",
allowRequests ? "Pair requests enabled: others can invite you." : "Pair requests disabled: others cannot invite you.",
NotificationType.Info,
default));
}
}
// Radius only available when both Nearby and Allow Pair Requests are ON
if (enableDiscovery && _configService.Current.AllowAutoDetectPairRequests)
{
ImGui.Indent();
int maxMeters = _configService.Current.AutoDetectMaxDistanceMeters;
ImGui.SetNextItemWidth(200 * ImGuiHelpers.GlobalScale);
if (ImGui.SliderInt("Max distance (meters)", ref maxMeters, 5, 100))
{
_configService.Current.AutoDetectMaxDistanceMeters = maxMeters;
_configService.Save();
}
ImGui.Unindent();
}
ImGui.Separator(); ImGui.Separator();
_uiShared.BigText("Transfer UI"); _uiShared.BigText("Transfer UI");
@@ -492,7 +548,6 @@ public class SettingsUi : WindowMediatorSubscriberBase
if (ImGui.Checkbox($"Enable chat for this syncshell##{gid}", ref shellEnabled)) if (ImGui.Checkbox($"Enable chat for this syncshell##{gid}", ref shellEnabled))
{ {
// If there is an active group with the same syncshell number, pick a new one
int nextNumber = 1; int nextNumber = 1;
bool conflict = false; bool conflict = false;
foreach (var otherGroup in _pairManager.Groups) foreach (var otherGroup in _pairManager.Groups)
@@ -512,17 +567,13 @@ public class SettingsUi : WindowMediatorSubscriberBase
using var pushDisabled = ImRaii.Disabled(!shellEnabled); using var pushDisabled = ImRaii.Disabled(!shellEnabled);
ImGui.SetNextItemWidth(50 * ImGuiHelpers.GlobalScale); ImGui.SetNextItemWidth(50 * ImGuiHelpers.GlobalScale);
// _uiShared.DrawCombo() remembers the selected option -- we don't want that, because the value can change
if (ImGui.BeginCombo("Syncshell number##{gid}", $"{shellNumber}")) if (ImGui.BeginCombo("Syncshell number##{gid}", $"{shellNumber}"))
{ {
// Same hard-coded number in CommandManagerService
for (int i = 1; i <= ChatService.CommandMaxNumber; ++i) for (int i = 1; i <= ChatService.CommandMaxNumber; ++i)
{ {
if (ImGui.Selectable($"{i}", i == shellNumber)) if (ImGui.Selectable($"{i}", i == shellNumber))
{ {
// Find an active group with the same syncshell number as selected, and swap it
// This logic can leave duplicate IDs present in the config but its not critical
foreach (var otherGroup in _pairManager.Groups) foreach (var otherGroup in _pairManager.Groups)
{ {
if (gid.Equals(otherGroup.Key.GID, StringComparison.Ordinal)) continue; if (gid.Equals(otherGroup.Key.GID, StringComparison.Ordinal)) continue;
@@ -597,7 +648,7 @@ public class SettingsUi : WindowMediatorSubscriberBase
ImGui.SameLine(300.0f * ImGuiHelpers.GlobalScale); ImGui.SameLine(300.0f * ImGuiHelpers.GlobalScale);
if (_ipcProvider.ImpersonationActive) if (_ipcProvider.ImpersonationActive)
{ {
UiSharedService.ColorTextWrapped("Umbra API active!", ImGuiColors.HealerGreen); UiSharedService.ColorTextWrapped("Umbra API active!", UiSharedService.AccentColor);
} }
else else
{ {
@@ -936,7 +987,7 @@ public class SettingsUi : WindowMediatorSubscriberBase
_uiShared.DrawHelpText("If this option is selected all already existing notes for UIDs will be overwritten by the imported notes."); _uiShared.DrawHelpText("If this option is selected all already existing notes for UIDs will be overwritten by the imported notes.");
if (_notesSuccessfullyApplied.HasValue && _notesSuccessfullyApplied.Value) if (_notesSuccessfullyApplied.HasValue && _notesSuccessfullyApplied.Value)
{ {
UiSharedService.ColorTextWrapped("User Notes successfully imported", ImGuiColors.HealerGreen); UiSharedService.ColorTextWrapped("User Notes successfully imported", UiSharedService.AccentColor);
} }
else if (_notesSuccessfullyApplied.HasValue && !_notesSuccessfullyApplied.Value) else if (_notesSuccessfullyApplied.HasValue && !_notesSuccessfullyApplied.Value)
{ {
@@ -999,13 +1050,7 @@ public class SettingsUi : WindowMediatorSubscriberBase
_configService.Save(); _configService.Save();
} }
ImGui.SetNextItemWidth(250 * ImGuiHelpers.GlobalScale); DrawDtrStyleCombo();
_uiShared.DrawCombo("Server Info Bar style", Enumerable.Range(0, DtrEntry.NumStyles), (i) => DtrEntry.RenderDtrStyle(i, "123"),
(i) =>
{
_configService.Current.DtrStyle = i;
_configService.Save();
}, _configService.Current.DtrStyle);
if (ImGui.Checkbox("Color-code the Server Info Bar entry according to status", ref useColorsInDtr)) if (ImGui.Checkbox("Color-code the Server Info Bar entry according to status", ref useColorsInDtr))
{ {
@@ -1255,7 +1300,7 @@ public class SettingsUi : WindowMediatorSubscriberBase
ImGui.TextUnformatted("Current VRAM utilization by all nearby players:"); ImGui.TextUnformatted("Current VRAM utilization by all nearby players:");
ImGui.SameLine(); ImGui.SameLine();
using (ImRaii.PushColor(ImGuiCol.Text, ImGuiColors.HealerGreen, totalVramBytes < 2.0 * 1024.0 * 1024.0 * 1024.0)) using (ImRaii.PushColor(ImGuiCol.Text, UiSharedService.AccentColor, totalVramBytes < 2.0 * 1024.0 * 1024.0 * 1024.0))
using (ImRaii.PushColor(ImGuiCol.Text, ImGuiColors.DalamudYellow, totalVramBytes >= 4.0 * 1024.0 * 1024.0 * 1024.0)) using (ImRaii.PushColor(ImGuiCol.Text, ImGuiColors.DalamudYellow, totalVramBytes >= 4.0 * 1024.0 * 1024.0 * 1024.0))
using (ImRaii.PushColor(ImGuiCol.Text, ImGuiColors.DalamudRed, totalVramBytes >= 6.0 * 1024.0 * 1024.0 * 1024.0)) using (ImRaii.PushColor(ImGuiCol.Text, ImGuiColors.DalamudRed, totalVramBytes >= 6.0 * 1024.0 * 1024.0 * 1024.0))
ImGui.TextUnformatted($"{totalVramBytes / 1024.0 / 1024.0 / 1024.0:0.00} GiB"); ImGui.TextUnformatted($"{totalVramBytes / 1024.0 / 1024.0 / 1024.0:0.00} GiB");
@@ -1610,8 +1655,6 @@ public class SettingsUi : WindowMediatorSubscriberBase
string selectedKeyName = string.Empty; string selectedKeyName = string.Empty;
if (selectedServer.SecretKeys.TryGetValue(item.SecretKeyIdx, out var selectedKey)) if (selectedServer.SecretKeys.TryGetValue(item.SecretKeyIdx, out var selectedKey))
selectedKeyName = selectedKey.FriendlyName; selectedKeyName = selectedKey.FriendlyName;
// _uiShared.DrawCombo() remembers the selected option -- we don't want that, because the value can change
if (ImGui.BeginCombo($"##{item.CharacterName}{i}", selectedKeyName)) if (ImGui.BeginCombo($"##{item.CharacterName}{i}", selectedKeyName))
{ {
foreach (var key in selectedServer.SecretKeys) foreach (var key in selectedServer.SecretKeys)
@@ -1848,11 +1891,11 @@ public class SettingsUi : WindowMediatorSubscriberBase
{ {
ImGui.TextUnformatted("Service " + _serverConfigurationManager.CurrentServer!.ServerName + ":"); ImGui.TextUnformatted("Service " + _serverConfigurationManager.CurrentServer!.ServerName + ":");
ImGui.SameLine(); ImGui.SameLine();
ImGui.TextColored(ImGuiColors.ParsedGreen, "Available"); ImGui.TextColored(UiSharedService.AccentColor, "Available");
ImGui.SameLine(); ImGui.SameLine();
ImGui.TextUnformatted("("); ImGui.TextUnformatted("(");
ImGui.SameLine(); ImGui.SameLine();
ImGui.TextColored(ImGuiColors.ParsedGreen, _apiController.OnlineUsers.ToString(CultureInfo.InvariantCulture)); ImGui.TextColored(UiSharedService.AccentColor, _apiController.OnlineUsers.ToString(CultureInfo.InvariantCulture));
ImGui.SameLine(); ImGui.SameLine();
ImGui.TextUnformatted("Users Online"); ImGui.TextUnformatted("Users Online");
ImGui.SameLine(); ImGui.SameLine();
@@ -1890,13 +1933,7 @@ public class SettingsUi : WindowMediatorSubscriberBase
ImGui.BeginDisabled(_registrationInProgress); ImGui.BeginDisabled(_registrationInProgress);
DrawServerConfiguration(); DrawServerConfiguration();
ImGui.EndTabItem(); ImGui.EndTabItem();
ImGui.EndDisabled(); // _registrationInProgress ImGui.EndDisabled();
}
if (ImGui.BeginTabItem("Chat"))
{
DrawChatConfig();
ImGui.EndTabItem();
} }
if (ImGui.BeginTabItem("Advanced")) if (ImGui.BeginTabItem("Advanced"))
@@ -1909,6 +1946,38 @@ public class SettingsUi : WindowMediatorSubscriberBase
} }
} }
private void DrawDtrStyleCombo()
{
var styleIndex = _configService.Current.DtrStyle;
string previewText = styleIndex == 0 ? DtrDefaultPreviewText : DtrEntry.RenderDtrStyle(styleIndex, "123");
ImGui.SetNextItemWidth(250 * ImGuiHelpers.GlobalScale);
bool comboOpen = ImGui.BeginCombo("Server Info Bar style", previewText);
if (comboOpen)
{
for (int i = 0; i < DtrEntry.NumStyles; i++)
{
string label = i == 0 ? DtrDefaultPreviewText : DtrEntry.RenderDtrStyle(i, "123");
bool isSelected = i == styleIndex;
if (ImGui.Selectable(label, isSelected))
{
_configService.Current.DtrStyle = i;
_configService.Save();
}
if (isSelected)
{
ImGui.SetItemDefaultFocus();
}
}
ImGui.EndCombo();
}
}
private void UiSharedService_GposeEnd() private void UiSharedService_GposeEnd()
{ {
IsOpen = _wasOpen; IsOpen = _wasOpen;

View File

@@ -36,7 +36,7 @@ public partial class UiSharedService : DisposableMediatorSubscriberBase
ImGuiWindowFlags.NoScrollbar | ImGuiWindowFlags.NoScrollbar |
ImGuiWindowFlags.NoScrollWithMouse; ImGuiWindowFlags.NoScrollWithMouse;
public static Vector4 AccentColor { get; set; } = ImGuiColors.DalamudYellow; public static Vector4 AccentColor { get; set; } = ImGuiColors.DalamudViolet;
public readonly FileDialogManager FileDialogManager; public readonly FileDialogManager FileDialogManager;
@@ -310,7 +310,7 @@ public partial class UiSharedService : DisposableMediatorSubscriberBase
} }
} }
public static Vector4 GetBoolColor(bool input) => input ? ImGuiColors.ParsedGreen : ImGuiColors.DalamudRed; public static Vector4 GetBoolColor(bool input) => input ? AccentColor : ImGuiColors.DalamudRed;
public float GetIconTextButtonSize(FontAwesomeIcon icon, string text) public float GetIconTextButtonSize(FontAwesomeIcon icon, string text)
{ {
@@ -517,7 +517,7 @@ public partial class UiSharedService : DisposableMediatorSubscriberBase
public void BooleanToColoredIcon(bool value, bool inline = true) public void BooleanToColoredIcon(bool value, bool inline = true)
{ {
using var colorgreen = ImRaii.PushColor(ImGuiCol.Text, ImGuiColors.HealerGreen, value); using var colorgreen = ImRaii.PushColor(ImGuiCol.Text, AccentColor, value);
using var colorred = ImRaii.PushColor(ImGuiCol.Text, ImGuiColors.DalamudRed, !value); using var colorred = ImRaii.PushColor(ImGuiCol.Text, ImGuiColors.DalamudRed, !value);
if (inline) ImGui.SameLine(); if (inline) ImGui.SameLine();

View File

@@ -1,14 +1,14 @@
{ {
"Author": "SirConstance", "Author": "Keda",
"Name": "UmbraSync", "Name": "UmbraSync",
"Punchline": "Share your true self.", "Punchline": "Parce que nous le valons bien.",
"Description": "This plugin will synchronize your Penumbra mods and current Glamourer state with other paired clients automatically.", "Description": "Ce plugin synchronisera automatiquement vos mods Penumbra et l'état actuel de Glamourer avec les autres clients appairés.",
"InternalName": "UmbraSync", "InternalName": "UmbraSync",
"ApplicableVersion": "any", "ApplicableVersion": "any",
"Tags": [ "Tags": [
"customization" "customization"
], ],
"IconUrl": "https://repo.umbra-sync.net/logo.png", "IconUrl": "https://repo.umbra-sync.net/images/logo.png",
"RepoUrl": "https://repo.umbra-sync.net/plugin.json", "RepoUrl": "https://repo.umbra-sync.net/plugin.json",
"CanUnloadAsync": true "CanUnloadAsync": true
} }

View File

@@ -0,0 +1,209 @@
using System.Net.Http.Headers;
using System.Text;
using System.Text.Json;
using Microsoft.Extensions.Logging;
using MareSynchronos.WebAPI.SignalR;
using MareSynchronos.Services.AutoDetect;
namespace MareSynchronos.WebAPI.AutoDetect;
public class DiscoveryApiClient
{
private readonly ILogger<DiscoveryApiClient> _logger;
private readonly TokenProvider _tokenProvider;
private readonly DiscoveryConfigProvider _configProvider;
private readonly HttpClient _httpClient = new();
private static readonly JsonSerializerOptions JsonOpt = new() { PropertyNameCaseInsensitive = true };
public DiscoveryApiClient(ILogger<DiscoveryApiClient> logger, TokenProvider tokenProvider, DiscoveryConfigProvider configProvider)
{
_logger = logger;
_tokenProvider = tokenProvider;
_configProvider = configProvider;
_httpClient.Timeout = TimeSpan.FromSeconds(30);
}
public async Task<List<ServerMatch>> QueryAsync(string endpoint, IEnumerable<string> hashes, CancellationToken ct)
{
try
{
var token = await _tokenProvider.GetOrUpdateToken(ct).ConfigureAwait(false);
if (string.IsNullOrEmpty(token)) return [];
var distinctHashes = hashes.Distinct(StringComparer.Ordinal).ToArray();
using var req = new HttpRequestMessage(HttpMethod.Post, endpoint);
req.Headers.Authorization = new AuthenticationHeaderValue("Bearer", token);
var body = JsonSerializer.Serialize(new
{
hashes = distinctHashes,
salt = _configProvider.SaltB64
});
req.Content = new StringContent(body, Encoding.UTF8, "application/json");
var resp = await _httpClient.SendAsync(req, ct).ConfigureAwait(false);
if (resp.StatusCode == System.Net.HttpStatusCode.Unauthorized)
{
var token2 = await _tokenProvider.ForceRefreshToken(ct).ConfigureAwait(false);
if (string.IsNullOrEmpty(token2)) return [];
using var req2 = new HttpRequestMessage(HttpMethod.Post, endpoint);
req2.Headers.Authorization = new AuthenticationHeaderValue("Bearer", token2);
var body2 = JsonSerializer.Serialize(new
{
hashes = distinctHashes,
salt = _configProvider.SaltB64
});
req2.Content = new StringContent(body2, Encoding.UTF8, "application/json");
resp = await _httpClient.SendAsync(req2, ct).ConfigureAwait(false);
}
resp.EnsureSuccessStatusCode();
var json = await resp.Content.ReadAsStringAsync(ct).ConfigureAwait(false);
var result = JsonSerializer.Deserialize<List<ServerMatch>>(json, JsonOpt) ?? [];
return result;
}
catch (Exception ex)
{
_logger.LogWarning(ex, "Discovery query failed");
return [];
}
}
public async Task<bool> SendRequestAsync(string endpoint, string token, string? displayName, CancellationToken ct)
{
try
{
var jwt = await _tokenProvider.GetOrUpdateToken(ct).ConfigureAwait(false);
if (string.IsNullOrEmpty(jwt)) return false;
using var req = new HttpRequestMessage(HttpMethod.Post, endpoint);
req.Headers.Authorization = new AuthenticationHeaderValue("Bearer", jwt);
var body = JsonSerializer.Serialize(new { token, displayName });
req.Content = new StringContent(body, Encoding.UTF8, "application/json");
var resp = await _httpClient.SendAsync(req, ct).ConfigureAwait(false);
if (resp.StatusCode == System.Net.HttpStatusCode.Unauthorized)
{
var jwt2 = await _tokenProvider.ForceRefreshToken(ct).ConfigureAwait(false);
if (string.IsNullOrEmpty(jwt2)) return false;
using var req2 = new HttpRequestMessage(HttpMethod.Post, endpoint);
req2.Headers.Authorization = new AuthenticationHeaderValue("Bearer", jwt2);
var body2 = JsonSerializer.Serialize(new { token, displayName });
req2.Content = new StringContent(body2, Encoding.UTF8, "application/json");
resp = await _httpClient.SendAsync(req2, ct).ConfigureAwait(false);
}
if (!resp.IsSuccessStatusCode)
{
string txt = string.Empty;
try { txt = await resp.Content.ReadAsStringAsync(ct).ConfigureAwait(false); } catch { }
_logger.LogWarning("Discovery request failed: {code} {reason} {body}", (int)resp.StatusCode, resp.ReasonPhrase, txt);
return false;
}
return true;
}
catch (Exception ex)
{
_logger.LogWarning(ex, "Discovery send request failed");
return false;
}
}
public async Task<bool> PublishAsync(string endpoint, IEnumerable<string> hashes, string? displayName, CancellationToken ct, bool allowRequests = true)
{
try
{
var jwt = await _tokenProvider.GetOrUpdateToken(ct).ConfigureAwait(false);
if (string.IsNullOrEmpty(jwt)) return false;
using var req = new HttpRequestMessage(HttpMethod.Post, endpoint);
req.Headers.Authorization = new AuthenticationHeaderValue("Bearer", jwt);
var bodyObj = new
{
hashes = hashes.Distinct(StringComparer.Ordinal).ToArray(),
displayName,
salt = _configProvider.SaltB64,
allowRequests
};
var body = JsonSerializer.Serialize(bodyObj);
req.Content = new StringContent(body, Encoding.UTF8, "application/json");
var resp = await _httpClient.SendAsync(req, ct).ConfigureAwait(false);
if (resp.StatusCode == System.Net.HttpStatusCode.Unauthorized)
{
var jwt2 = await _tokenProvider.ForceRefreshToken(ct).ConfigureAwait(false);
if (string.IsNullOrEmpty(jwt2)) return false;
using var req2 = new HttpRequestMessage(HttpMethod.Post, endpoint);
req2.Headers.Authorization = new AuthenticationHeaderValue("Bearer", jwt2);
var body2 = JsonSerializer.Serialize(bodyObj);
req2.Content = new StringContent(body2, Encoding.UTF8, "application/json");
resp = await _httpClient.SendAsync(req2, ct).ConfigureAwait(false);
}
return resp.IsSuccessStatusCode;
}
catch (Exception ex)
{
_logger.LogWarning(ex, "Discovery publish failed");
return false;
}
}
public async Task<bool> SendAcceptAsync(string endpoint, string targetUid, string? displayName, CancellationToken ct)
{
try
{
var jwt = await _tokenProvider.GetOrUpdateToken(ct).ConfigureAwait(false);
if (string.IsNullOrEmpty(jwt)) return false;
using var req = new HttpRequestMessage(HttpMethod.Post, endpoint);
req.Headers.Authorization = new AuthenticationHeaderValue("Bearer", jwt);
var bodyObj = new { targetUid, displayName };
var body = JsonSerializer.Serialize(bodyObj);
req.Content = new StringContent(body, Encoding.UTF8, "application/json");
var resp = await _httpClient.SendAsync(req, ct).ConfigureAwait(false);
if (resp.StatusCode == System.Net.HttpStatusCode.Unauthorized)
{
var jwt2 = await _tokenProvider.ForceRefreshToken(ct).ConfigureAwait(false);
if (string.IsNullOrEmpty(jwt2)) return false;
using var req2 = new HttpRequestMessage(HttpMethod.Post, endpoint);
req2.Headers.Authorization = new AuthenticationHeaderValue("Bearer", jwt2);
var body2 = JsonSerializer.Serialize(bodyObj);
req2.Content = new StringContent(body2, Encoding.UTF8, "application/json");
resp = await _httpClient.SendAsync(req2, ct).ConfigureAwait(false);
}
return resp.IsSuccessStatusCode;
}
catch (Exception ex)
{
_logger.LogWarning(ex, "Discovery accept notify failed");
return false;
}
}
public async Task DisableAsync(string endpoint, CancellationToken ct)
{
try
{
var jwt = await _tokenProvider.GetOrUpdateToken(ct).ConfigureAwait(false);
if (string.IsNullOrEmpty(jwt)) return;
using var req = new HttpRequestMessage(HttpMethod.Post, endpoint);
req.Headers.Authorization = new AuthenticationHeaderValue("Bearer", jwt);
var resp = await _httpClient.SendAsync(req, ct).ConfigureAwait(false);
if (resp.StatusCode == System.Net.HttpStatusCode.Unauthorized)
{
var jwt2 = await _tokenProvider.ForceRefreshToken(ct).ConfigureAwait(false);
if (string.IsNullOrEmpty(jwt2)) return;
using var req2 = new HttpRequestMessage(HttpMethod.Post, endpoint);
req2.Headers.Authorization = new AuthenticationHeaderValue("Bearer", jwt2);
resp = await _httpClient.SendAsync(req2, ct).ConfigureAwait(false);
}
if (!resp.IsSuccessStatusCode)
{
string txt = string.Empty;
try { txt = await resp.Content.ReadAsStringAsync(ct).ConfigureAwait(false); } catch { }
_logger.LogWarning("Discovery disable failed: {code} {reason} {body}", (int)resp.StatusCode, resp.ReasonPhrase, txt);
}
}
catch (Exception ex)
{
_logger.LogWarning(ex, "Discovery disable failed");
}
}
}
public sealed class ServerMatch
{
public string Hash { get; set; } = string.Empty;
public string? Token { get; set; }
public string? Uid { get; set; }
public string? DisplayName { get; set; }
}

View File

@@ -49,7 +49,7 @@ public partial class FileDownloadManager : DisposableMediatorSubscriberBase
public List<FileTransfer> ForbiddenTransfers => _orchestrator.ForbiddenTransfers; public List<FileTransfer> ForbiddenTransfers => _orchestrator.ForbiddenTransfers;
public bool IsDownloading => !CurrentDownloads.Any(); public bool IsDownloading => CurrentDownloads.Any();
public void ClearDownload() public void ClearDownload()
{ {

View File

@@ -1,4 +1,5 @@
using MareSynchronos.API.Data; using System;
using MareSynchronos.API.Data;
using MareSynchronos.API.Dto.Group; using MareSynchronos.API.Dto.Group;
using MareSynchronos.WebAPI.SignalR.Utils; using MareSynchronos.WebAPI.SignalR.Utils;
using Microsoft.AspNetCore.SignalR.Client; using Microsoft.AspNetCore.SignalR.Client;
@@ -49,10 +50,21 @@ public partial class ApiController
await _mareHub!.SendAsync(nameof(GroupClear), group).ConfigureAwait(false); await _mareHub!.SendAsync(nameof(GroupClear), group).ConfigureAwait(false);
} }
public async Task<GroupPasswordDto> GroupCreate() public Task<GroupPasswordDto> GroupCreate()
{
return GroupCreate(null);
}
public async Task<GroupPasswordDto> GroupCreate(string? alias)
{ {
CheckConnection(); CheckConnection();
return await _mareHub!.InvokeAsync<GroupPasswordDto>(nameof(GroupCreate)).ConfigureAwait(false); return await _mareHub!.InvokeAsync<GroupPasswordDto>(nameof(GroupCreate), string.IsNullOrWhiteSpace(alias) ? null : alias.Trim()).ConfigureAwait(false);
}
public async Task<GroupPasswordDto> GroupCreateTemporary(DateTime expiresAtUtc)
{
CheckConnection();
return await _mareHub!.InvokeAsync<GroupPasswordDto>(nameof(GroupCreateTemporary), expiresAtUtc).ConfigureAwait(false);
} }
public async Task<List<string>> GroupCreateTempInvite(GroupDto group, int amount) public async Task<List<string>> GroupCreateTempInvite(GroupDto group, int amount)

View File

@@ -222,7 +222,7 @@ public sealed partial class ApiController : DisposableMediatorSubscriberBase, IM
#endif #endif
} }
await LoadIninitialPairs().ConfigureAwait(false); await LoadInitialPairs().ConfigureAwait(false);
await LoadOnlinePairs().ConfigureAwait(false); await LoadOnlinePairs().ConfigureAwait(false);
} }
catch (OperationCanceledException) catch (OperationCanceledException)
@@ -375,7 +375,7 @@ public sealed partial class ApiController : DisposableMediatorSubscriberBase, IM
_initialized = true; _initialized = true;
} }
private async Task LoadIninitialPairs() private async Task LoadInitialPairs()
{ {
foreach (var userPair in await UserGetPairedClients().ConfigureAwait(false)) foreach (var userPair in await UserGetPairedClients().ConfigureAwait(false))
{ {
@@ -435,7 +435,7 @@ public sealed partial class ApiController : DisposableMediatorSubscriberBase, IM
return; return;
} }
ServerState = ServerState.Connected; ServerState = ServerState.Connected;
await LoadIninitialPairs().ConfigureAwait(false); await LoadInitialPairs().ConfigureAwait(false);
await LoadOnlinePairs().ConfigureAwait(false); await LoadOnlinePairs().ConfigureAwait(false);
Mediator.Publish(new ConnectedMessage(_connectionDto)); Mediator.Publish(new ConnectedMessage(_connectionDto));
} }

View File

@@ -172,6 +172,16 @@ public sealed class TokenProvider : IDisposable, IMediatorSubscriber
return await GetNewToken(jwtIdentifier, ct).ConfigureAwait(false); return await GetNewToken(jwtIdentifier, ct).ConfigureAwait(false);
} }
public async Task<string?> ForceRefreshToken(CancellationToken ct)
{
JwtIdentifier? jwtIdentifier = await GetIdentifier().ConfigureAwait(false);
if (jwtIdentifier == null) return null;
_tokenCache.TryRemove(jwtIdentifier, out _);
_logger.LogTrace("ForceRefresh: Getting new token");
return await GetNewToken(jwtIdentifier, ct).ConfigureAwait(false);
}
public string? GetStapledWellKnown(string apiUrl) public string? GetStapledWellKnown(string apiUrl)
{ {
_wellKnownCache.TryGetValue(apiUrl, out var wellKnown); _wellKnownCache.TryGetValue(apiUrl, out var wellKnown);