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 logger, LobbyClient lobbyClient) : IDisposable { private Process Process; private Dictionary Variables { get; set; } = new Dictionary(); 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 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(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> actionOverrides = null; if (!string.IsNullOrWhiteSpace(action?.OptionOverrides)) { try { actionOverrides = JsonSerializer.Deserialize>>(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(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(); 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(); } } }