Implementation of script debugger component with RPC via SignalR

This commit is contained in:
Pat Hartl 2025-12-10 20:55:38 -06:00
parent f242f66813
commit 17fa802c19
19 changed files with 516 additions and 62 deletions

View file

@ -6,31 +6,40 @@ namespace LANCommander.Launcher.Services.PowerShell;
public class ScriptDebugger : IScriptDebugger
{
public Func<System.Management.Automation.PowerShell, Task> OnDebugStart;
public Func<System.Management.Automation.PowerShell, Task> OnDebugBreak;
public Func<System.Management.Automation.PowerShell, Task> OnDebugEnd;
public Func<LogLevel, string, Task> OnOutput;
public Func<IScriptDebugContext, Task>? OnDebugStart;
public Func<IScriptDebugContext, Task>? OnDebugBreak;
public Func<IScriptDebugContext, Task>? OnDebugEnd;
public Func<LogLevel, string, Task>? OnOutput;
private static readonly Regex TokenRegex = new(@"\{[^}]+\}", RegexOptions.Compiled);
public async Task StartAsync(System.Management.Automation.PowerShell ps)
public Task StartAsync(IScriptDebugContext context)
{
await OnDebugStart?.Invoke(ps)!;
return OnDebugStart is null
? Task.CompletedTask
: OnDebugStart(context);
}
public async Task EndAsync(System.Management.Automation.PowerShell ps)
public Task EndAsync(IScriptDebugContext context)
{
await OnDebugEnd?.Invoke(ps)!;
return OnDebugEnd is null
? Task.CompletedTask
: OnDebugEnd(context);
}
public async Task BreakAsync(System.Management.Automation.PowerShell ps)
public Task BreakAsync(IScriptDebugContext context)
{
await OnDebugBreak?.Invoke(ps)!;
return OnDebugBreak is null
? Task.CompletedTask
: OnDebugBreak(context);
}
public async Task OutputAsync(LogLevel level, string message, params object[] args)
public Task OutputAsync(IScriptDebugContext context, LogLevel level, string message, params object[] args)
{
await OnOutput?.Invoke(level, Format(message, args))!;
if (OnOutput is null)
return Task.CompletedTask;
return OnOutput(level, Format(message, args));
}
private string Format(string template, object?[] args)

View file

@ -61,7 +61,7 @@
}
};
Debugger.OnDebugBreak = async (ps) =>
Debugger.OnDebugBreak = async (context) =>
{
while (true)
{
@ -73,10 +73,7 @@
if (input.StartsWith('$'))
input = "Write-Host " + input;
ps.Commands.Clear();
ps.AddScript(input);
await ps.InvokeAsync();
await context.ExecuteAsync(input);
}
Visible = false;

View file

@ -0,0 +1,10 @@
using System;
using System.Threading.Tasks;
namespace LANCommander.SDK.PowerShell;
public interface IScriptDebugContext
{
Guid SessionId { get; set; }
Task ExecuteAsync(string script);
}

View file

@ -5,8 +5,8 @@ namespace LANCommander.SDK.PowerShell;
public interface IScriptDebugger
{
Task StartAsync(System.Management.Automation.PowerShell ps);
Task EndAsync(System.Management.Automation.PowerShell ps);
Task BreakAsync(System.Management.Automation.PowerShell ps);
Task OutputAsync(LogLevel level, string message, params object[] args);
Task StartAsync(IScriptDebugContext context);
Task EndAsync(IScriptDebugContext context);
Task BreakAsync(IScriptDebugContext context);
Task OutputAsync(IScriptDebugContext context, LogLevel level, string message, params object[] args);
}

View file

@ -0,0 +1,17 @@
using System;
using System.Threading.Tasks;
namespace LANCommander.SDK.PowerShell;
public class PowerShellDebugContext(System.Management.Automation.PowerShell ps) : IScriptDebugContext
{
public Guid SessionId { get; set; } = Guid.NewGuid();
public async Task ExecuteAsync(string script)
{
ps.Commands.Clear();
ps.AddScript(script);
await ps.InvokeAsync().ConfigureAwait(false);
}
}

View file

@ -45,6 +45,7 @@ namespace LANCommander.SDK.PowerShell
private ILogger<PowerShellScript> Logger { get; set; }
private IEnumerable<IScriptDebugger> Debuggers { get; set; }
private System.Management.Automation.PowerShell Context { get; set; }
private IScriptDebugContext DebugContext { get; set; }
private const string Logo = @"
__ ___ _ _______ __
@ -187,9 +188,11 @@ namespace LANCommander.SDK.PowerShell
Context.Runspace = runspace;
DebugContext = new PowerShellDebugContext(Context);
await DebugAsync(async dbg =>
{
await dbg.StartAsync(Context);
await dbg.StartAsync(DebugContext);
});
Context.AddScript("Write-Host $Logo");
@ -222,7 +225,7 @@ namespace LANCommander.SDK.PowerShell
await DebugAsync(async dbg =>
{
await dbg.BreakAsync(Context);
await dbg.BreakAsync(DebugContext);
});
var returnValue = Context.Runspace.SessionStateProxy.PSVariable.GetValue("Return");
@ -265,8 +268,8 @@ namespace LANCommander.SDK.PowerShell
await DebugAsync(async dbg =>
{
await dbg.OutputAsync(LogLevel.Error, "{InvocationName} : {ExceptionMessage}", record.InvocationInfo.InvocationName, record.Exception.Message);
await dbg.OutputAsync(LogLevel.Error, record.InvocationInfo.PositionMessage);
await dbg.OutputAsync(DebugContext, LogLevel.Error, "{InvocationName} : {ExceptionMessage}", record.InvocationInfo.InvocationName, record.Exception.Message);
await dbg.OutputAsync(DebugContext, LogLevel.Error, record.InvocationInfo.PositionMessage);
});
}
@ -276,7 +279,7 @@ namespace LANCommander.SDK.PowerShell
await DebugAsync(async dbg =>
{
await dbg.OutputAsync(LogLevel.Warning, record.Message);
await dbg.OutputAsync(DebugContext, LogLevel.Warning, record.Message);
});
}
@ -286,7 +289,7 @@ namespace LANCommander.SDK.PowerShell
await DebugAsync(async dbg =>
{
await dbg.OutputAsync(LogLevel.Debug, record.Message);
await dbg.OutputAsync(DebugContext, LogLevel.Debug, record.Message);
});
}
@ -296,7 +299,7 @@ namespace LANCommander.SDK.PowerShell
await DebugAsync(async dbg =>
{
await dbg.OutputAsync(LogLevel.Trace, record.Message);
await dbg.OutputAsync(DebugContext, LogLevel.Trace, record.Message);
});
}
@ -306,7 +309,7 @@ namespace LANCommander.SDK.PowerShell
await DebugAsync(async dbg =>
{
await dbg.OutputAsync(LogLevel.Information, (record.MessageData as HostInformationMessage).Message);
await dbg.OutputAsync(DebugContext, LogLevel.Information, (record.MessageData as HostInformationMessage).Message);
});
}

