Update 0.1.2 - AutoDetect Debug before release

This commit is contained in:
2025-09-11 15:42:41 +02:00
parent a70968d30c
commit 95d9f65068
11 changed files with 689 additions and 45 deletions

View File

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

View File

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

View File

@@ -96,6 +96,10 @@ public sealed class Plugin : IDalamudPlugin
collection.AddSingleton<HubFactory>();
collection.AddSingleton<FileUploadManager>();
collection.AddSingleton<FileTransferOrchestrator>();
collection.AddSingleton<MareSynchronos.Services.AutoDetect.DiscoveryConfigProvider>();
collection.AddSingleton<MareSynchronos.WebAPI.AutoDetect.DiscoveryApiClient>();
collection.AddSingleton<MareSynchronos.Services.AutoDetect.AutoDetectRequestService>();
collection.AddSingleton<MareSynchronos.Services.AutoDetect.NearbyDiscoveryService>();
collection.AddSingleton<MarePlugin>();
collection.AddSingleton<MareProfileManager>();
collection.AddSingleton<GameObjectHandlerFactory>();
@@ -206,6 +210,7 @@ public sealed class Plugin : IDalamudPlugin
collection.AddHostedService(p => p.GetRequiredService<EventAggregator>());
collection.AddHostedService(p => p.GetRequiredService<MarePlugin>());
collection.AddHostedService(p => p.GetRequiredService<IpcProvider>());
collection.AddHostedService(p => p.GetRequiredService<MareSynchronos.Services.AutoDetect.NearbyDiscoveryService>());
})
.Build();

View File

@@ -0,0 +1,37 @@
using Microsoft.Extensions.Logging;
using MareSynchronos.WebAPI.AutoDetect;
using MareSynchronos.MareConfiguration;
namespace MareSynchronos.Services.AutoDetect;
public class AutoDetectRequestService
{
private readonly ILogger<AutoDetectRequestService> _logger;
private readonly DiscoveryConfigProvider _configProvider;
private readonly DiscoveryApiClient _client;
private readonly MareConfigService _configService;
public AutoDetectRequestService(ILogger<AutoDetectRequestService> logger, DiscoveryConfigProvider configProvider, DiscoveryApiClient client, MareConfigService configService)
{
_logger = logger;
_configProvider = configProvider;
_client = client;
_configService = configService;
}
public async Task<bool> SendRequestAsync(string token, CancellationToken ct = default)
{
if (!_configService.Current.AllowAutoDetectPairRequests)
{
_logger.LogDebug("Nearby request blocked: AllowAutoDetectPairRequests is disabled");
return false;
}
var endpoint = _configProvider.RequestEndpoint;
if (string.IsNullOrEmpty(endpoint))
{
_logger.LogDebug("No request endpoint configured");
return false;
}
return await _client.SendRequestAsync(endpoint!, token, ct).ConfigureAwait(false);
}
}

View File

