From 68b112d6ce921219ebd9e41c68ddb897d05af894 Mon Sep 17 00:00:00 2001 From: Pat Hartl Date: Tue, 26 May 2026 19:59:06 -0500 Subject: [PATCH] Implement RunWrapper script execution for redistributables RunWrapper scripts were defined as a script type but never executed. This adds full execution support during game launch, with proper game running state tracking and cancellation/stop support including child process cleanup. --- LANCommander.SDK/Clients/GameClient.cs | 50 ++++++- .../Clients/ScriptClient.Redistributables.cs | 122 ++++++++++++++++++ .../PowerShell/PowerShellScript.cs | 12 ++ .../UI/Components/ScriptEditorDialog.razor | 3 +- .../PowerShellCompletionProvider.ts | 16 ++- 5 files changed, 196 insertions(+), 7 deletions(-) diff --git a/LANCommander.SDK/Clients/GameClient.cs b/LANCommander.SDK/Clients/GameClient.cs index 34446536..cc85ba99 100644 --- a/LANCommander.SDK/Clients/GameClient.cs +++ b/LANCommander.SDK/Clients/GameClient.cs @@ -2085,11 +2085,55 @@ namespace LANCommander.SDK.Services try { var cancellationTokenSource = new CancellationTokenSource(); - var task = context.ExecuteGameActionAsync(installDirectory, gameId, action, "", cancellationTokenSource.Token); - _running[gameId] = cancellationTokenSource; - await task; + #region Run Wrapper Scripts + bool runWrapperHandled = false; + + var gameManifest = await ManifestHelper.ReadAsync(installDirectory, gameId); + var resolvedAction = action ?? gameManifest.Actions.FirstOrDefault(a => a.IsPrimaryAction); + + if (resolvedAction != null && gameManifest.Redistributables != null) + { + var wrapperRedistributables = gameManifest.Redistributables + .Where(r => r.Scripts != null && r.Scripts.Any(s => s.Type == Enums.ScriptType.RunWrapper)) + .ToList(); + + if (wrapperRedistributables.Any()) + { + if (gameManifest.CustomFields != null && gameManifest.CustomFields.Any()) + { + foreach (var customField in gameManifest.CustomFields) + { + context.AddVariable(customField.Name, customField.Value); + } + } + + var executablePath = context.ExpandVariables(resolvedAction.Path, installDirectory); + var arguments = context.ExpandVariables(resolvedAction.Arguments, installDirectory, skipSlashes: true); + var workingDirectory = context.ExpandVariables(resolvedAction.WorkingDirectory, installDirectory); + + if (string.IsNullOrWhiteSpace(workingDirectory)) + workingDirectory = installDirectory; + + if (!string.IsNullOrWhiteSpace(args)) + arguments = string.IsNullOrWhiteSpace(arguments) ? args : arguments + " " + args; + + foreach (var redistributable in wrapperRedistributables) + { + runWrapperHandled = await scriptClient.Redistributable_RunRunWrapperScriptAsync(installDirectory, gameId, redistributable.Id, executablePath, arguments, workingDirectory, cancellationTokenSource.Token); + + if (runWrapperHandled) + break; + } + } + } + #endregion + + if (!runWrapperHandled) + { + await context.ExecuteGameActionAsync(installDirectory, gameId, action, args, cancellationTokenSource.Token); + } _running.Remove(gameId); cancellationTokenSource.Dispose(); diff --git a/LANCommander.SDK/Clients/ScriptClient.Redistributables.cs b/LANCommander.SDK/Clients/ScriptClient.Redistributables.cs index 37e729ac..e85d1b8a 100644 --- a/LANCommander.SDK/Clients/ScriptClient.Redistributables.cs +++ b/LANCommander.SDK/Clients/ScriptClient.Redistributables.cs @@ -4,6 +4,8 @@ using LANCommander.SDK.Models; using LANCommander.SDK.PowerShell; using Microsoft.Extensions.Logging; using System; +using System.Collections.Generic; +using System.Diagnostics; using System.IO; using System.Linq; using System.Threading; @@ -425,6 +427,126 @@ public partial class ScriptClient return result; } + public async Task Redistributable_RunRunWrapperScriptAsync(string installDirectory, Guid gameId, Guid redistributableId, string executablePath, string arguments, string workingDirectory, CancellationToken cancellationToken = default) + { + bool result = false; + + try + { + var gameManifest = await ManifestHelper.ReadAsync(installDirectory, gameId); + var redistributableManifest = await ManifestHelper.ReadAsync(installDirectory, redistributableId); + + var path = ScriptHelper.GetScriptFilePath(installDirectory, redistributableId, Enums.ScriptType.RunWrapper); + + using (var op = logger.BeginOperation("Executing run wrapper script")) + { + if (File.Exists(path)) + { + var script = powerShellScriptFactory.Create(Enums.ScriptType.RunWrapper); + + script.AddVariable("InstallDirectory", installDirectory); + script.AddVariable("GameManifest", gameManifest); + script.AddVariable("RedistributableManifest", redistributableManifest); + script.AddVariable("DefaultInstallDirectory", settingsProvider.CurrentValue.Games.InstallDirectories.FirstOrDefault()); + script.AddVariable("ServerAddress", connectionClient.GetServerAddress()); + script.AddVariable("ExecutablePath", executablePath); + script.AddVariable("Arguments", arguments); + script.AddVariable("WorkingDirectory", workingDirectory); + + try + { + op + .Enrich("InstallDirectory", installDirectory) + .Enrich("GameManifestPath", ManifestHelper.GetPath(installDirectory, gameId)) + .Enrich("RedistributableManifestPath", ManifestHelper.GetPath(installDirectory, redistributableId)) + .Enrich("ScriptPath", path) + .Enrich("ExecutablePath", executablePath) + .Enrich("GameTitle", gameManifest.Title) + .Enrich("GameId", gameManifest.Id) + .Enrich("RedistributableName", redistributableManifest.Name) + .Enrich("RedistributableId", redistributableManifest.Id); + } + catch (Exception ex) + { + logger?.LogError(ex, "Could not enrich logs"); + } + + if (gameManifest.CustomFields != null && gameManifest.CustomFields.Any()) + { + foreach (var customField in gameManifest.CustomFields) + { + script.AddVariable(customField.Name, customField.Value); + } + } + + script.UseWorkingDirectory(Path.Combine(GameClient.GetMetadataDirectoryPath(installDirectory, redistributableId))); + script.UseFile(path); + + if (Debug) + script.EnableDebug(); + + var handled = await RunScriptExternallyAsync(script); + + if (!handled) + { + // Snapshot existing process IDs so we can identify child processes on stop + var existingPids = new HashSet(Process.GetProcesses().Select(p => p.Id)); + + using (var registration = cancellationToken.Register(() => + { + logger?.LogTrace("Stopping run wrapper script due to cancellation"); + script.Stop(); + + // Kill any processes spawned during script execution + try + { + var currentProcesses = Process.GetProcesses(); + + foreach (var proc in currentProcesses) + { + try + { + if (!existingPids.Contains(proc.Id) && !proc.HasExited) + { + logger?.LogTrace("Killing child process {ProcessId} ({ProcessName})", proc.Id, proc.ProcessName); + proc.Kill(true); + } + } + catch { } + finally + { + proc.Dispose(); + } + } + } + catch (Exception ex) + { + logger?.LogWarning(ex, "Error killing child processes after run wrapper cancellation"); + } + })) + { + await script.ExecuteAsync(); + } + } + + result = true; + } + else + { + logger?.LogTrace("No run wrapper script found"); + } + + op.Complete(); + } + } + catch (Exception ex) + { + logger?.LogError(ex, "Ran into an unexpected error when attempting to run a Run Wrapper script"); + } + + return result; + } + public async Task Redistributable_RunPackageScriptAsync(Script packageScript, Redistributable redistributable) { try diff --git a/LANCommander.SDK/PowerShell/PowerShellScript.cs b/LANCommander.SDK/PowerShell/PowerShellScript.cs index 1f38a3c6..4151457d 100644 --- a/LANCommander.SDK/PowerShell/PowerShellScript.cs +++ b/LANCommander.SDK/PowerShell/PowerShellScript.cs @@ -144,6 +144,18 @@ namespace LANCommander.SDK.PowerShell return this; } + public void Stop() + { + try + { + Context?.Stop(); + } + catch (Exception ex) + { + Logger?.LogWarning(ex, "Error stopping PowerShell pipeline"); + } + } + public PowerShellScript AsAdmin() { RunAsAdmin = true; diff --git a/LANCommander.Server/UI/Components/ScriptEditorDialog.razor b/LANCommander.Server/UI/Components/ScriptEditorDialog.razor index 39854f7f..b6bded98 100644 --- a/LANCommander.Server/UI/Components/ScriptEditorDialog.razor +++ b/LANCommander.Server/UI/Components/ScriptEditorDialog.razor @@ -136,7 +136,7 @@ (Options.ToolId.HasValue && Options.ToolId != Guid.Empty) || (Options.RedistributableId.HasValue && Options.RedistributableId != Guid.Empty); - static readonly string[] GameClientScriptTypes = ["Install", "Uninstall", "BeforeStart", "AfterStop", "NameChange", "KeyChange", "SaveUpload", "SaveDownload", "DetectInstall"]; + static readonly string[] GameClientScriptTypes = ["Install", "Uninstall", "BeforeStart", "AfterStop", "NameChange", "KeyChange", "SaveUpload", "SaveDownload", "DetectInstall", "RunWrapper"]; static readonly Dictionary VariablesByScriptType = new() { @@ -154,6 +154,7 @@ ["GameStopped"] = ["$WorkingDirectory", "$Server", "$Game", "$User", "$ServerId", "$ServerName", "$ServerHost", "$ServerPort", "$GameTitle", "$GameId"], ["UserRegistration"] = ["$WorkingDirectory", "$User"], ["UserLogin"] = ["$WorkingDirectory", "$User"], + ["RunWrapper"] = ["$InstallDirectory", "$WorkingDirectory", "$ServerAddress", "$DefaultInstallDirectory", "$GameManifest", "$RedistributableManifest", "$ExecutablePath", "$Arguments"], }; void UpdateVariables() diff --git a/LANCommander.UI/Components/MonacoCodeEditor/PowerShellCompletionProvider.ts b/LANCommander.UI/Components/MonacoCodeEditor/PowerShellCompletionProvider.ts index 684300f5..f797e6a4 100644 --- a/LANCommander.UI/Components/MonacoCodeEditor/PowerShellCompletionProvider.ts +++ b/LANCommander.UI/Components/MonacoCodeEditor/PowerShellCompletionProvider.ts @@ -16,14 +16,14 @@ interface VariableDefinition { } // Variables available across all client-side game script types -const gameClientScriptTypes = ["Install", "Uninstall", "BeforeStart", "AfterStop", "NameChange", "KeyChange", "SaveUpload", "SaveDownload", "DetectInstall"]; +const gameClientScriptTypes = ["Install", "Uninstall", "BeforeStart", "AfterStop", "NameChange", "KeyChange", "SaveUpload", "SaveDownload", "DetectInstall", "RunWrapper"]; const variables: VariableDefinition[] = [ { name: "$InstallDirectory", type: "string", description: "Root install directory for the game", scriptTypes: gameClientScriptTypes }, { name: "$WorkingDirectory", type: "string", description: "Current working directory for the script" }, { name: "$ServerAddress", type: "string", description: "Address of the LANCommander server", scriptTypes: gameClientScriptTypes }, { name: "$DefaultInstallDirectory", type: "string", description: "Default installation directory", scriptTypes: gameClientScriptTypes }, - { name: "$GameManifest", type: "GameManifest", description: "The game's manifest object containing metadata such as title, ID, sort title, description, notes, and related collections", scriptTypes: ["Install", "Uninstall", "BeforeStart", "AfterStop", "NameChange", "KeyChange", "SaveUpload", "SaveDownload", "DetectInstall"] }, + { name: "$GameManifest", type: "GameManifest", description: "The game's manifest object containing metadata such as title, ID, sort title, description, notes, and related collections", scriptTypes: ["Install", "Uninstall", "BeforeStart", "AfterStop", "NameChange", "KeyChange", "SaveUpload", "SaveDownload", "DetectInstall", "RunWrapper"] }, { name: "$PlayerAlias", type: "string", description: "Current player's alias/display name", scriptTypes: ["BeforeStart", "AfterStop"] }, { name: "$NewPlayerAlias", type: "string", description: "New player alias (name change scripts)", scriptTypes: ["NameChange"] }, { name: "$OldPlayerAlias", type: "string", description: "Previous player alias (name change scripts)", scriptTypes: ["NameChange"] }, @@ -34,7 +34,9 @@ const variables: VariableDefinition[] = [ { name: "$User", type: "User", description: "User model object with properties: Id, UserName, and Alias", scriptTypes: ["GameStarted", "GameStopped", "UserRegistration", "UserLogin"] }, { name: "$ToolManifest", type: "ToolManifest", description: "Tool manifest object containing metadata for the current tool", scriptTypes: ["Install", "BeforeStart", "AfterStop", "DetectInstall"] }, { name: "$Tool", type: "Tool", description: "Tool model object with properties: Id, Name, and Description", scriptTypes: ["Package"] }, - { name: "$RedistributableManifest", type: "RedistributableManifest", description: "Redistributable manifest object containing metadata for the current redistributable", scriptTypes: ["Install", "BeforeStart", "AfterStop", "NameChange", "DetectInstall"] }, + { name: "$RedistributableManifest", type: "RedistributableManifest", description: "Redistributable manifest object containing metadata for the current redistributable", scriptTypes: ["Install", "BeforeStart", "AfterStop", "NameChange", "DetectInstall", "RunWrapper"] }, + { name: "$ExecutablePath", type: "string", description: "Resolved path to the game executable", scriptTypes: ["RunWrapper"] }, + { name: "$Arguments", type: "string", description: "Resolved command-line arguments for the executable", scriptTypes: ["RunWrapper"] }, { name: "$Redistributable", type: "Redistributable", description: "Redistributable model object with properties: Id, Name, and Description", scriptTypes: ["Package"] }, { name: "$DisplayWidth", type: "string", description: "Primary display width in pixels", scriptTypes: gameClientScriptTypes }, { name: "$DisplayHeight", type: "string", description: "Primary display height in pixels", scriptTypes: gameClientScriptTypes }, @@ -455,6 +457,14 @@ const scriptTemplates: Record = { "# Runs after the game process stops.", "", ].join("\n"), + RunWrapper: [ + "# Run Wrapper Script", + "# Controls how the game executable is launched.", + "# This script runs instead of the normal process launch.", + "", + "# Launch the game", + "Start-Process -FilePath $ExecutablePath -ArgumentList $Arguments -WorkingDirectory $WorkingDirectory -Wait", + ].join("\n"), }; export function getScriptTemplate(scriptType: string): string | null {