View file

@ -0,0 +1,12 @@
using System.Threading.Tasks;
using Microsoft.Extensions.Logging;
namespace LANCommander.SDK.PowerShell.Rpc;
public interface IScriptDebuggerClient
{
Task Start(IScriptDebugContext context);
Task End(IScriptDebugContext context);
Task Break(IScriptDebugContext context);
Task Output(IScriptDebugContext context, LogLevel level, string message);
}

View file

@ -0,0 +1,10 @@
using System;
using System.Threading.Tasks;
namespace LANCommander.SDK.PowerShell.Rpc;
public interface IScriptDebuggerHub
{
Task DebugPackageScript(Guid gameId);
Task SendInput(Guid sessionId, string input);
}

View file

@ -1,13 +0,0 @@
using System;
using System.Threading.Tasks;
namespace LANCommander.SDK.Rpc.Server;
public partial interface IRpcHub
{
Task Server_GetStatusAsync(Guid serverId);
Task Server_UpdateStatusAsync(Guid serverId);
Task Server_StartAsync(Guid serverId);
Task Server_StopAsync(Guid serverId);
Task Server_LogAsync(Guid serverId, string message);
}

View file

@ -15,7 +15,6 @@ public class RpcClient(IRpcSubscriber subscriber)
IRpcSubscriber _subscriber = subscriber;
public RpcChatClient Chat => new(_subscriber);
public RpcServerClient Servers => new(_subscriber);
public bool IsConnected => _subscriber.IsConnectedAsync().Result;
@ -60,22 +59,4 @@ public class RpcChatClient(IRpcSubscriber subscriber)
public async Task<IEnumerable<User>> GetUsersAsync()
=> await RpcClient.Hub.Chat_GetUsersAsync();
}
public class RpcServerClient(IRpcSubscriber subscriber)
{
public async Task GetStatusAsync(Guid serverId)
=> await RpcClient.Hub.Server_GetStatusAsync(serverId);
public async Task UpdateStatusAsync(Guid serverId)
=> await RpcClient.Hub.Server_UpdateStatusAsync(serverId);
public async Task StartAsync(Guid serverId)
=> await RpcClient.Hub.Server_StartAsync(serverId);
public async Task StopAsync(Guid serverId)
=> await RpcClient.Hub.Server_StopAsync(serverId);
public async Task LogAsync(Guid serverId, string message)
=> await RpcClient.Hub.Server_LogAsync(serverId, message);
}