@@ -0,0 +1,170 @@
using System.Text.Json;
using System.Text.Json.Serialization;
using Microsoft.Extensions.Logging;
using MareSynchronos.Services.Mediator;
using MareSynchronos.Services.ServerConfiguration;
using MareSynchronos.WebAPI.SignalR;
using System.Net.Http;
using System.Net.Http.Headers;
using System.Reflection;
namespace MareSynchronos.Services.AutoDetect;
public class DiscoveryConfigProvider
{
private readonly ILogger<DiscoveryConfigProvider> _logger;
private readonly ServerConfigurationManager _serverManager;
private readonly TokenProvider _tokenProvider;
private WellKnownRoot? _config;
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 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 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}", NearbyEnabled);
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; }
}
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,232 @@
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;
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, _ => _configProvider.TryLoadFromStapled());
_ = Task.Run(() => Loop(_loopCts.Token));
return Task.CompletedTask;
}
public Task StopAsync(CancellationToken cancellationToken)
{
_mediator.UnsubscribeAll(this);
try { _loopCts?.Cancel(); } catch { }
return Task.CompletedTask;
}
private async Task Loop(CancellationToken ct)
{
// best effort config load
_configProvider.TryLoadFromStapled();
while (!ct.IsCancellationRequested)
{
try
{
if (!_config.Current.EnableAutoDetectDiscovery || !_dalamud.IsLoggedIn)
{
await Task.Delay(1000, ct).ConfigureAwait(false);
continue;
}
// Ensure we have a valid Nearby config: try stapled, then HTTP fallback
if (!_configProvider.HasConfig || _configProvider.IsExpired())
{
if (!_configProvider.TryLoadFromStapled())
{
await _configProvider.TryFetchFromServerAsync(ct).ConfigureAwait(false);
}
}
var entries = await GetLocalNearbyAsync().ConfigureAwait(false);
// Log when local count changes (including 0) to indicate activity
if (entries.Count != _lastLocalCount)
{
_lastLocalCount = entries.Count;
_logger.LogInformation("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 and reuse for publish
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);
}
// Publish local snapshot if endpoint is available (deduplicated)
if (!string.IsNullOrEmpty(_configProvider.PublishEndpoint))
{
string? displayName = null;
try
{
var me = await _dalamud.RunOnFrameworkThread(() => _dalamud.GetPlayerCharacter()).ConfigureAwait(false);
if (me != null)
{
displayName = me.Name.TextValue;
}
}
catch { /* ignore */ }
if (hashes.Count > 0)
{
var sig = string.Join(',', hashes.OrderBy(s => s, StringComparer.Ordinal)).GetHash256();
if (!string.Equals(sig, _lastPublishedSignature, StringComparison.Ordinal))
{
_lastPublishedSignature = sig;
_logger.LogDebug("Nearby publish: {count} hashes (updated)", hashes.Count);
_ = _api.PublishAsync(_configProvider.PublishEndpoint!, hashes, displayName, ct);
}
else
{
_logger.LogDebug("Nearby publish skipped (no changes)");
}
}
// else: no local entries; 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);
}
}
}
// Log change in number of Umbra matches
int matchCount = entries.Count(e => e.IsMatch);
if (matchCount != _lastMatchCount)
{
_lastMatchCount = matchCount;
_logger.LogInformation("Nearby: {count} Umbra users nearby", matchCount);
}
}
}
catch (Exception ex)
{
_logger.LogDebug(ex, "Nearby query failed; falling back to local list");
}
}
else
{
if (!_loggedLocalOnly)
{
_loggedLocalOnly = true;
_logger.LogInformation("Nearby: well-known not available or disabled; running in local-only mode");
}
}
_mediator.Publish(new DiscoveryListUpdated(entries));
var delayMs = Math.Max(1000, _configProvider.MinQueryIntervalMs);
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);
for (int i = 0; i < 200; i += 2)
{
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.ToString();
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));
}
}
catch
{
// ignore
}
return list;
}
}

View File

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

View File

