From 52dd3f23babe41d3319b759c62cc31ea46dcf3b3 Mon Sep 17 00:00:00 2001 From: Pat Hartl Date: Wed, 27 May 2026 20:42:23 -0500 Subject: [PATCH] Add "Package" button to archive page w/ dialog --- .../PowerShell/PowerShellScript.cs | 107 +++++++-- LANCommander.Server.Services/GameService.cs | 18 +- .../RedistributableService.cs | 22 +- LANCommander.Server.Services/ToolService.cs | 22 +- .../Models/PackagingDialogOptions.cs | 8 + .../Styles/_packaging-dialog.scss | 16 ++ LANCommander.Server/Styles/app.scss | 1 + .../UI/Components/ArchiveEditor.razor | 74 +++++- .../UI/Components/PackagingDialog.razor | 217 ++++++++++++++++++ .../UI/Pages/Games/Edit/General.razor | 14 -- .../Pages/Redistributables/Edit/General.razor | 14 -- .../UI/Pages/Tools/Edit/General.razor | 14 -- 12 files changed, 443 insertions(+), 84 deletions(-) create mode 100644 LANCommander.Server/Models/PackagingDialogOptions.cs create mode 100644 LANCommander.Server/Styles/_packaging-dialog.scss create mode 100644 LANCommander.Server/UI/Components/PackagingDialog.razor diff --git a/LANCommander.SDK/PowerShell/PowerShellScript.cs b/LANCommander.SDK/PowerShell/PowerShellScript.cs index 4151457d..2906543c 100644 --- a/LANCommander.SDK/PowerShell/PowerShellScript.cs +++ b/LANCommander.SDK/PowerShell/PowerShellScript.cs @@ -184,6 +184,14 @@ namespace LANCommander.SDK.PowerShell { runspace.Open(); + // Ensure TLS 1.2 is available for web requests (GitHub, etc.) + using (var tls = System.Management.Automation.PowerShell.Create()) + { + tls.Runspace = runspace; + tls.AddScript("[Net.ServicePointManager]::SecurityProtocol = [Net.ServicePointManager]::SecurityProtocol -bor [Net.SecurityProtocolType]::Tls12"); + tls.Invoke(); + } + runspace.SessionStateProxy.Path.SetLocation(WorkingDirectory); foreach (var variable in Variables) @@ -218,6 +226,12 @@ namespace LANCommander.SDK.PowerShell Context.AddScript("Write-Host $Logo"); Context.AddScript(Contents); + Context.Streams.Information.DataAdded += Information_DataAdded; + Context.Streams.Verbose.DataAdded += Verbose_DataAdded; + Context.Streams.Debug.DataAdded += Debug_DataAdded; + Context.Streams.Warning.DataAdded += Warning_DataAdded; + Context.Streams.Error.DataAdded += Error_DataAdded; + if (Debug) { Context.AddScript("Write-Host '--------- DEBUG ---------'"); Context.AddScript("Write-Host \"Script Type: $ScriptType\""); @@ -231,37 +245,41 @@ namespace LANCommander.SDK.PowerShell Context.AddScript("Write-Host ''"); Context.AddScript("Write-Host 'Enter \"exit\" to continue'"); - - Context.Streams.Information.DataAdded += Information_DataAdded; - Context.Streams.Verbose.DataAdded += Verbose_DataAdded; - Context.Streams.Debug.DataAdded += Debug_DataAdded; - Context.Streams.Warning.DataAdded += Warning_DataAdded; - Context.Streams.Error.DataAdded += Error_DataAdded; } try { var results = await Context.InvokeAsync(); - await DebugAsync(async dbg => - { - await dbg.BreakAsync(DebugContext); - }); - if (Context.HadErrors) { foreach (var error in Context.Streams.Error) { Logger.LogError("Script error: {InvocationName} : {ErrorMessage}", error.InvocationInfo?.InvocationName, error.Exception?.Message); + + await DebugAsync(async dbg => + { + await dbg.OutputAsync(DebugContext, LogLevel.Error, "{InvocationName} : {ErrorMessage}", error.InvocationInfo?.InvocationName, error.Exception?.Message); + }); } } var returnValue = Context.Runspace.SessionStateProxy.PSVariable.GetValue("Return"); + if (returnValue == null && results != null && results.Count > 0) + returnValue = results[results.Count - 1]; + if (returnValue != null) - result = (T)returnValue; + { + result = ConvertResult(returnValue); + + if (result == null) + Logger.LogWarning("Script returned a value but it could not be converted to {ExpectedType}", typeof(T).Name); + } else - Logger.LogWarning("Script did not set $Return variable"); + { + Logger.LogWarning("Script did not return a value via $Return or the pipeline"); + } } catch (Exception ex) { @@ -273,7 +291,15 @@ namespace LANCommander.SDK.PowerShell { await dbg.EndAsync(DebugContext); }); - + + if (Debug) + { + await DebugAsync(async dbg => + { + await dbg.BreakAsync(DebugContext); + }); + } + Context.Dispose(); } } @@ -283,11 +309,58 @@ namespace LANCommander.SDK.PowerShell return result; } + private static T ConvertResult(object value) + { + // Unwrap PSObject wrapper + var psObj = value as PSObject; + var raw = psObj?.BaseObject ?? value; + + // Direct cast if the underlying object is already the right type + if (raw is T typed) + return typed; + + // Map PSObject properties onto a new instance of T by name + if (psObj != null) + { + try + { + var instance = Activator.CreateInstance(); + var targetProps = typeof(T).GetProperties(System.Reflection.BindingFlags.Public | System.Reflection.BindingFlags.Instance); + + foreach (var targetProp in targetProps) + { + if (!targetProp.CanWrite) + continue; + + var psProp = psObj.Properties[targetProp.Name]; + + if (psProp == null) + continue; + + var psValue = psProp.Value; + + if (psValue is PSObject psValObj) + psValue = psValObj.BaseObject; + + if (psValue != null && targetProp.PropertyType.IsAssignableFrom(psValue.GetType())) + targetProp.SetValue(instance, psValue); + else if (psValue != null) + targetProp.SetValue(instance, Convert.ChangeType(psValue, targetProp.PropertyType)); + } + + return instance; + } + catch + { + return default; + } + } + + return default; + } + private async Task DebugAsync(Func action) { - if (!Debug) - return; - if (Debuggers == null) Debuggers = ServiceProvider.GetServices(); diff --git a/LANCommander.Server.Services/GameService.cs b/LANCommander.Server.Services/GameService.cs index 4b4275e8..3c242f10 100644 --- a/LANCommander.Server.Services/GameService.cs +++ b/LANCommander.Server.Services/GameService.cs @@ -281,14 +281,16 @@ namespace LANCommander.Server.Services if (package == null) { - logger?.LogError("Could not package game {GameTitle}, the package script did not return a result", game.Title); - continue; + var message = $"Could not package game {game.Title}, the package script did not return a result"; + logger?.LogError(message); + throw new Exception(message); } if (String.IsNullOrWhiteSpace(package.Path) || !Directory.Exists(package.Path)) { - logger?.LogError("Could not package game {GameTitle}, the path {Path} could not be found", game.Title, package.Path); - continue; + var message = $"Could not package game {game.Title}, the path {package.Path} could not be found"; + logger?.LogError(message); + throw new Exception(message); } var archive = new Archive @@ -305,13 +307,17 @@ namespace LANCommander.Server.Services var destination = await archiveService.GetArchiveFileLocationAsync(archive); ZipFile.CreateFromDirectory(package.Path, destination); - + + await archiveService.RecalculateFileSizeArchiveAsync(archive); + logger?.LogInformation("Successfully packaged {GameTitle} and created new archive with version number {GameVersion}", game.Title, archive.Version); } } else { - logger?.LogWarning("Could not package game {GameTitle}, no packaging scripts are defined", game.Title); + var message = $"Could not package game {game.Title}, no packaging scripts are defined"; + logger?.LogWarning(message); + throw new Exception(message); } } } diff --git a/LANCommander.Server.Services/RedistributableService.cs b/LANCommander.Server.Services/RedistributableService.cs index 6f660c35..0e1a16a6 100644 --- a/LANCommander.Server.Services/RedistributableService.cs +++ b/LANCommander.Server.Services/RedistributableService.cs @@ -92,16 +92,16 @@ namespace LANCommander.Server.Services if (package == null) { - logger?.LogError("Could not package redistributable {RedistributableName}, the package script did not return a result", redistributable.Name); - continue; + var message = $"Could not package redistributable {redistributable.Name}, the package script did not return a result"; + logger?.LogError(message); + throw new Exception(message); } if (String.IsNullOrWhiteSpace(package.Path) || !Directory.Exists(package.Path)) { - logger?.LogError( - "Could not package redistributable {RedistributableName}, the path {Path} could not be found", - redistributable.Name, package.Path); - continue; + var message = $"Could not package redistributable {redistributable.Name}, the path {package.Path} could not be found"; + logger?.LogError(message); + throw new Exception(message); } var archive = new Archive @@ -118,13 +118,17 @@ namespace LANCommander.Server.Services var destination = await archiveService.GetArchiveFileLocationAsync(archive); ZipFile.CreateFromDirectory(package.Path, destination); - - logger?.LogInformation("Successfully packaged {RedistributableName} and create new archive with version number {RedistributableVersion}", redistributable.Name, archive.Version); + + await archiveService.RecalculateFileSizeArchiveAsync(archive); + + logger?.LogInformation("Successfully packaged {RedistributableName} and created new archive with version number {RedistributableVersion}", redistributable.Name, archive.Version); } } else { - logger?.LogWarning("Could not package redistributable {RedistributableName}, no packaging scripts are defined", redistributable.Name); + var message = $"Could not package redistributable {redistributable.Name}, no packaging scripts are defined"; + logger?.LogWarning(message); + throw new Exception(message); } } } diff --git a/LANCommander.Server.Services/ToolService.cs b/LANCommander.Server.Services/ToolService.cs index fc93ebed..a06fa97b 100644 --- a/LANCommander.Server.Services/ToolService.cs +++ b/LANCommander.Server.Services/ToolService.cs @@ -92,16 +92,16 @@ namespace LANCommander.Server.Services if (package == null) { - logger?.LogError("Could not package tool {ToolName}, the package script did not return a result", tool.Name); - continue; + var message = $"Could not package tool {tool.Name}, the package script did not return a result"; + logger?.LogError(message); + throw new Exception(message); } if (String.IsNullOrWhiteSpace(package.Path) || !Directory.Exists(package.Path)) { - logger?.LogError( - "Could not package tool {ToolName}, the path {Path} could not be found", - tool.Name, package.Path); - continue; + var message = $"Could not package tool {tool.Name}, the path {package.Path} could not be found"; + logger?.LogError(message); + throw new Exception(message); } var archive = new Archive @@ -118,13 +118,17 @@ namespace LANCommander.Server.Services var destination = await archiveService.GetArchiveFileLocationAsync(archive); ZipFile.CreateFromDirectory(package.Path, destination); - - logger?.LogInformation("Successfully packaged {ToolName} and create new archive with version number {ToolVersion}", tool.Name, archive.Version); + + await archiveService.RecalculateFileSizeArchiveAsync(archive); + + logger?.LogInformation("Successfully packaged {ToolName} and created new archive with version number {ToolVersion}", tool.Name, archive.Version); } } else { - logger?.LogWarning("Could not package tool {ToolName}, no packaging scripts are defined", tool.Name); + var message = $"Could not package tool {tool.Name}, no packaging scripts are defined"; + logger?.LogWarning(message); + throw new Exception(message); } } } diff --git a/LANCommander.Server/Models/PackagingDialogOptions.cs b/LANCommander.Server/Models/PackagingDialogOptions.cs new file mode 100644 index 00000000..8d91abba --- /dev/null +++ b/LANCommander.Server/Models/PackagingDialogOptions.cs @@ -0,0 +1,8 @@ +namespace LANCommander.Server.Models; + +public class PackagingDialogOptions +{ + public Guid GameId { get; set; } + public Guid ToolId { get; set; } + public Guid RedistributableId { get; set; } +} diff --git a/LANCommander.Server/Styles/_packaging-dialog.scss b/LANCommander.Server/Styles/_packaging-dialog.scss new file mode 100644 index 00000000..43f10563 --- /dev/null +++ b/LANCommander.Server/Styles/_packaging-dialog.scss @@ -0,0 +1,16 @@ +.packaging-dialog { + .ant-result { + padding: 24px 16px; + } +} + +.packaging-terminal { + border-radius: 4px; + overflow: hidden; + min-height: 300px; + background: #1e1e1e; + + .xterm { + padding: 8px; + } +} diff --git a/LANCommander.Server/Styles/app.scss b/LANCommander.Server/Styles/app.scss index de8a69d7..5f5e63a3 100644 --- a/LANCommander.Server/Styles/app.scss +++ b/LANCommander.Server/Styles/app.scss @@ -22,6 +22,7 @@ @use '_mobile'; @use '_tree'; @use '_file-picker'; +@use '_packaging-dialog'; // Fix Blazor rejoin dialog font color being white, force to black // see: https://github.com/dotnet/aspnetcore/issues/57453 diff --git a/LANCommander.Server/UI/Components/ArchiveEditor.razor b/LANCommander.Server/UI/Components/ArchiveEditor.razor index cfbc12f7..7afacdef 100644 --- a/LANCommander.Server/UI/Components/ArchiveEditor.razor +++ b/LANCommander.Server/UI/Components/ArchiveEditor.razor @@ -1,4 +1,8 @@ @inject ArchiveService ArchiveService +@inject ScriptService ScriptService +@inject GameService GameService +@inject ToolService ToolService +@inject RedistributableService RedistributableService @inject HttpClient HttpClient @inject NavigationManager Navigator @inject ModalService ModalService @@ -13,6 +17,10 @@ Responsive Query="a => (GameId != Guid.Empty && a.GameId == GameId) || (RedistributableId != Guid.Empty && a.RedistributableId == RedistributableId) || (ToolId != Guid.Empty && a.ToolId == ToolId)"> + @if (_hasPackageScript) + { + + } @@ -65,10 +73,74 @@ ArchiveUploader _uploader; bool _browsing; + bool _packaging; + bool _hasPackageScript; - protected override void OnInitialized() + protected override async Task OnInitializedAsync() { HttpClient.BaseAddress = new Uri(Navigator.BaseUri); + + if (GameId != Guid.Empty) + _hasPackageScript = (await ScriptService.GetAsync(s => s.GameId == GameId && s.Type == SDK.Enums.ScriptType.Package)).Any(); + else if (RedistributableId != Guid.Empty) + _hasPackageScript = (await ScriptService.GetAsync(s => s.RedistributableId == RedistributableId && s.Type == SDK.Enums.ScriptType.Package)).Any(); + else if (ToolId != Guid.Empty) + _hasPackageScript = (await ScriptService.GetAsync(s => s.ToolId == ToolId && s.Type == SDK.Enums.ScriptType.Package)).Any(); + } + + private async Task Package() + { + _packaging = true; + + await InvokeAsync(StateHasChanged); + await Task.Yield(); + + string title = "Package"; + + if (GameId != Guid.Empty) + { + var game = await GameService.GetAsync(GameId); + title = $"Package {game.Title}"; + } + else if (ToolId != Guid.Empty) + { + var tool = await ToolService.GetAsync(ToolId); + title = $"Package {tool.Name}"; + } + else if (RedistributableId != Guid.Empty) + { + var redistributable = await RedistributableService.GetAsync(RedistributableId); + title = $"Package {redistributable.Name}"; + } + + var modalOptions = new ModalOptions() + { + Title = title, + Maximizable = true, + DefaultMaximized = false, + Closable = true, + Footer = null, + Width = 800, + }; + + var options = new PackagingDialogOptions + { + GameId = GameId, + ToolId = ToolId, + RedistributableId = RedistributableId, + }; + + var modalRef = await ModalService.CreateModalAsync(modalOptions, options); + + modalRef.OnCancel = async () => + { + await _table.ReloadAsync(); + }; + + _packaging = false; + + await InvokeAsync(StateHasChanged); + await Task.Yield(); } private async Task RecalculateFileSizes() diff --git a/LANCommander.Server/UI/Components/PackagingDialog.razor b/LANCommander.Server/UI/Components/PackagingDialog.razor new file mode 100644 index 00000000..f5566da1 --- /dev/null +++ b/LANCommander.Server/UI/Components/PackagingDialog.razor @@ -0,0 +1,217 @@ +@using LANCommander.SDK.PowerShell +@using LANCommander.Server.Services.PowerShell +@using XtermBlazor +@using LogLevel = Microsoft.Extensions.Logging.LogLevel +@inherits FeedbackComponent +@inject ScriptDebugger ScriptDebugger +@inject GameService GameService +@inject ToolService ToolService +@inject RedistributableService RedistributableService +@inject ILogger Logger +@implements IDisposable + +
+ @if (_status == PackagingStatus.Complete) + { + + + } + else if (_status == PackagingStatus.Failed) + { + + + } + else if (_status == PackagingStatus.Stopped) + { + + + } + +
+ @if (_status == PackagingStatus.Running) + { + + @FormatElapsed() + + +
+
+ +@code { + enum PackagingStatus { Running, Complete, Failed, Stopped } + + Guid _id = Guid.NewGuid(); + Terminal? _terminal; + CancellationTokenSource? _cts; + DateTime? _startTime; + TimeSpan _elapsed; + System.Threading.Timer? _elapsedTimer; + + PackagingStatus _status = PackagingStatus.Running; + bool _hasErrors; + string? _errorMessage; + + readonly TerminalOptions _terminalOptions = new() + { + CursorBlink = false, + CursorStyle = CursorStyle.Bar, + }; + + protected override async Task OnInitializedAsync() + { + _status = PackagingStatus.Running; + _hasErrors = false; + _errorMessage = null; + + ScriptDebugger.OnStart = Start; + ScriptDebugger.OnEnd = End; + ScriptDebugger.OnOutput = Output; + ScriptDebugger.OnBreak = null; + + _cts?.Dispose(); + _cts = new CancellationTokenSource(); + _startTime = DateTime.UtcNow; + + _elapsedTimer = new System.Threading.Timer( + _ => InvokeAsync(StateHasChanged), + null, + TimeSpan.Zero, + TimeSpan.FromSeconds(1)); + } + + async Task OnTerminalReady() + { + if (_terminal != null) + await _terminal.FitAsync(); + + await RunAsync(); + } + + async Task RunAsync() + { + try + { + if (Options.GameId != Guid.Empty) + await GameService.PackageAsync(Options.GameId); + else if (Options.ToolId != Guid.Empty) + await ToolService.PackageAsync(Options.ToolId); + else if (Options.RedistributableId != Guid.Empty) + await RedistributableService.PackageAsync(Options.RedistributableId); + } + catch (Exception ex) + { + _hasErrors = true; + _errorMessage = ex.Message; + Logger.LogError(ex, "Packaging failed with an exception"); + + if (_terminal != null) + await _terminal.WriteLine(ex.Message, LogLevel.Error); + + StopTimer(); + _elapsed = _startTime.HasValue ? DateTime.UtcNow - _startTime.Value : TimeSpan.Zero; + _status = PackagingStatus.Failed; + await InvokeAsync(StateHasChanged); + } + + } + + async Task Start(IScriptDebugContext context) + { + if (_terminal == null) + return; + + await InvokeAsync(StateHasChanged); + } + + async Task End(IScriptDebugContext context) + { + StopTimer(); + + _elapsed = _startTime.HasValue ? DateTime.UtcNow - _startTime.Value : TimeSpan.Zero; + + if (_terminal != null) + await _terminal.WriteLine($"\nCompleted in {FormatTimeSpan(_elapsed)}", LogLevel.Information); + + _status = _hasErrors ? PackagingStatus.Failed : PackagingStatus.Complete; + + await InvokeAsync(StateHasChanged); + } + + async Task Output(IScriptDebugContext context, LogLevel level, string message) + { + if (_terminal == null) + return; + + if (level == LogLevel.Error) + { + _hasErrors = true; + _errorMessage ??= message; + } + + try + { + await _terminal.WriteLine(message, level); + } + catch + { + } + } + + async Task Stop() + { + _cts?.Cancel(); + StopTimer(); + + _elapsed = _startTime.HasValue ? DateTime.UtcNow - _startTime.Value : TimeSpan.Zero; + + if (_terminal != null) + await _terminal.WriteLine("\nScript stopped by user.", LogLevel.Warning); + + _status = PackagingStatus.Stopped; + + await InvokeAsync(StateHasChanged); + } + + void StopTimer() + { + _elapsedTimer?.Dispose(); + _elapsedTimer = null; + } + + string FormatElapsed() + { + if (!_startTime.HasValue) + return ""; + + var elapsed = DateTime.UtcNow - _startTime.Value; + + return FormatTimeSpan(elapsed); + } + + static string FormatTimeSpan(TimeSpan ts) + { + if (ts.TotalHours >= 1) + return ts.ToString(@"h\:mm\:ss"); + + return ts.ToString(@"m\:ss"); + } + + public void Dispose() + { + _cts?.Cancel(); + _cts?.Dispose(); + StopTimer(); + } +} diff --git a/LANCommander.Server/UI/Pages/Games/Edit/General.razor b/LANCommander.Server/UI/Pages/Games/Edit/General.razor index 4368bd90..5abd3ba1 100644 --- a/LANCommander.Server/UI/Pages/Games/Edit/General.razor +++ b/LANCommander.Server/UI/Pages/Games/Edit/General.razor @@ -27,11 +27,6 @@ - @if (context != null && (context.Scripts?.Any(s => s.Type == ScriptType.Package) ?? false)) - { - - } - @if (context != null && context.Id != Guid.Empty) { @@ -172,7 +167,6 @@ bool _loaded; bool _success; - bool _packaging; bool _openingExportDialog; IEnumerable Engines = new List(); @@ -318,12 +312,4 @@ await Task.Yield(); } - private async Task Package() - { - _packaging = true; - - await GameService.PackageAsync(Id); - - _packaging = false; - } } diff --git a/LANCommander.Server/UI/Pages/Redistributables/Edit/General.razor b/LANCommander.Server/UI/Pages/Redistributables/Edit/General.razor index dc82c87c..d7bee285 100644 --- a/LANCommander.Server/UI/Pages/Redistributables/Edit/General.razor +++ b/LANCommander.Server/UI/Pages/Redistributables/Edit/General.razor @@ -21,11 +21,6 @@ - @if (context != null && (context.Scripts?.Any(s => s.Type == ScriptType.Package) ?? false)) - { - - } - @if (context != null && context.Id != Guid.Empty) { @@ -71,7 +66,6 @@ ICollection Games; RedistributableEditView EditView; - bool _packaging; bool _openingExportDialog; protected override async Task OnInitializedAsync() @@ -117,13 +111,5 @@ await InvokeAsync(StateHasChanged); await Task.Yield(); } - - private async Task Package() - { - _packaging = true; - await RedistributableService.PackageAsync(Id); - - _packaging = false; - } } \ No newline at end of file diff --git a/LANCommander.Server/UI/Pages/Tools/Edit/General.razor b/LANCommander.Server/UI/Pages/Tools/Edit/General.razor index 6f960c16..2e0fd1d5 100644 --- a/LANCommander.Server/UI/Pages/Tools/Edit/General.razor +++ b/LANCommander.Server/UI/Pages/Tools/Edit/General.razor @@ -21,11 +21,6 @@ - @if (context != null && (context.Scripts?.Any(s => s.Type == ScriptType.Package) ?? false)) - { - - } - @if (context != null && context.Id != Guid.Empty) { @@ -71,7 +66,6 @@ ICollection Games; ToolEditView EditView; - bool _packaging; bool _openingExportDialog; protected override async Task OnInitializedAsync() @@ -117,13 +111,5 @@ await InvokeAsync(StateHasChanged); await Task.Yield(); } - - private async Task Package() - { - _packaging = true; - await ToolService.PackageAsync(Id); - - _packaging = false; - } } \ No newline at end of file