Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
56 changes: 0 additions & 56 deletions Daybreak.API/Services/ApiAdvertisingService.cs

This file was deleted.

3 changes: 0 additions & 3 deletions Daybreak.API/Services/WebApplicationBuilderExtensions.cs
Original file line number Diff line number Diff line change
@@ -1,6 +1,5 @@
using Daybreak.API.Services.Interop;
using Daybreak.Shared.Services.BuildTemplates;
using Daybreak.Shared.Services.MDns;
using System.Diagnostics.CodeAnalysis;

namespace Daybreak.API.Services;
Expand All @@ -11,8 +10,6 @@ public static WebApplicationBuilder WithDaybreakServices(this WebApplicationBuil
{
builder.Services.AddSingleton<HashingService>();
builder.Services.AddSingleton<IBuildTemplateManager, BuildTemplateManager>();
builder.Services.AddSingleton<IMDomainNameService, MDomainNameService>();
builder.Services.AddHostedService<ApiAdvertisingService>();
builder.Services.AddSingleton<MemoryScanningService>();
builder.Services.AddSingleton<ChatService>();
builder.Services.AddSingleton<MainPlayerService>();
Expand Down
3 changes: 1 addition & 2 deletions Daybreak.Core/Configuration/ProjectConfiguration.cs
Original file line number Diff line number Diff line change
Expand Up @@ -67,7 +67,6 @@
using Daybreak.Shared.Services.Injection;
using Daybreak.Shared.Services.InternetChecker;
using Daybreak.Shared.Services.LaunchConfigurations;
using Daybreak.Shared.Services.MDns;
using Daybreak.Shared.Services.Menu;
using Daybreak.Shared.Services.Metrics;
using Daybreak.Shared.Services.Mods;
Expand Down Expand Up @@ -159,7 +158,6 @@ public override void RegisterServices(IServiceCollection services)
services.AddSingleton<INotificationStorage, InMemoryNotificationStorage>();
services.AddSingleton<IModsManager, ModsManager>();
services.AddSingleton<IPluginsService, PluginsService>();
services.AddSingleton<IMDomainNameService, MDomainNameService>();
services.AddSingleton<IAttachedApiAccessor, AttachedApiAccessor>();
services.AddSingleton<PrivilegeContext>();
services.AddSingleton<ViewRedirectContext>();
Expand Down Expand Up @@ -255,6 +253,7 @@ public override void RegisterServices(IServiceCollection services)
services.AddHostedSingleton<ITradeAlertingService, TradeAlertingService>();
services.AddHostedSingleton<IGuildWarsExecutableManager, GuildWarsExecutableManager>();
services.AddHostedSingleton<IEventNotifierService, EventNotifierService>();
services.AddHostedSingleton<IApiScanningService, ApiScanningService>();
services.AddHostedSingleton<GameScreenshotsTheme>();
services.AddHostedService<StartupActionManager>();
services.AddHostedService<ProcessorUsageMonitor>();
Expand Down
1 change: 0 additions & 1 deletion Daybreak.Core/Daybreak.Core.csproj
Original file line number Diff line number Diff line change
Expand Up @@ -40,7 +40,6 @@

<ItemGroup>
<PackageReference Include="Elastic.OpenTelemetry" />
<PackageReference Include="ksemenenko.ColorThief" />
<PackageReference Include="Microsoft.Extensions.FileProviders.Embedded" />
<PackageReference Include="Microsoft.FluentUI.AspNetCore.Components" />
<PackageReference Include="Microsoft.FluentUI.AspNetCore.Components.Icons" />
Expand Down
Original file line number Diff line number Diff line change
@@ -1,47 +1,42 @@
using System.Net.Http.Json;
using System.Text.Json;
using Daybreak.Linux.Services.Wine;
using Daybreak.Shared.Models.Api;
using Daybreak.Shared.Services.MDns;
using Daybreak.Shared.Services.Api;
using Microsoft.Extensions.Hosting;
using Microsoft.Extensions.Logging;
using System.Extensions.Core;

namespace Daybreak.Linux.Services.MDns;
namespace Daybreak.Services.Api;

/// <summary>
/// Linux-specific implementation of <see cref="IMDomainRegistrar"/>.
/// Since mDNS announcements from Wine don't propagate to the host,
/// this implementation scans a known port range (5080–5100) for running
/// Daybreak API instances and matches them to Guild Wars processes
/// by querying the API's health endpoint for its Wine PID, then
/// converting it to a Linux PID via <see cref="IWinePidMapper"/>.
/// Scans a known port range (5080–5100) for running Daybreak API instances
/// and matches them to Guild Wars processes by querying the API's health endpoint.
/// All probes fire in parallel with a 500ms total timeout.
/// </summary>
public sealed class PortScanningDomainRegistrar(
IWinePidMapper winePidMapper,
ILogger<PortScanningDomainRegistrar> logger)
: IMDomainRegistrar, IHostedService, IDisposable
public sealed class ApiScanningService(
IPidProvider pidProvider,
ILogger<ApiScanningService> logger)
: IApiScanningService, IHostedService, IDisposable
{
private const int StartPort = 5080;
private const int EndPort = 5100;
private const string HealthPath = "/api/v1/rest/health";
private const string DaybreakApiServicePrefix = "daybreak-api-";
private static readonly TimeSpan ProbeTimeout = TimeSpan.FromMilliseconds(500);
private static readonly TimeSpan ScanInterval = TimeSpan.FromSeconds(15);
private const string GuildWarsExecutable = "Gw.exe";
private static readonly TimeSpan ProbeTimeout = TimeSpan.FromMilliseconds(2000);
private static readonly TimeSpan ScanInterval = TimeSpan.FromSeconds(10);
private static readonly JsonSerializerOptions JsonOptions = new() { PropertyNameCaseInsensitive = true };

private readonly IWinePidMapper winePidMapper = winePidMapper;
private readonly ILogger<PortScanningDomainRegistrar> logger = logger;
private readonly IPidProvider pidProvider = pidProvider;
private readonly ILogger<ApiScanningService> logger = logger;
private readonly HttpClient httpClient = new() { Timeout = ProbeTimeout };

// Rebuilt on each scan — survives Daybreak restarts since it's based on live port probing.
private volatile Dictionary<string, List<Uri>> discoveredServices = [];
private volatile Dictionary<int, Uri> discoveredApis = [];
private CancellationTokenSource? backgroundCts;

Task IHostedService.StartAsync(CancellationToken cancellationToken)
{
this.backgroundCts = CancellationTokenSource.CreateLinkedTokenSource(cancellationToken);
this.backgroundCts = new CancellationTokenSource();
_ = Task.Factory.StartNew(
() => this.ScanPeriodically(this.backgroundCts.Token),
this.backgroundCts.Token,
Expand All @@ -62,31 +57,31 @@ public void Dispose()
this.httpClient.Dispose();
}

public IReadOnlyList<Uri>? Resolve(string service)
public Uri? GetApiUriByProcessId(int processId)
{
if (this.discoveredServices.TryGetValue(service, out var uris) && uris.Count > 0)
if (this.discoveredApis.TryGetValue(processId, out var uri))
{
return uris;
return uri;
}

return default;
}

public IReadOnlyList<Uri>? QueryByServiceName(Func<string, bool> query)
public IReadOnlyList<(int ProcessId, Uri Uri)>? QueryByProcessId(Func<int, bool> predicate)
{
var results = new List<Uri>();
foreach (var (serviceName, uris) in this.discoveredServices)
var results = new List<(int ProcessId, Uri Uri)>();
foreach (var (processId, uri) in this.discoveredApis)
{
if (query(serviceName))
if (predicate(processId))
{
results.AddRange(uris);
results.Add((processId, uri));
}
}

return results.Count > 0 ? results : default;
}

public void QueryAllServices()
public void RequestScan()
{
_ = Task.Run(() => this.ScanPortsAsync(CancellationToken.None));
}
Expand All @@ -103,7 +98,7 @@ private async Task ScanPeriodically(CancellationToken cancellationToken)
private async Task ScanPortsAsync(CancellationToken cancellationToken)
{
var scopedLogger = this.logger.CreateScopedLogger();
var newServices = new Dictionary<string, List<Uri>>();
var newApis = new Dictionary<int, Uri>();

using var timeoutCts = new CancellationTokenSource(ProbeTimeout);
using var linkedCts = CancellationTokenSource.CreateLinkedTokenSource(cancellationToken, timeoutCts.Token);
Expand All @@ -129,36 +124,27 @@ private async Task ScanPortsAsync(CancellationToken cancellationToken)
continue;
}

var (port, processId) = task.Result;
if (processId is null)
var (port, reportedPid) = task.Result;
if (reportedPid is null)
{
continue;
}

// The API reports its Wine PID. Convert to Linux PID.
var linuxPid = this.winePidMapper.WinePidToLinuxPid(processId.Value, "Gw.exe");
var effectivePid = linuxPid ?? processId.Value;

var serviceName = $"{DaybreakApiServicePrefix}{effectivePid}";
// Convert the reported PID (Wine PID on Linux) to system PID
var systemPid = this.pidProvider.ResolveSystemPid(reportedPid.Value, GuildWarsExecutable);
var uri = new Uri($"http://localhost:{port}");

scopedLogger.LogDebug(
"Found Daybreak API on port {Port} with Wine PID {WinePid} (Linux PID {LinuxPid})",
"Found Daybreak API on port {Port} with reported PID {ReportedPid} (system PID {SystemPid})",
port,
processId.Value,
effectivePid);

if (!newServices.TryGetValue(serviceName, out var uriList))
{
uriList = [];
newServices[serviceName] = uriList;
}
reportedPid.Value,
systemPid);

uriList.Add(uri);
newApis[systemPid] = uri;
}

this.discoveredServices = newServices;
scopedLogger.LogDebug("Port scan complete. Found {Count} Daybreak API instance(s)", newServices.Count);
this.discoveredApis = newApis;
scopedLogger.LogDebug("Port scan complete. Found {Count} Daybreak API instance(s)", newApis.Count);
}

private async Task<(int Port, int? ProcessId)> ProbePortAsync(int port, CancellationToken cancellationToken)
Expand Down
20 changes: 7 additions & 13 deletions Daybreak.Core/Services/Api/DaybreakApiService.cs
Original file line number Diff line number Diff line change
Expand Up @@ -5,7 +5,6 @@
using Daybreak.Shared.Models.Mods;
using Daybreak.Shared.Services.Api;
using Daybreak.Shared.Services.Injection;
using Daybreak.Shared.Services.MDns;
using Daybreak.Shared.Services.Notifications;
using Daybreak.Shared.Services.Options;
using Daybreak.Shared.Utils;
Expand All @@ -20,7 +19,7 @@ namespace Daybreak.Services.Api;
public sealed class DaybreakApiService(
IOptionsProvider optionsProvider,
IAttachedApiAccessor attachedApiAccessor,
IMDomainRegistrar mDomainRegistrar,
IApiScanningService apiScanningService,
IStubInjector stubInjector,
INotificationService notificationService,
IHttpClient<ScopedApiContext> scopedApiClient,
Expand All @@ -32,13 +31,10 @@ public sealed class DaybreakApiService(
private const string EntryPoint = "ThreadInit";
private const string LocalHost = "localhost";
private const string DaybreakApiName = "Api/Daybreak.API.dll";
private const string ProcessIdPlaceholder = "{PID}";
private const string DaybreakApiServiceName = $"daybreak-api-{ProcessIdPlaceholder}";
private const string ServiceSubType = "daybreak-api";

private readonly IOptionsProvider optionsProvider = optionsProvider.ThrowIfNull();
private readonly IAttachedApiAccessor attachedApiAccessor = attachedApiAccessor.ThrowIfNull();
private readonly IMDomainRegistrar mDomainRegistrar = mDomainRegistrar.ThrowIfNull();
private readonly IApiScanningService apiScanningService = apiScanningService.ThrowIfNull();
private readonly IStubInjector stubInjector = stubInjector.ThrowIfNull();
private readonly INotificationService notificationService = notificationService.ThrowIfNull();
private readonly IHttpClient<ScopedApiContext> scopedApiClient = scopedApiClient.ThrowIfNull();
Expand Down Expand Up @@ -124,12 +120,10 @@ public Task OnCustomManagement(CancellationToken cancellationToken)
return default;
}

var serviceName = DaybreakApiServiceName.Replace(ProcessIdPlaceholder, guildWarsProcess.Id.ToString());
var serviceUris = this.mDomainRegistrar.Resolve(serviceName);
var serviceUri = serviceUris?.Count > 0 ? serviceUris[0] : default;
var serviceUri = this.apiScanningService.GetApiUriByProcessId(guildWarsProcess.Id);
if (serviceUri is null)
{
scopedLogger.LogWarning("Failed to find Daybreak API service by name {serviceName}", serviceName);
scopedLogger.LogWarning("Failed to find Daybreak API service for process {ProcessId}", guildWarsProcess.Id);
return default;
}

Expand All @@ -150,8 +144,8 @@ public Task OnCustomManagement(CancellationToken cancellationToken)
public async Task<ScopedApiContext?> FindDaybreakApiContextByCredentials(LoginCredentials loginCredentials, CancellationToken cancellationToken)
{
var scopedLogger = this.logger.CreateScopedLogger();
var serviceUris = this.mDomainRegistrar.QueryByServiceName(n => n.StartsWith(ServiceSubType));
foreach(var uri in serviceUris ?? [])
var discoveredApis = this.apiScanningService.QueryByProcessId(_ => true);
foreach (var (_, uri) in discoveredApis ?? [])
{
var uriBuilder = new UriBuilder(uri)
{
Expand All @@ -173,7 +167,7 @@ public Task OnCustomManagement(CancellationToken cancellationToken)

public void RequestInstancesAnnouncement()
{
this.mDomainRegistrar.QueryAllServices();
this.apiScanningService.RequestScan();
}

public Task OnGuildWarsCreated(GuildWarsCreatedContext guildWarsCreatedContext, CancellationToken cancellationToken) =>
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -439,6 +439,7 @@ is null
return (default, []);
}

scopedLogger.LogDebug("Waiting for Guild Wars to be ready...");
var sw = Stopwatch.StartNew();
var isReady = await this.guildWarsReadyChecker.WaitForReady(
process,
Expand Down
Loading
Loading