@@ -12,7 +12,7 @@ using System.Numerics;
namespace MareSynchronos.Services.Mediator;
#pragma warning disable MA0048 // File name must match type name
#pragma warning disable MA0048
#pragma warning disable S2094
public record SwitchToIntroUiMessage : MessageBase;
public record SwitchToMainUiMessage : MessageBase;
@@ -108,6 +108,9 @@ public record GPoseLobbyReceiveCharaData(CharaDataDownloadDto CharaDataDownloadD
public record GPoseLobbyReceivePoseData(UserData UserData, PoseData PoseData) : MessageBase;
public record GPoseLobbyReceiveWorldData(UserData UserData, WorldData WorldData) : MessageBase;
public record NearbyEntry(string Name, ushort WorldId, float Distance, bool IsMatch, string? Token);
public record DiscoveryListUpdated(List<NearbyEntry> Entries) : MessageBase;
public record PluginChangeMessage(string InternalName, Version Version, bool IsLoaded) : KeyedMessage(InternalName);
#pragma warning restore S2094
#pragma warning restore MA0048 // File name must match type name
#pragma warning restore MA0048

View File

@@ -17,15 +17,19 @@ public class AutoDetectUi : WindowMediatorSubscriberBase
private readonly MareConfigService _configService;
private readonly DalamudUtilService _dalamud;
private readonly IObjectTable _objectTable;
private readonly Services.AutoDetect.AutoDetectRequestService _requestService;
private List<Services.Mediator.NearbyEntry> _entries = new();
public AutoDetectUi(ILogger<AutoDetectUi> logger, MareMediator mediator,
MareConfigService configService, DalamudUtilService dalamudUtilService, IObjectTable objectTable,
Services.AutoDetect.AutoDetectRequestService requestService,
PerformanceCollectorService performanceCollectorService)
: base(logger, mediator, "Umbra Nearby", performanceCollectorService)
{
_configService = configService;
_dalamud = dalamudUtilService;
_objectTable = objectTable;
_requestService = requestService;
Flags |= ImGuiWindowFlags.NoScrollbar;
SizeConstraints = new WindowSizeConstraints()
@@ -43,7 +47,7 @@ public class AutoDetectUi : WindowMediatorSubscriberBase
protected override void DrawInternal()
{
using var _ = ImRaii.PushId("autosync-ui");
using var idScope = ImRaii.PushId("autodetect-ui");
if (!_configService.Current.EnableAutoDetectDiscovery)
{
@@ -65,42 +69,79 @@ public class AutoDetectUi : WindowMediatorSubscriberBase
ImGuiHelpers.ScaledDummy(6);
// Table header
if (ImGui.BeginTable("autosync-nearby", 3, ImGuiTableFlags.SizingStretchProp))
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 local = _dalamud.GetPlayerCharacter();
var localPos = local?.Position ?? Vector3.Zero;
for (int i = 0; i < 200; i += 2)
var data = _entries.Count > 0 ? _entries : BuildLocalSnapshot(maxDist);
foreach (var e in data)
{
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)
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();
ImGui.TextUnformatted(e.IsMatch ? "On Umbra" : "Unknown");
ImGui.TableNextColumn();
bool allowRequests = _configService.Current.AllowAutoDetectPairRequests;
using (ImRaii.Disabled(!allowRequests || !e.IsMatch || string.IsNullOrEmpty(e.Token)))
{
worldId = (ushort)pc.HomeWorld.RowId;
if (ImGui.Button($"Send request##{e.Name}"))
{
_ = _requestService.SendRequestAsync(e.Token!);
}
}
if (!allowRequests)
{
UiSharedService.AttachToolTip("Enable 'Allow pair requests' in Settings to send a request.");
}
string world = worldId == 0 ? "-" : (_dalamud.WorldData.Value.TryGetValue(worldId, out var w) ? w : worldId.ToString());
ImGui.TableNextColumn();
ImGui.TextUnformatted(name);
ImGui.TableNextColumn();
ImGui.TextUnformatted(world);
ImGui.TableNextColumn();
ImGui.TextUnformatted(float.IsNaN(dist) ? "-" : $"{dist:0.0} m");
}
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));
}
return list;
}
}

View File

