From 95d9f65068607159e8db4374709b5f36dcdf78af Mon Sep 17 00:00:00 2001 From: SirConstance Date: Thu, 11 Sep 2025 15:42:41 +0200 Subject: [PATCH] Update 0.1.2 - AutoDetect Debug before release --- .../ConfigurationMigrator.cs | 59 ++++- MareSynchronos/MareSynchronos.csproj | 2 +- MareSynchronos/Plugin.cs | 7 +- .../AutoDetect/AutoDetectRequestService.cs | 37 +++ .../AutoDetect/DiscoveryConfigProvider.cs | 170 +++++++++++++ .../AutoDetect/NearbyDiscoveryService.cs | 232 ++++++++++++++++++ .../Services/CommandManagerService.cs | 15 +- MareSynchronos/Services/Mediator/Messages.cs | 7 +- MareSynchronos/UI/AutoDetectUi.cs | 93 +++++-- MareSynchronos/UI/CompactUI.cs | 20 +- .../WebAPI/AutoDetect/DiscoveryApiClient.cs | 92 +++++++ 11 files changed, 689 insertions(+), 45 deletions(-) create mode 100644 MareSynchronos/Services/AutoDetect/AutoDetectRequestService.cs create mode 100644 MareSynchronos/Services/AutoDetect/DiscoveryConfigProvider.cs create mode 100644 MareSynchronos/Services/AutoDetect/NearbyDiscoveryService.cs create mode 100644 MareSynchronos/WebAPI/AutoDetect/DiscoveryApiClient.cs diff --git a/MareSynchronos/MareConfiguration/ConfigurationMigrator.cs b/MareSynchronos/MareConfiguration/ConfigurationMigrator.cs index 5cd9112..79458b2 100644 --- a/MareSynchronos/MareConfiguration/ConfigurationMigrator.cs +++ b/MareSynchronos/MareConfiguration/ConfigurationMigrator.cs @@ -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 logger) : IHostedService +public class ConfigurationMigrator(ILogger logger, MareConfigService mareConfig) : IHostedService { private readonly ILogger _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) diff --git a/MareSynchronos/MareSynchronos.csproj b/MareSynchronos/MareSynchronos.csproj index c10cec5..a3759a3 100644 --- a/MareSynchronos/MareSynchronos.csproj +++ b/MareSynchronos/MareSynchronos.csproj @@ -3,7 +3,7 @@ UmbraSync UmbraSync - 0.1.1.0 + 0.1.2.0 diff --git a/MareSynchronos/Plugin.cs b/MareSynchronos/Plugin.cs index 6b893cf..12761b5 100644 --- a/MareSynchronos/Plugin.cs +++ b/MareSynchronos/Plugin.cs @@ -96,6 +96,10 @@ public sealed class Plugin : IDalamudPlugin collection.AddSingleton(); collection.AddSingleton(); collection.AddSingleton(); + collection.AddSingleton(); + collection.AddSingleton(); + collection.AddSingleton(); + collection.AddSingleton(); collection.AddSingleton(); collection.AddSingleton(); collection.AddSingleton(); @@ -206,6 +210,7 @@ public sealed class Plugin : IDalamudPlugin collection.AddHostedService(p => p.GetRequiredService()); collection.AddHostedService(p => p.GetRequiredService()); collection.AddHostedService(p => p.GetRequiredService()); + collection.AddHostedService(p => p.GetRequiredService()); }) .Build(); @@ -226,4 +231,4 @@ public sealed class Plugin : IDalamudPlugin _host.StopAsync().GetAwaiter().GetResult(); _host.Dispose(); } -} \ No newline at end of file +} diff --git a/MareSynchronos/Services/AutoDetect/AutoDetectRequestService.cs b/MareSynchronos/Services/AutoDetect/AutoDetectRequestService.cs new file mode 100644 index 0000000..cac9879 --- /dev/null +++ b/MareSynchronos/Services/AutoDetect/AutoDetectRequestService.cs @@ -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 _logger; + private readonly DiscoveryConfigProvider _configProvider; + private readonly DiscoveryApiClient _client; + private readonly MareConfigService _configService; + + public AutoDetectRequestService(ILogger logger, DiscoveryConfigProvider configProvider, DiscoveryApiClient client, MareConfigService configService) + { + _logger = logger; + _configProvider = configProvider; + _client = client; + _configService = configService; + } + + public async Task 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); + } +} diff --git a/MareSynchronos/Services/AutoDetect/DiscoveryConfigProvider.cs b/MareSynchronos/Services/AutoDetect/DiscoveryConfigProvider.cs new file mode 100644 index 0000000..8e9fbd2 --- /dev/null +++ b/MareSynchronos/Services/AutoDetect/DiscoveryConfigProvider.cs @@ -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 _logger; + private readonly ServerConfigurationManager _serverManager; + private readonly TokenProvider _tokenProvider; + + private WellKnownRoot? _config; + private DateTimeOffset _lastLoad = DateTimeOffset.MinValue; + + public DiscoveryConfigProvider(ILogger 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(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 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(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; + } +} diff --git a/MareSynchronos/Services/AutoDetect/NearbyDiscoveryService.cs b/MareSynchronos/Services/AutoDetect/NearbyDiscoveryService.cs new file mode 100644 index 0000000..1a35a1d --- /dev/null +++ b/MareSynchronos/Services/AutoDetect/NearbyDiscoveryService.cs @@ -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 _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 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(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 hashToIndex = new(StringComparer.Ordinal); + List 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 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> GetLocalNearbyAsync() + { + var list = new List(); + 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; + } +} diff --git a/MareSynchronos/Services/CommandManagerService.cs b/MareSynchronos/Services/CommandManagerService.cs index 4a368c8..38289c7 100644 --- a/MareSynchronos/Services/CommandManagerService.cs +++ b/MareSynchronos/Services/CommandManagerService.cs @@ -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); } diff --git a/MareSynchronos/Services/Mediator/Messages.cs b/MareSynchronos/Services/Mediator/Messages.cs index 662b2e7..078e845 100644 --- a/MareSynchronos/Services/Mediator/Messages.cs +++ b/MareSynchronos/Services/Mediator/Messages.cs @@ -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 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 \ No newline at end of file +#pragma warning restore MA0048 \ No newline at end of file diff --git a/MareSynchronos/UI/AutoDetectUi.cs b/MareSynchronos/UI/AutoDetectUi.cs index 055e021..1d4d95b 100644 --- a/MareSynchronos/UI/AutoDetectUi.cs +++ b/MareSynchronos/UI/AutoDetectUi.cs @@ -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 _entries = new(); public AutoDetectUi(ILogger 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(); } } -} \ No newline at end of file + + public override void OnOpen() + { + base.OnOpen(); + Mediator.Subscribe(this, OnDiscoveryUpdated); + } + + public override void OnClose() + { + Mediator.Unsubscribe(this); + base.OnClose(); + } + + private void OnDiscoveryUpdated(Services.Mediator.DiscoveryListUpdated msg) + { + _entries = msg.Entries; + } + + private List BuildLocalSnapshot(int maxDist) + { + var list = new List(); + 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; + } +} diff --git a/MareSynchronos/UI/CompactUI.cs b/MareSynchronos/UI/CompactUI.cs index 6a6d190..fada6b0 100644 --- a/MareSynchronos/UI/CompactUI.cs +++ b/MareSynchronos/UI/CompactUI.cs @@ -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 _nearbyEntries = new(); public CompactUi(ILogger 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(this, (_) => UiSharedService_GposeEnd()); Mediator.Subscribe(this, (msg) => _currentDownloads[msg.DownloadId] = msg.DownloadStatus); Mediator.Subscribe(this, (msg) => _currentDownloads.TryRemove(msg.DownloadId, out _)); + Mediator.Subscribe(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(); } @@ -666,4 +680,4 @@ public class CompactUi : WindowMediatorSubscriberBase _wasOpen = IsOpen; IsOpen = false; } -} \ No newline at end of file +} diff --git a/MareSynchronos/WebAPI/AutoDetect/DiscoveryApiClient.cs b/MareSynchronos/WebAPI/AutoDetect/DiscoveryApiClient.cs new file mode 100644 index 0000000..842a154 --- /dev/null +++ b/MareSynchronos/WebAPI/AutoDetect/DiscoveryApiClient.cs @@ -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 _logger; + private readonly TokenProvider _tokenProvider; + private readonly HttpClient _httpClient = new(); + + public DiscoveryApiClient(ILogger logger, TokenProvider tokenProvider) + { + _logger = logger; + _tokenProvider = tokenProvider; + _httpClient.Timeout = TimeSpan.FromSeconds(30); + } + + public async Task> QueryAsync(string endpoint, IEnumerable 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>(json) ?? []; + return result; + } + catch (Exception ex) + { + _logger.LogWarning(ex, "Discovery query failed"); + return []; + } + } + + public async Task 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 PublishAsync(string endpoint, IEnumerable 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; } +}