diff --git a/LANCommander.Launcher.Services/PowerShell/ScriptDebugger.cs b/LANCommander.Launcher.Services/PowerShell/ScriptDebugger.cs index ca882411..8d7b1868 100644 --- a/LANCommander.Launcher.Services/PowerShell/ScriptDebugger.cs +++ b/LANCommander.Launcher.Services/PowerShell/ScriptDebugger.cs @@ -6,31 +6,40 @@ namespace LANCommander.Launcher.Services.PowerShell; public class ScriptDebugger : IScriptDebugger { - public Func OnDebugStart; - public Func OnDebugBreak; - public Func OnDebugEnd; - public Func OnOutput; + public Func? OnDebugStart; + public Func? OnDebugBreak; + public Func? OnDebugEnd; + public Func? 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) diff --git a/LANCommander.Launcher/UI/Components/PowerShellConsole.razor b/LANCommander.Launcher/UI/Components/PowerShellConsole.razor index 29bde5da..264fe156 100644 --- a/LANCommander.Launcher/UI/Components/PowerShellConsole.razor +++ b/LANCommander.Launcher/UI/Components/PowerShellConsole.razor @@ -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; diff --git a/LANCommander.SDK/PowerShell/IScriptDebugContext.cs b/LANCommander.SDK/PowerShell/IScriptDebugContext.cs new file mode 100644 index 00000000..05a9eafa --- /dev/null +++ b/LANCommander.SDK/PowerShell/IScriptDebugContext.cs @@ -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); +} \ No newline at end of file diff --git a/LANCommander.SDK/PowerShell/IScriptDebugger.cs b/LANCommander.SDK/PowerShell/IScriptDebugger.cs index c184ea76..b5388293 100644 --- a/LANCommander.SDK/PowerShell/IScriptDebugger.cs +++ b/LANCommander.SDK/PowerShell/IScriptDebugger.cs @@ -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); } \ No newline at end of file diff --git a/LANCommander.SDK/PowerShell/PowerShellDebugContext.cs b/LANCommander.SDK/PowerShell/PowerShellDebugContext.cs new file mode 100644 index 00000000..ae43dd0f --- /dev/null +++ b/LANCommander.SDK/PowerShell/PowerShellDebugContext.cs @@ -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); + } +} \ No newline at end of file diff --git a/LANCommander.SDK/PowerShell/PowerShellScript.cs b/LANCommander.SDK/PowerShell/PowerShellScript.cs index a0daf581..ebe77305 100644 --- a/LANCommander.SDK/PowerShell/PowerShellScript.cs +++ b/LANCommander.SDK/PowerShell/PowerShellScript.cs @@ -45,6 +45,7 @@ namespace LANCommander.SDK.PowerShell private ILogger Logger { get; set; } private IEnumerable 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); }); } diff --git a/LANCommander.SDK/PowerShell/Rpc/IScriptDebuggerClient.cs b/LANCommander.SDK/PowerShell/Rpc/IScriptDebuggerClient.cs new file mode 100644 index 00000000..72798ec9 --- /dev/null +++ b/LANCommander.SDK/PowerShell/Rpc/IScriptDebuggerClient.cs @@ -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); +} \ No newline at end of file diff --git a/LANCommander.SDK/PowerShell/Rpc/IScriptDebuggerHub.cs b/LANCommander.SDK/PowerShell/Rpc/IScriptDebuggerHub.cs new file mode 100644 index 00000000..8865034a --- /dev/null +++ b/LANCommander.SDK/PowerShell/Rpc/IScriptDebuggerHub.cs @@ -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); +} \ No newline at end of file diff --git a/LANCommander.SDK/Rpc/Interfaces/Server/IRpcHub.Server.cs b/LANCommander.SDK/Rpc/Interfaces/Server/IRpcHub.Server.cs deleted file mode 100644 index b27c2044..00000000 --- a/LANCommander.SDK/Rpc/Interfaces/Server/IRpcHub.Server.cs +++ /dev/null @@ -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); -} \ No newline at end of file diff --git a/LANCommander.SDK/Services/RpcClient.cs b/LANCommander.SDK/Services/RpcClient.cs index 85ac37e2..273d203c 100644 --- a/LANCommander.SDK/Services/RpcClient.cs +++ b/LANCommander.SDK/Services/RpcClient.cs @@ -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> 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); } \ No newline at end of file diff --git a/LANCommander.Server.Services/Extensions/IServiceCollectionExtensions.cs b/LANCommander.Server.Services/Extensions/IServiceCollectionExtensions.cs index b694e323..0c633429 100644 --- a/LANCommander.Server.Services/Extensions/IServiceCollectionExtensions.cs +++ b/LANCommander.Server.Services/Extensions/IServiceCollectionExtensions.cs @@ -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(); services.AddSingleton(provider => provider.GetService()); + services.AddSingleton(); + services.AddSingleton(sp => + sp.GetRequiredService()); + services.AddSingleton(); services.AddSingleton(); diff --git a/LANCommander.Server.Services/PowerShell/ScriptDebugger.cs b/LANCommander.Server.Services/PowerShell/ScriptDebugger.cs new file mode 100644 index 00000000..95b9a3c8 --- /dev/null +++ b/LANCommander.Server.Services/PowerShell/ScriptDebugger.cs @@ -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? OnStart; + public Func? OnBreak; + public Func? OnEnd; + public Func? OnOutput; + + private IDictionary _contexts = new Dictionary(); + + 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); + } +} \ No newline at end of file diff --git a/LANCommander.Server/Extensions/IHubCallerClientsExtensions.cs b/LANCommander.Server/Extensions/IHubCallerClientsExtensions.cs new file mode 100644 index 00000000..c7b23ebf --- /dev/null +++ b/LANCommander.Server/Extensions/IHubCallerClientsExtensions.cs @@ -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(this IHubCallerClients 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}"); +} \ No newline at end of file diff --git a/LANCommander.Server/Hubs/Script.cs b/LANCommander.Server/Hubs/Script.cs new file mode 100644 index 00000000..1302ffde --- /dev/null +++ b/LANCommander.Server/Hubs/Script.cs @@ -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, 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); + } +} \ No newline at end of file diff --git a/LANCommander.Server/Hubs/_RpcHub.cs b/LANCommander.Server/Hubs/_RpcHub.cs index 17a02f18..2707ce49 100644 --- a/LANCommander.Server/Hubs/_RpcHub.cs +++ b/LANCommander.Server/Hubs/_RpcHub.cs @@ -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 logger, ChatService chatService, - ServerService serverService) : Hub, IRpcHub + ServerService serverService, + GameService gameService, + ScriptDebugger scriptDebugger, + ScriptClient scriptClient) : Hub, IRpcHub { private string GetConnectionsCacheKey(string userIdentifier) => $"RPC/Connections/{userIdentifier}"; diff --git a/LANCommander.Server/Startup/SignalR.cs b/LANCommander.Server/Startup/SignalR.cs index 95d8a79a..b6a576a9 100644 --- a/LANCommander.Server/Startup/SignalR.cs +++ b/LANCommander.Server/Startup/SignalR.cs @@ -17,6 +17,7 @@ public static class SignalR app.MapHub("/rpc"); app.MapHub("/hubs/gameserver"); app.MapHub("/logging"); + app.MapHub("/RPC/ScriptDebugger"); return app; } diff --git a/LANCommander.Server/UI/Components/PowerShellConsole.razor b/LANCommander.Server/UI/Components/PowerShellConsole.razor new file mode 100644 index 00000000..3a8993d6 --- /dev/null +++ b/LANCommander.Server/UI/Components/PowerShellConsole.razor @@ -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 +@inject ScriptDebugger + +
+ +
+ +@code { + private bool Visible { get; set; } = false; + private Xterm Terminal; + private TaskCompletionSource InputTaskCompletionSource; + + private TerminalOptions _options = new() + { + CursorBlink = true, + CursorStyle = CursorStyle.Bar, + }; + + private HashSet 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 ReadLine() + { + return await Terminal.Addon("readline").InvokeAsync("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; + } + } +} \ No newline at end of file diff --git a/LANCommander.Server/UI/Components/ScriptEditorDialog.razor b/LANCommander.Server/UI/Components/ScriptEditorDialog.razor index f12520e5..521b3b16 100644 --- a/LANCommander.Server/UI/Components/ScriptEditorDialog.razor +++ b/LANCommander.Server/UI/Components/ScriptEditorDialog.razor @@ -35,6 +35,13 @@ } + + @if (Options.GameId.HasValue && Options.GameId != Guid.Empty && IsDebuggable) + { + +