LANCommander/LANCommander.SDK/ProcessExecutionContext.cs

318 lines
12 KiB
C#
Raw Permalink Normal View History

using LANCommander.SDK.Extensions;
using LANCommander.SDK.Helpers;
using LANCommander.SDK.Models;
using Microsoft.Extensions.Logging;
using System;
using System.Collections.Generic;
using System.Diagnostics;
using System.Linq;
using System.Text.Json;
using YamlDotNet.Serialization;
using YamlDotNet.Serialization.NamingConventions;
using System.Threading;
using System.Threading.Tasks;
using LANCommander.SDK.Enums;
Implement SDK using dependency injection - API requests are now made through the ApiRequestBuilder with DI supplied via the ApiRequestFactory singleton - Reliance on RestSharp and WebClient has been removed in favor of HttpClient - Auth token is now being tracked by the ITokenProvider - Network information (MAC address, broadcast addresses, IP address) is now supplied with the INetworkInformationProvider singleton - Connection state is now being maintained by the ConnectionService, with a reliance on RPC (SignalR websocket) reporting actual connection state without relying on pings - All download streams are now provided as a TrackableStream - Singleton Client class has been removed. All usage of the SDK should happen via Dependency Injection This is just a base, non-functional refactor of the SDK to use DI. The following changes to the rest of the codebase need to be made: - Usage of the SDK client in the launcher needs to be replaced in favor of injecting SDK services - PowerShell cmdlets need to be able to have services injected. Most likely a separate scope will have to be opened up per PS runtime? - Usage of the SDK client in the server needs to be replaced in favor of injecting SDK services. This should be minimal and should actually provide benefit when it comes to executing client-like features (scripts mostly) without needing a fully configured client that maintains connection state. - Configuration of the client needs to be implemented using ILANCommanderConfiguration
2025-09-22 00:29:51 -05:00
using LANCommander.SDK.Services;
namespace LANCommander.SDK
{
Implement SDK using dependency injection - API requests are now made through the ApiRequestBuilder with DI supplied via the ApiRequestFactory singleton - Reliance on RestSharp and WebClient has been removed in favor of HttpClient - Auth token is now being tracked by the ITokenProvider - Network information (MAC address, broadcast addresses, IP address) is now supplied with the INetworkInformationProvider singleton - Connection state is now being maintained by the ConnectionService, with a reliance on RPC (SignalR websocket) reporting actual connection state without relying on pings - All download streams are now provided as a TrackableStream - Singleton Client class has been removed. All usage of the SDK should happen via Dependency Injection This is just a base, non-functional refactor of the SDK to use DI. The following changes to the rest of the codebase need to be made: - Usage of the SDK client in the launcher needs to be replaced in favor of injecting SDK services - PowerShell cmdlets need to be able to have services injected. Most likely a separate scope will have to be opened up per PS runtime? - Usage of the SDK client in the server needs to be replaced in favor of injecting SDK services. This should be minimal and should actually provide benefit when it comes to executing client-like features (scripts mostly) without needing a fully configured client that maintains connection state. - Configuration of the client needs to be implemented using ILANCommanderConfiguration
2025-09-22 00:29:51 -05:00
public class ProcessExecutionContext(
ILogger<ProcessExecutionContext> logger,
LobbyClient lobbyClient) : IDisposable
{
private Process Process;
private Dictionary<string, string> Variables { get; set; } = new Dictionary<string, string>();
public event DataReceivedEventHandler? OutputDataReceived;
public event DataReceivedEventHandler? ErrorDataReceived;
public void AddVariable(string key, string value)
{
Variables[key] = value;
}
public string ExpandVariables(string input, string workingDirectory, Dictionary<string, string> additionalVariables = null, bool skipSlashes = false)
{
try
{
if (input == null)
return input;
foreach (var variable in Variables)
{
input = input.Replace($"{{{variable.Key}}}", variable.Value);
}
if (additionalVariables != null)
foreach (var variable in additionalVariables)
{
input = input.Replace($"{{{variable.Key}}}", variable.Value);
}
return input.ExpandEnvironmentVariables(workingDirectory, skipSlashes);
}
catch (Exception ex)
{
Implement SDK using dependency injection - API requests are now made through the ApiRequestBuilder with DI supplied via the ApiRequestFactory singleton - Reliance on RestSharp and WebClient has been removed in favor of HttpClient - Auth token is now being tracked by the ITokenProvider - Network information (MAC address, broadcast addresses, IP address) is now supplied with the INetworkInformationProvider singleton - Connection state is now being maintained by the ConnectionService, with a reliance on RPC (SignalR websocket) reporting actual connection state without relying on pings - All download streams are now provided as a TrackableStream - Singleton Client class has been removed. All usage of the SDK should happen via Dependency Injection This is just a base, non-functional refactor of the SDK to use DI. The following changes to the rest of the codebase need to be made: - Usage of the SDK client in the launcher needs to be replaced in favor of injecting SDK services - PowerShell cmdlets need to be able to have services injected. Most likely a separate scope will have to be opened up per PS runtime? - Usage of the SDK client in the server needs to be replaced in favor of injecting SDK services. This should be minimal and should actually provide benefit when it comes to executing client-like features (scripts mostly) without needing a fully configured client that maintains connection state. - Configuration of the client needs to be implemented using ILANCommanderConfiguration
2025-09-22 00:29:51 -05:00
logger?.LogError(ex, "Could not expand runtime variables");
return input;
}
}
public Process GetProcess()
{
return Process;
}
public Task ExecuteServerAsync(Models.Server server,
CancellationTokenSource cancellationTokenSource = default)
{
Process = new Process();
Process.EnableRaisingEvents = true;
var processStartInfo = new ProcessStartInfo();
processStartInfo.Arguments = ExpandVariables(server.Arguments, server.WorkingDirectory, skipSlashes: true);
processStartInfo.FileName = ExpandVariables(server.Path, server.WorkingDirectory);
processStartInfo.WorkingDirectory = server.WorkingDirectory;
processStartInfo.UseShellExecute = server.UseShellExecute;
2025-02-28 22:58:28 -06:00
if (!server.UseShellExecute)
{
processStartInfo.RedirectStandardError = true;
processStartInfo.RedirectStandardOutput = true;
}
Process.StartInfo = processStartInfo;
2025-02-28 22:58:28 -06:00
if (OutputDataReceived != null && !processStartInfo.UseShellExecute)
Process.OutputDataReceived += OutputDataReceived;
2025-02-28 22:58:28 -06:00
if (OutputDataReceived != null && !processStartInfo.UseShellExecute)
Process.ErrorDataReceived += ErrorDataReceived;
Implement SDK using dependency injection - API requests are now made through the ApiRequestBuilder with DI supplied via the ApiRequestFactory singleton - Reliance on RestSharp and WebClient has been removed in favor of HttpClient - Auth token is now being tracked by the ITokenProvider - Network information (MAC address, broadcast addresses, IP address) is now supplied with the INetworkInformationProvider singleton - Connection state is now being maintained by the ConnectionService, with a reliance on RPC (SignalR websocket) reporting actual connection state without relying on pings - All download streams are now provided as a TrackableStream - Singleton Client class has been removed. All usage of the SDK should happen via Dependency Injection This is just a base, non-functional refactor of the SDK to use DI. The following changes to the rest of the codebase need to be made: - Usage of the SDK client in the launcher needs to be replaced in favor of injecting SDK services - PowerShell cmdlets need to be able to have services injected. Most likely a separate scope will have to be opened up per PS runtime? - Usage of the SDK client in the server needs to be replaced in favor of injecting SDK services. This should be minimal and should actually provide benefit when it comes to executing client-like features (scripts mostly) without needing a fully configured client that maintains connection state. - Configuration of the client needs to be implemented using ILANCommanderConfiguration
2025-09-22 00:29:51 -05:00
logger?.LogTrace("Running server executable");
logger?.LogTrace("Arguments: {Arguments}", Process.StartInfo.Arguments);
logger?.LogTrace("File Name: {FileName}", Process.StartInfo.FileName);
logger?.LogTrace("Working Directory: {WorkingDirectory}", Process.StartInfo.WorkingDirectory);
bool exited = false;
Process.Start();
Process.Exited += (sender, args) =>
{
if (cancellationTokenSource?.Token.CanBeCanceled ?? false)
cancellationTokenSource.Cancel();
};
if (processStartInfo.RedirectStandardError)
Process.BeginErrorReadLine();
if (processStartInfo.RedirectStandardOutput)
Process.BeginOutputReadLine();
cancellationTokenSource?.Token.WaitHandle.WaitOne();
Process.Kill(server.ProcessTerminationMethod);
return Task.CompletedTask;
}
public async Task ExecuteGameActionAsync(string installDirectory, Guid gameId, Models.Manifest.Action action, string args = "", CancellationToken cancellationToken = default)
{
var manifest = await ManifestHelper.ReadAsync<Models.Manifest.Game>(installDirectory, gameId);
if (action == null)
action = manifest.Actions.FirstOrDefault(a => a.IsPrimaryAction);
if (manifest.CustomFields != null && manifest.CustomFields.Any())
{
foreach (var customField in manifest.CustomFields)
{
AddVariable(customField.Name, customField.Value);
}
}
Process = new Process();
Process.StartInfo.Arguments = ExpandVariables(action.Arguments, installDirectory, skipSlashes: true);
Process.StartInfo.FileName = ExpandVariables(action.Path, installDirectory);
Process.StartInfo.WorkingDirectory = ExpandVariables(action.WorkingDirectory, installDirectory);
Process.StartInfo.UseShellExecute = true;
if (OutputDataReceived != null)
Process.OutputDataReceived += OutputDataReceived;
if (ErrorDataReceived != null)
Process.ErrorDataReceived += ErrorDataReceived;
if (String.IsNullOrWhiteSpace(action.WorkingDirectory))
Process.StartInfo.WorkingDirectory = installDirectory;
if (!String.IsNullOrWhiteSpace(args))
Process.StartInfo.Arguments += " " + args;
ApplyCompatibilityOptions(manifest, action, installDirectory);
Implement SDK using dependency injection - API requests are now made through the ApiRequestBuilder with DI supplied via the ApiRequestFactory singleton - Reliance on RestSharp and WebClient has been removed in favor of HttpClient - Auth token is now being tracked by the ITokenProvider - Network information (MAC address, broadcast addresses, IP address) is now supplied with the INetworkInformationProvider singleton - Connection state is now being maintained by the ConnectionService, with a reliance on RPC (SignalR websocket) reporting actual connection state without relying on pings - All download streams are now provided as a TrackableStream - Singleton Client class has been removed. All usage of the SDK should happen via Dependency Injection This is just a base, non-functional refactor of the SDK to use DI. The following changes to the rest of the codebase need to be made: - Usage of the SDK client in the launcher needs to be replaced in favor of injecting SDK services - PowerShell cmdlets need to be able to have services injected. Most likely a separate scope will have to be opened up per PS runtime? - Usage of the SDK client in the server needs to be replaced in favor of injecting SDK services. This should be minimal and should actually provide benefit when it comes to executing client-like features (scripts mostly) without needing a fully configured client that maintains connection state. - Configuration of the client needs to be implemented using ILANCommanderConfiguration
2025-09-22 00:29:51 -05:00
logger?.LogTrace("Running game executable");
logger?.LogTrace("Arguments: {Arguments}", Process.StartInfo.Arguments);
logger?.LogTrace("File Name: {FileName}", Process.StartInfo.FileName);
logger?.LogTrace("Working Directory: {WorkingDirectory}", Process.StartInfo.WorkingDirectory);
logger?.LogTrace("Manifest Path: {ManifestPath}", ManifestHelper.GetPath(installDirectory, gameId));
bool exited = false;
Process.Start();
await Process.WaitForAllExitAsync(cancellationToken);
}
private void ApplyCompatibilityOptions(Models.Manifest.Game manifest, Models.Manifest.Action action, string installDirectory)
{
if (manifest.Redistributables == null)
return;
var shimRedistributables = manifest.Redistributables
.Where(r => !string.IsNullOrWhiteSpace(r.OptionSchema))
.ToList();
if (!shimRedistributables.Any())
return;
// Parse per-action overrides
Dictionary<Guid, Dictionary<string, string>> actionOverrides = null;
if (!string.IsNullOrWhiteSpace(action?.OptionOverrides))
{
try
{
actionOverrides = JsonSerializer.Deserialize<Dictionary<Guid, Dictionary<string, string>>>(action.OptionOverrides);
}
catch (Exception ex)
{
logger?.LogWarning(ex, "Could not parse action option overrides");
}
}
foreach (var redistributable in shimRedistributables)
{
OptionSchema schema;
try
{
var deserializer = new DeserializerBuilder()
.WithNamingConvention(PascalCaseNamingConvention.Instance)
2026-05-17 01:21:05 -05:00
.WithTypeConverter(new OptionChoiceYamlConverter())
.IgnoreUnmatchedProperties()
.Build();
schema = deserializer.Deserialize<OptionSchema>(redistributable.OptionSchema);
}
catch (Exception ex)
{
logger?.LogWarning(ex, "Could not parse option schema for redistributable {Name}", redistributable.Name);
continue;
}
if (schema == null)
continue;
// Flatten nested options into dot-notation keys
var flatOptions = schema.GetFlattenedOptions();
// Resolve option values: defaults → per-game → per-action overrides
var resolvedOptions = new Dictionary<string, string>();
foreach (var kvp in flatOptions)
{
2026-05-18 22:04:03 +00:00
var defaultValue = kvp.Value.GetDefaultAsString();
if (!string.IsNullOrWhiteSpace(defaultValue))
resolvedOptions[kvp.Key] = defaultValue;
}
// Apply per-game values
if (redistributable.Options != null)
{
foreach (var kvp in redistributable.Options)
{
resolvedOptions[kvp.Key] = kvp.Value;
}
}
// Apply per-action overrides
if (actionOverrides != null && actionOverrides.TryGetValue(redistributable.Id, out var overrides))
{
foreach (var kvp in overrides)
{
resolvedOptions[kvp.Key] = kvp.Value;
}
}
// Set environment variables for options that define envVar
{
bool hasEnvVars = flatOptions.Any(kvp =>
2026-05-18 22:04:03 +00:00
kvp.Value.IsEnvironmentVariable && !kvp.Value.IsList && resolvedOptions.ContainsKey(kvp.Key));
if (hasEnvVars || !string.IsNullOrWhiteSpace(schema.CommandTemplate))
{
Process.StartInfo.UseShellExecute = false;
}
foreach (var kvp in flatOptions.Where(kvp => kvp.Value.IsEnvironmentVariable))
{
2026-05-18 22:04:03 +00:00
if (kvp.Value.IsList)
{
logger?.LogTrace("Skipping env-var assignment for list option {Key} — read it via Get-RedistributableOptions instead", kvp.Key);
continue;
}
if (resolvedOptions.TryGetValue(kvp.Key, out var value) && !string.IsNullOrWhiteSpace(value))
{
// Use the leaf key name as the environment variable name
var envVarName = kvp.Key.Contains(".") ? kvp.Key.Substring(kvp.Key.LastIndexOf('.') + 1) : kvp.Key;
Process.StartInfo.EnvironmentVariables[envVarName] = ExpandVariables(value, installDirectory);
logger?.LogTrace("Set environment variable {EnvVar}={Value}", envVarName, value);
}
}
}
// Apply command template
if (!string.IsNullOrWhiteSpace(schema.CommandTemplate))
{
var originalExe = Process.StartInfo.FileName;
var originalArgs = Process.StartInfo.Arguments;
var expandedTemplate = ExpandVariables(schema.CommandTemplate, installDirectory);
expandedTemplate = expandedTemplate.Replace("{exe}", originalExe).Replace("{args}", originalArgs);
// Split template into command and arguments
var parts = expandedTemplate.Split(new[] { ' ' }, 2);
Process.StartInfo.FileName = parts[0];
Process.StartInfo.Arguments = parts.Length > 1 ? parts[1] : string.Empty;
logger?.LogTrace("Applied compatibility template: {FileName} {Arguments}", Process.StartInfo.FileName, Process.StartInfo.Arguments);
}
}
}
public void Dispose()
{
try
{
if (Process != null)
{
if (!Process.HasExited)
Process.Close();
Process.Dispose();
}
}
catch { }
lobbyClient.ReleaseSteam();
}
}
}