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.
This commit is contained in:
Pat Hartl 2026-05-26 19:59:06 -05:00
parent 5b3278685e
commit 68b112d6ce
5 changed files with 196 additions and 7 deletions

View file

@ -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<SDK.Models.Manifest.Game>(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();

View file

@ -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<bool> 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<SDK.Models.Manifest.Game>(installDirectory, gameId);
var redistributableManifest = await ManifestHelper.ReadAsync<Redistributable>(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<int>(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<int>();
}
}
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<Package> Redistributable_RunPackageScriptAsync(Script packageScript, Redistributable redistributable)
{
try

View file

@ -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;

View file

@ -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<string, string[]> 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()

View file

@ -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<string, string> = {
"# 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 {