diff --git a/MareSynchronos/MareSynchronos.csproj b/MareSynchronos/MareSynchronos.csproj
index b9b60d8..4617480 100644
--- a/MareSynchronos/MareSynchronos.csproj
+++ b/MareSynchronos/MareSynchronos.csproj
@@ -3,7 +3,7 @@
UmbraSync
UmbraSync
- 0.1.6.2
+ 0.1.6.3
diff --git a/MareSynchronos/Services/AutoDetect/NearbyDiscoveryService.cs b/MareSynchronos/Services/AutoDetect/NearbyDiscoveryService.cs
index 9fc3a22..24d0446 100644
--- a/MareSynchronos/Services/AutoDetect/NearbyDiscoveryService.cs
+++ b/MareSynchronos/Services/AutoDetect/NearbyDiscoveryService.cs
@@ -32,6 +32,8 @@ public class NearbyDiscoveryService : IHostedService, IMediatorSubscriber
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 logger, MareMediator mediator,
MareConfigService config, DiscoveryConfigProvider configProvider, DalamudUtilService dalamudUtilService,
@@ -53,10 +55,75 @@ public class NearbyDiscoveryService : IHostedService, IMediatorSubscriber
_loopCts = new CancellationTokenSource();
_mediator.Subscribe(this, _ => { _isConnected = true; _configProvider.TryLoadFromStapled(); });
_mediator.Subscribe(this, _ => { _isConnected = false; _lastPublishedSignature = null; });
+ _mediator.Subscribe(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)
{
@@ -259,6 +326,7 @@ public class NearbyDiscoveryService : IHostedService, IMediatorSubscriber
_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)
@@ -272,7 +340,17 @@ public class NearbyDiscoveryService : IHostedService, IMediatorSubscriber
}
else
{
- _logger.LogDebug("Nearby publish skipped (no changes)");
+ // 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
diff --git a/MareSynchronos/Services/Mediator/Messages.cs b/MareSynchronos/Services/Mediator/Messages.cs
index 7dbbaaa..b6302c1 100644
--- a/MareSynchronos/Services/Mediator/Messages.cs
+++ b/MareSynchronos/Services/Mediator/Messages.cs
@@ -110,6 +110,8 @@ public record GPoseLobbyReceiveWorldData(UserData UserData, WorldData WorldData)
public record NearbyEntry(string Name, ushort WorldId, float Distance, bool IsMatch, string? Token, string? DisplayName, string? Uid);
public record DiscoveryListUpdated(List 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);
#pragma warning restore S2094
diff --git a/MareSynchronos/UI/SettingsUi.cs b/MareSynchronos/UI/SettingsUi.cs
index e363b80..77168c5 100644
--- a/MareSynchronos/UI/SettingsUi.cs
+++ b/MareSynchronos/UI/SettingsUi.cs
@@ -218,14 +218,42 @@ public class SettingsUi : WindowMediatorSubscriberBase
{
_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));
+ }
}
- bool allowRequests = _configService.Current.AllowAutoDetectPairRequests;
- if (ImGui.Checkbox("Allow pair requests", ref allowRequests))
+
+ // Allow Pair Requests is disabled when Nearby is OFF
+ using (ImRaii.Disabled(!enableDiscovery))
{
- _configService.Current.AllowAutoDetectPairRequests = allowRequests;
- _configService.Save();
+ 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));
+ }
}
- if (enableDiscovery)
+
+ // Radius only available when both Nearby and Allow Pair Requests are ON
+ if (enableDiscovery && _configService.Current.AllowAutoDetectPairRequests)
{
ImGui.Indent();
int maxMeters = _configService.Current.AutoDetectMaxDistanceMeters;
diff --git a/MareSynchronos/WebAPI/AutoDetect/DiscoveryApiClient.cs b/MareSynchronos/WebAPI/AutoDetect/DiscoveryApiClient.cs
index 0731a94..e149f79 100644
--- a/MareSynchronos/WebAPI/AutoDetect/DiscoveryApiClient.cs
+++ b/MareSynchronos/WebAPI/AutoDetect/DiscoveryApiClient.cs
@@ -39,6 +39,21 @@ public class DiscoveryApiClient
});
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)
+ {
+ // Retry once with a fresh token
+ var token2 = await _tokenProvider.GetOrUpdateToken(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 = hashes.Distinct(StringComparer.Ordinal).ToArray(),
+ 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>(json, JsonOpt) ?? [];
@@ -62,6 +77,16 @@ public class DiscoveryApiClient
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.GetOrUpdateToken(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;
@@ -96,6 +121,16 @@ public class DiscoveryApiClient
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.GetOrUpdateToken(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)
@@ -117,6 +152,16 @@ public class DiscoveryApiClient
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.GetOrUpdateToken(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)
@@ -135,6 +180,14 @@ public class DiscoveryApiClient
req.Headers.Authorization = new AuthenticationHeaderValue("Bearer", jwt);
// no body required
var resp = await _httpClient.SendAsync(req, ct).ConfigureAwait(false);
+ if (resp.StatusCode == System.Net.HttpStatusCode.Unauthorized)
+ {
+ var jwt2 = await _tokenProvider.GetOrUpdateToken(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;