View file

@ -1,11 +1,13 @@
using LANCommander.SDK;
using LANCommander.SDK.Interceptors;
using LANCommander.SDK.Models;
using LANCommander.SDK.PowerShell;
using LANCommander.SDK.Services;
using LANCommander.Server.Services.Abstractions;
using LANCommander.Server.Services.Factories;
using LANCommander.Server.Services.Interceptors;
using LANCommander.Server.Services.MediaGrabbers;
using LANCommander.Server.Services.PowerShell;
using LANCommander.Server.Services.ServerEngines;
using Microsoft.Extensions.DependencyInjection;
@ -64,6 +66,10 @@ public static class IServiceCollectionExtensions
services.AddSingleton<DockerServerEngine>();
services.AddSingleton<IServerEngine>(provider => provider.GetService<DockerServerEngine>());
services.AddSingleton<ScriptDebugger>();
services.AddSingleton<IScriptDebugger>(sp =>
sp.GetRequiredService<ScriptDebugger>());
services.AddSingleton<IPXRelayService>();
services.AddSingleton<IBeaconMessageInterceptor, BeaconMessageInterceptor>();

View file

@ -0,0 +1,79 @@
using System.Text.RegularExpressions;
using LANCommander.SDK.PowerShell;
using Microsoft.Extensions.Logging;
namespace LANCommander.Server.Services.PowerShell;
public class ScriptDebugger : IScriptDebugger
{
public Func<IScriptDebugContext, Task>? OnStart;
public Func<IScriptDebugContext, Task>? OnBreak;
public Func<IScriptDebugContext, Task>? OnEnd;
public Func<IScriptDebugContext, LogLevel, string, Task>? OnOutput;
private IDictionary<Guid, IScriptDebugContext?> _contexts = new Dictionary<Guid, IScriptDebugContext?>();
private static readonly Regex TokenRegex = new(@"\{[^}]+\}", RegexOptions.Compiled);
public Guid CreateSession()
{
var sessionId = Guid.NewGuid();
_contexts.Add(sessionId, null);
return sessionId;
}
public Task StartAsync(IScriptDebugContext context)
{
var latestSession = _contexts.FirstOrDefault(kvp => kvp.Value == null);
context.SessionId = latestSession.Key;
_contexts[latestSession.Key] = context;
return OnStart is null
? Task.CompletedTask
: OnStart(context);
}
public Task EndAsync(IScriptDebugContext context)
{
_contexts.Remove(context.SessionId);
return OnEnd is null
? Task.CompletedTask
: OnEnd(context);
}
public Task BreakAsync(IScriptDebugContext context)
{
return OnBreak is null
? Task.CompletedTask
: OnBreak(context);
}
public Task OutputAsync(IScriptDebugContext context, LogLevel level, string message, params object[] args)
{
if (OnOutput is null)
return Task.CompletedTask;
return OnOutput(context, level, Format(message, args));
}
public async Task ExecuteAsync(Guid sessionId, string input)
{
if (_contexts.ContainsKey(sessionId))
await _contexts[sessionId]!.ExecuteAsync(input);
}
private string Format(string template, object?[] args)
{
if (args == null || args.Length == 0)
return template;
int i = 0;
return TokenRegex.Replace(template, _ =>
i < args.Length ? args[i++]?.ToString() ?? string.Empty : string.Empty);
}
}

