Files
Daybreak/Daybreak.Core/Services/Api/DaybreakApiService.cs
T
amacocian 47369851f2 Configuration scoped modlist (#1423) (Closes #1225)
* Mod selection for each launch configuration (Closes #1225)

* Store enabled mods with the launched application
2026-02-11 14:22:43 -08:00

247 lines
10 KiB
C#

using Daybreak.Configuration.Options;
using Daybreak.Shared.Models;
using Daybreak.Shared.Models.Async;
using Daybreak.Shared.Models.LaunchConfigurations;
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;
using Microsoft.Extensions.Logging;
using Microsoft.Extensions.Options;
using System.Core.Extensions;
using System.Diagnostics;
using System.Extensions.Core;
namespace Daybreak.Services.Api;
public sealed class DaybreakApiService(
IOptionsProvider optionsProvider,
IAttachedApiAccessor attachedApiAccessor,
IMDomainRegistrar mDomainRegistrar,
IStubInjector stubInjector,
INotificationService notificationService,
IHttpClient<ScopedApiContext> scopedApiClient,
IOptionsMonitor<DaybreakApiOptions> daybreakApiOptions,
ILogger<DaybreakApiService> logger,
ILogger<ScopedApiContext> scopedApiLogger)
: IDaybreakApiService
{
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 IStubInjector stubInjector = stubInjector.ThrowIfNull();
private readonly INotificationService notificationService = notificationService.ThrowIfNull();
private readonly IHttpClient<ScopedApiContext> scopedApiClient = scopedApiClient.ThrowIfNull();
private readonly IOptionsMonitor<DaybreakApiOptions> daybreakApiOptions = daybreakApiOptions.ThrowIfNull();
private readonly ILogger<DaybreakApiService> logger = logger.ThrowIfNull();
private readonly ILogger<ScopedApiContext> scopedApiLogger = scopedApiLogger.ThrowIfNull();
public string Name { get; } = "Daybreak API";
public string Description { get; } = "Daybreak API integration with Guild Wars. Gets injected into Guild Wars to enable extended functionality such as loading builds, character switching, and more";
public bool IsEnabled
{
get => this.daybreakApiOptions.CurrentValue.Enabled;
set
{
var options = this.daybreakApiOptions.CurrentValue;
options.Enabled = value;
this.optionsProvider.SaveOption(options);
}
}
public bool IsInstalled => true;
public bool IsVisible => true;
public bool CanCustomManage => false;
public bool CanUninstall => false;
public bool CanDisable => true;
public IProgressAsyncOperation<bool> PerformUninstallation(CancellationToken cancellationToken)
{
throw new NotImplementedException("DaybreakApi mod does not support manual uninstallation");
}
public IEnumerable<string> GetCustomArguments() => [];
public Task<bool> IsUpdateAvailable(CancellationToken cancellationToken) => Task.FromResult(false);
public Task<bool> PerformUpdate(CancellationToken cancellationToken)
{
throw new NotImplementedException("DaybreakApi mod does not support manual updates");
}
public IProgressAsyncOperation<bool> PerformInstallation(CancellationToken cancellationToken)
{
throw new NotImplementedException("DaybreakApi mod does not support manual installation");
}
public Task OnCustomManagement(CancellationToken cancellationToken)
{
throw new NotImplementedException("DaybreakApi mod does not support custom management");
}
public Task<ScopedApiContext?> AttachDaybreakApiContext(GuildWarsApplicationLaunchContext launchContext, ScopedApiContext apiContext, CancellationToken _)
{
if (this.attachedApiAccessor is AttachedApiAccessor accessor)
{
accessor.LaunchContext = launchContext;
accessor.ApiContext = apiContext;
}
return Task.FromResult<ScopedApiContext?>(apiContext);
}
public async Task<ScopedApiContext?> AttachDaybreakApiContext(GuildWarsApplicationLaunchContext launchContext, CancellationToken cancellationToken)
{
var apiContext = await this.GetDaybreakApiContext(launchContext.GuildWarsProcess, cancellationToken);
if (apiContext is not null)
{
return await this.AttachDaybreakApiContext(launchContext, apiContext, cancellationToken);
}
else
{
return apiContext;
}
}
public async Task<ScopedApiContext?> GetDaybreakApiContext(Process guildWarsProcess, CancellationToken cancellationToken)
{
var scopedLogger = this.logger.CreateScopedLogger();
if (guildWarsProcess.HasExited)
{
scopedLogger.LogWarning("Guild Wars process has exited");
return default;
}
var serviceName = DaybreakApiServiceName.Replace(ProcessIdPlaceholder, guildWarsProcess.Id.ToString());
var serviceUris = this.mDomainRegistrar.Resolve(serviceName);
var serviceUri = serviceUris?.Count > 0 ? serviceUris[0] : default;
if (serviceUri is null)
{
scopedLogger.LogWarning("Failed to find Daybreak API service by name {serviceName}", serviceName);
return default;
}
var uriBuilder = new UriBuilder(serviceUri)
{
Host = LocalHost
};
var apiContext = new DaybreakAPIContext(uriBuilder.Uri);
var scopedApiContext = new ScopedApiContext(this.scopedApiLogger, this.scopedApiClient, apiContext);
if (await scopedApiContext.IsAvailable(cancellationToken))
{
return scopedApiContext;
}
return default;
}
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 uriBuilder = new UriBuilder(uri)
{
Host = LocalHost
};
var apiContext = new DaybreakAPIContext(uriBuilder.Uri);
var scopedApiContext = new ScopedApiContext(this.scopedApiLogger, this.scopedApiClient, apiContext);
var response = await scopedApiContext.GetLoginInfo(cancellationToken);
if (loginCredentials.Username == response?.Email)
{
return scopedApiContext;
}
}
scopedLogger.LogWarning("Failed to find Daybreak API service by credentials for user {username}", loginCredentials?.Username ?? string.Empty);
return default;
}
public void RequestInstancesAnnouncement()
{
this.mDomainRegistrar.QueryAllServices();
}
public Task OnGuildWarsCreated(GuildWarsCreatedContext guildWarsCreatedContext, CancellationToken cancellationToken) =>
this.InjectWithStub(guildWarsCreatedContext.ApplicationLauncherContext, cancellationToken);
public Task OnGuildWarsStarted(GuildWarsStartedContext guildWarsStartedContext, CancellationToken cancellationToken) => Task.CompletedTask;
public Task OnGuildWarsStarting(GuildWarsStartingContext guildWarsStartingContext, CancellationToken cancellationToken) => Task.CompletedTask;
public Task OnGuildWarsStartingDisabled(GuildWarsStartingDisabledContext guildWarsStartingDisabledContext, CancellationToken cancellationToken) => Task.CompletedTask;
public Task<bool> ShouldRunAgain(GuildWarsRunningContext guildWarsRunningContext, CancellationToken cancellationToken)
{
if (!guildWarsRunningContext.LoadedModules.Contains(DaybreakApiName) &&
this.IsEnabled)
{
return Task.FromResult(true);
}
return Task.FromResult(false);
}
public Task OnGuildWarsRunning(GuildWarsRunningContext guildWarsRunningContext, CancellationToken cancellationToken)
=> this.InjectWithStub(guildWarsRunningContext.ApplicationLauncherContext, cancellationToken);
private async Task InjectWithStub(ApplicationLauncherContext context, CancellationToken cancellationToken)
{
var scopedLogger = this.logger.CreateScopedLogger();
var dllName =
Path.GetFullPath(
Path.Combine(
PathUtils.GetRootFolder(),
DaybreakApiName));
if (!File.Exists(dllName))
{
this.notificationService.NotifyError(
"Daybreak API Failure",
"Failed to inject with stub. Daybreak API dll not found",
expirationTime: DateTime.UtcNow + TimeSpan.FromSeconds(10));
scopedLogger.LogError("Failed to inject with stub. Daybreak API DLL not found at {dllPath}", dllName);
return;
}
scopedLogger.LogInformation("Injecting {dllName} into {processId}", dllName, context.Process.Id);
var result = await this.stubInjector.Inject(context.Process, dllName, EntryPoint, cancellationToken);
if (result < 0)
{
this.notificationService.NotifyError(
"Daybreak API Failure",
"Failed to inject with stub. Injection failed");
scopedLogger.LogError("Failed to inject with stub. Injection failed");
return;
}
var port = result;
if (port <= 0)
{
this.notificationService.NotifyError(
"Daybreak API Failure",
"Failed to start API. Non-success exit code");
scopedLogger.LogError($"Failed to start API. Exit code {port}");
return;
}
this.notificationService.NotifyInformation(
"Injected Daybreak API",
$"Daybreak API started on port {port}");
}
}