317 lines
12 KiB
C#
317 lines
12 KiB
C#
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;
|
|
using LANCommander.SDK.Services;
|
|
|
|
namespace LANCommander.SDK
|
|
{
|
|
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)
|
|
{
|
|
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;
|
|
|
|
if (!server.UseShellExecute)
|
|
{
|
|
processStartInfo.RedirectStandardError = true;
|
|
processStartInfo.RedirectStandardOutput = true;
|
|
}
|
|
|
|
Process.StartInfo = processStartInfo;
|
|
|
|
if (OutputDataReceived != null && !processStartInfo.UseShellExecute)
|
|
Process.OutputDataReceived += OutputDataReceived;
|
|
|
|
if (OutputDataReceived != null && !processStartInfo.UseShellExecute)
|
|
Process.ErrorDataReceived += ErrorDataReceived;
|
|
|
|
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);
|
|
|
|
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)
|
|
.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)
|
|
{
|
|
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 =>
|
|
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))
|
|
{
|
|
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();
|
|
}
|
|
}
|
|
}
|