View file

@ -0,0 +1,14 @@
using LANCommander.SDK.PowerShell.Rpc;
using LANCommander.SDK.Rpc.Client;
using Microsoft.AspNetCore.SignalR;
namespace LANCommander.Server.Extensions;
public static class SignalRExtensions
{
public static T DebugSession<T>(this IHubCallerClients<T> clients, Guid sessionId) where T : IScriptDebuggerClient
=> clients.Group($"DebugSession/{sessionId}");
public static async Task AddDebugSessionAsync(this IGroupManager manager, string connectionId, Guid sessionId)
=> await manager.AddToGroupAsync(connectionId, $"DebugSession/{sessionId}");
}

View file

@ -0,0 +1,62 @@
using LANCommander.SDK.PowerShell;
using LANCommander.SDK.PowerShell.Rpc;
using LANCommander.SDK.Services;
using LANCommander.Server.Extensions;
using LANCommander.Server.Services;
using LANCommander.Server.Services.PowerShell;
using Microsoft.AspNetCore.SignalR;
namespace LANCommander.Server.Hubs;
public class ScriptDebuggerHub(
GameService gameService,
ScriptClient scriptClient,
ScriptDebugger scriptDebugger) : Hub<IScriptDebuggerClient>, IScriptDebuggerHub
{
public override async Task OnConnectedAsync()
{
var sessionId = scriptDebugger.CreateSession();
await Groups.AddDebugSessionAsync(Context.ConnectionId, sessionId);
await base.OnConnectedAsync();
}
private void Script_Initialize()
{
scriptClient.Debug = true;
scriptDebugger.OnBreak = Script_DebugBreak;
scriptDebugger.OnStart = Script_DebugStart;
scriptDebugger.OnEnd = Script_DebugEnd;
scriptDebugger.OnOutput = Script_DebugOutput;
}
private async Task Script_DebugStart(IScriptDebugContext context)
=> await Clients.DebugSession(context.SessionId).Start(context);
private async Task Script_DebugEnd(IScriptDebugContext context)
=> await Clients.DebugSession(context.SessionId).End(context);
private async Task Script_DebugBreak(IScriptDebugContext context)
{
// Need to wait for user input?
// This could get tricky. How do we separate debug sessions per-user?
// The ScriptDebugger is a singleton, so you wouldn't be able to debug multiple scripts at once
// Maybe the script debugger has to be scoped only for the current user session?
await Clients.DebugSession(context.SessionId).Break(context);
}
private async Task Script_DebugOutput(IScriptDebugContext context, LogLevel level, string mesasge)
=> await Clients.DebugSession(context.SessionId).Output(context, level, mesasge);
public async Task DebugPackageScript(Guid gameId)
{
Script_Initialize();
await gameService.PackageAsync(gameId);
}
public async Task SendInput(Guid sessionId, string input)
{
await scriptDebugger.ExecuteAsync(sessionId, input);
}
}

View file

@ -1,7 +1,9 @@
using AutoMapper;
using LANCommander.SDK.Rpc.Client;
using LANCommander.SDK.Rpc.Server;
using LANCommander.SDK.Services;
using LANCommander.Server.Services;
using LANCommander.Server.Services.PowerShell;
using Microsoft.AspNetCore.SignalR;
using ZiggyCreatures.Caching.Fusion;
@ -12,7 +14,10 @@ public partial class RpcHub(
IMapper mapper,
ILogger<RpcHub> logger,
ChatService chatService,
ServerService serverService) : Hub<IRpcSubscriber>, IRpcHub
ServerService serverService,
GameService gameService,
ScriptDebugger scriptDebugger,
ScriptClient scriptClient) : Hub<IRpcSubscriber>, IRpcHub
{
private string GetConnectionsCacheKey(string userIdentifier) => $"RPC/Connections/{userIdentifier}";

View file

@ -17,6 +17,7 @@ public static class SignalR
app.MapHub<RpcHub>("/rpc");
app.MapHub<GameServerHub>("/hubs/gameserver");
app.MapHub<LoggingHub>("/logging");
app.MapHub<ScriptDebuggerHub>("/RPC/ScriptDebugger");
return app;
}