@@ -24,6 +24,7 @@ using System.Diagnostics;
using System.Globalization;
using System.Numerics;
using System.Reflection;
using System.Linq;
namespace MareSynchronos.UI;
@@ -57,6 +58,7 @@ public class CompactUi : WindowMediatorSubscriberBase
private bool _showSyncShells;
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,
ServerConfigurationManager serverManager, MareMediator mediator, FileUploadManager fileTransferManager, UidDisplayHandler uidDisplayHandler, CharaDataManager charaDataManager,
@@ -93,6 +95,7 @@ public class CompactUi : WindowMediatorSubscriberBase
Mediator.Subscribe<CutsceneEndMessage>(this, (_) => UiSharedService_GposeEnd());
Mediator.Subscribe<DownloadStartedMessage>(this, (msg) => _currentDownloads[msg.DownloadId] = msg.DownloadStatus);
Mediator.Subscribe<DownloadFinishedMessage>(this, (msg) => _currentDownloads.TryRemove(msg.DownloadId, out _));
Mediator.Subscribe<DiscoveryListUpdated>(this, (msg) => _nearbyEntries = msg.Entries);
Flags |= ImGuiWindowFlags.NoDocking;
@@ -386,13 +389,24 @@ public class CompactUi : WindowMediatorSubscriberBase
_uiSharedService.IconText(icon);
if (ImGui.IsItemClicked(ImGuiMouseButton.Left)) _nearbyOpen = !_nearbyOpen;
ImGui.SameLine();
ImGui.TextUnformatted("Nearby (0 Players)");
var nearbyCount = _nearbyEntries?.Count ?? 0;
var onUmbra = _nearbyEntries?.Count(e => e.IsMatch) ?? 0;
ImGui.TextUnformatted(onUmbra > 0
? $"Nearby ({nearbyCount} — {onUmbra} on Umbra)"
: $"Nearby ({nearbyCount} Players)");
if (ImGui.IsItemClicked(ImGuiMouseButton.Left)) _nearbyOpen = !_nearbyOpen;
if (_nearbyOpen)
{
ImGui.Indent();
UiSharedService.ColorTextWrapped("No nearby players detected.", ImGuiColors.DalamudGrey3);
if (nearbyCount == 0)
{
UiSharedService.ColorTextWrapped("No nearby players detected.", ImGuiColors.DalamudGrey3);
}
else
{
UiSharedService.ColorTextWrapped("Open Nearby for details.", ImGuiColors.DalamudGrey3);
}
ImGui.Unindent();
ImGui.Separator();
}

View File

@@ -0,0 +1,92 @@
using System.Net.Http.Headers;
using System.Text;
using System.Text.Json;
using Microsoft.Extensions.Logging;
using MareSynchronos.WebAPI.SignalR;
namespace MareSynchronos.WebAPI.AutoDetect;
public class DiscoveryApiClient
{
private readonly ILogger<DiscoveryApiClient> _logger;
private readonly TokenProvider _tokenProvider;
private readonly HttpClient _httpClient = new();
public DiscoveryApiClient(ILogger<DiscoveryApiClient> logger, TokenProvider tokenProvider)
{
_logger = logger;
_tokenProvider = tokenProvider;
_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 [];
using var req = new HttpRequestMessage(HttpMethod.Post, endpoint);
req.Headers.Authorization = new AuthenticationHeaderValue("Bearer", token);
var body = JsonSerializer.Serialize(new { hashes = hashes.Distinct(StringComparer.Ordinal).ToArray() });
req.Content = new StringContent(body, Encoding.UTF8, "application/json");
var resp = await _httpClient.SendAsync(req, ct).ConfigureAwait(false);
resp.EnsureSuccessStatusCode();
var json = await resp.Content.ReadAsStringAsync(ct).ConfigureAwait(false);
var result = JsonSerializer.Deserialize<List<ServerMatch>>(json) ?? [];
return result;
}
catch (Exception ex)
{
_logger.LogWarning(ex, "Discovery query failed");
return [];
}
}
public async Task<bool> SendRequestAsync(string endpoint, string token, 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 });
req.Content = new StringContent(body, Encoding.UTF8, "application/json");
var resp = await _httpClient.SendAsync(req, ct).ConfigureAwait(false);
return resp.IsSuccessStatusCode;
}
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)
{
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 };
var body = JsonSerializer.Serialize(bodyObj);
req.Content = new StringContent(body, Encoding.UTF8, "application/json");
var resp = await _httpClient.SendAsync(req, ct).ConfigureAwait(false);
return resp.IsSuccessStatusCode;
}
catch (Exception ex)
{
_logger.LogWarning(ex, "Discovery publish failed");
return false;
}
}
}
public sealed class ServerMatch
{
public string Hash { get; set; } = string.Empty;
public string Token { get; set; } = string.Empty;
public string? DisplayName { get; set; }
}