View file

@ -0,0 +1,104 @@
@using LANCommander.SDK.PowerShell
@using LANCommander.SDK.PowerShell.Rpc
@using LANCommander.Server.Services.PowerShell
@using XtermBlazor
@using LogLevel = Microsoft.Extensions.Logging.LogLevel
@inherits RpcComponentBase<IScriptDebuggerClient, IScriptDebuggerHub>
@inject ScriptDebugger
<div class="terminal @(Visible ? "" : "hidden")">
<Xterm @ref="Terminal" Options="_options" OnFirstRender="@OnFirstRender" Addons="@Addons" />
</div>
@code {
private bool Visible { get; set; } = false;
private Xterm Terminal;
private TaskCompletionSource<string> InputTaskCompletionSource;
private TerminalOptions _options = new()
{
CursorBlink = true,
CursorStyle = CursorStyle.Bar,
};
private HashSet<string> Addons = new()
{
"readline",
"addon-fit"
};
protected override string HubUrl => "/RPC/ScriptDebugger";
private async Task OnFirstRender()
{
await Terminal.Addon("addon-fit").InvokeVoidAsync("fit");
}
private async Task<string> ReadLine()
{
return await Terminal.Addon("readline").InvokeAsync<string>("read", "> ");
}
public async Task DebugPackagingScript(Guid gameId)
{
await Hub.DebugPackageScript(gameId);
}
public async Task Start(IScriptDebugContext context)
{
Terminal?.Clear();
Visible = true;
await InvokeAsync(StateHasChanged);
await Terminal.Addon("addon-fit").InvokeVoidAsync("fit");
}
public async Task End(IScriptDebugContext context)
{
Visible = false;
await InvokeAsync(StateHasChanged);
}
public async Task Break(IScriptDebugContext context)
{
while (true)
{
var input = await ReadLine();
if (input.Trim().Equals("exit", StringComparison.OrdinalIgnoreCase))
break;
if (input.StartsWith('$'))
input = "Write-Host " + input;
await context.ExecuteAsync(input);
}
}
public async Task Output(IScriptDebugContext context, LogLevel level, string message)
{
switch (level)
{
case LogLevel.Error:
await Terminal.WriteLine($"\x1b[0;31m{message}");
break;
case LogLevel.Warning:
await Terminal.WriteLine($"\x1b[0;33m{message}");
break;
case LogLevel.Debug:
await Terminal.WriteLine($"\x1b[0;37m{message}");
break;
case LogLevel.Trace:
await Terminal.WriteLine($"\x1b[0;36m{message}");
break;
case LogLevel.Information:
await Terminal.WriteLine($"\x1b[0;37m{message}");
break;
}
}
}

View file

@ -35,6 +35,13 @@
}
<Button Icon="@IconType.Outline.Build" OnClick="() => RegToPowerShell.Open()" Type="@ButtonType.Text">Import .reg</Button>
@if (Options.GameId.HasValue && Options.GameId != Guid.Empty && IsDebuggable)
{
<Tooltip Title="Debug">
<Button Icon="@IconType.Outline.CaretRight" Type="@ButtonType.Text" OnClick="() => _console.DebugPackagingScript(Options.GameId.Value)" />
</Tooltip>
}
</FormItem>
<FormItem>
@ -59,6 +66,8 @@
<FormItem Label="Description">
<TextArea @bind-Value="context.Description" MaxLength=500 ShowCount />
</FormItem>
<PowerShellConsole @ref="_console" />
</Form>
<RegToPowerShell @ref="RegToPowerShell" OnParsed="(text) => InsertText(text)" />
@ -69,6 +78,7 @@
Form<Script> Form;
MonacoCodeEditor? Editor;
RegToPowerShell RegToPowerShell;
PowerShellConsole _console;
IEnumerable<Snippet> Snippets { get; set; }
Archive? _archive;
@ -86,6 +96,9 @@
Script Script = new();
bool IsDebuggable =>
Script.Type == ScriptType.Package;
protected override async Task OnInitializedAsync()
{
if (Options.ScriptId != Guid.Empty)

View file

@ -0,0 +1,144 @@
using System.Reflection;
using LANCommander.SDK.Extensions;
using Microsoft.AspNetCore.Components;
using Microsoft.AspNetCore.SignalR.Client;
namespace LANCommander.UI.Components
{
/// <summary>
/// Base component that connects to a SignalR hub and exposes the component
/// as a strongly-typed client (THubClient).
///
/// Derived components must:
/// - Implement THubClient
/// - Provide the HubUrl
/// </summary>
public abstract class RpcComponentBase<THubClient, THub> : ComponentBase, IAsyncDisposable
where THubClient : class where THub : class
{
private readonly List<IDisposable> _clientHandlerSubscriptions = new();
protected HubConnection? HubConnection { get; private set; }
protected THub? Hub { get; private set; }
[Inject]
protected NavigationManager NavigationManager { get; set; } = default!;
/// <summary>
/// Relative hub URL, e.g. "/hubs/notifications".
/// </summary>
protected abstract string HubUrl { get; }
/// <summary>
/// The component cast as the hub client interface.
/// </summary>
protected THubClient Client => (THubClient)(object)this;
protected bool IsConnected =>
HubConnection?.State == HubConnectionState.Connected;
protected override async Task OnInitializedAsync()
{
await base.OnInitializedAsync();
var absoluteHubUrl = NavigationManager.ToAbsoluteUri(HubUrl);
var builder = new HubConnectionBuilder()
.WithUrl(absoluteHubUrl)
.WithAutomaticReconnect();
HubConnection = builder.Build();
Hub = HubConnection.ServerProxy<THub>();
WireClientInterfaceHandlers(HubConnection);
HubConnection.Reconnected += async _ =>
{
await OnReconnectedAsync();
};
HubConnection.Reconnecting += async ex =>
{
await OnReconnectingAsync(ex);
};
HubConnection.Closed += async ex =>
{
await OnClosedAsync(ex);
};
await HubConnection.StartAsync();
await OnConnectedAsync();
}
/// <summary>
/// Called after the connection is successfully started.
/// </summary>
protected virtual Task OnConnectedAsync() => Task.CompletedTask;
/// <summary>
/// Called when the connection is in the process of reconnecting.
/// </summary>
protected virtual Task OnReconnectingAsync(Exception? exception) => Task.CompletedTask;
/// <summary>
/// Called when the connection has reconnected.
/// </summary>
protected virtual Task OnReconnectedAsync() => Task.CompletedTask;
/// <summary>
/// Called when the connection is closed and will not reconnect automatically.
/// </summary>
protected virtual Task OnClosedAsync(Exception? exception) => Task.CompletedTask;
/// <summary>
/// Registers handlers for every method on THubClient so that when the hub
/// invokes these methods on the client, they are forwarded to this component.
/// </summary>
private void WireClientInterfaceHandlers(HubConnection hubConnection)
{
var clientType = typeof(THubClient);
var methods = clientType
.GetMethods(BindingFlags.Public | BindingFlags.Instance)
.Where(m => !m.IsSpecialName); // ignore property accessors, etc.
foreach (var method in methods)
{
var parameters = method.GetParameters();
var parameterTypes = parameters.Select(p => p.ParameterType).ToArray();
// Use the generic On(string, Type[], Func<object[], Task>) overload
var subscription = hubConnection.On(
methodName: method.Name,
parameterTypes: parameterTypes,
handler: async args =>
{
var result = method.Invoke(Client, args);
if (result is Task task)
await task;
// Ensure UI updates are marshaled back onto the Blazor renderer
await InvokeAsync(StateHasChanged);
});
_clientHandlerSubscriptions.Add(subscription);
}
}
public async ValueTask DisposeAsync()
{
foreach (var subscription in _clientHandlerSubscriptions)
subscription.Dispose();
_clientHandlerSubscriptions.Clear();
if (HubConnection is not null)
{
await HubConnection.DisposeAsync();
}
}
}
}