Add "Package" button to archive page w/ dialog

This commit is contained in:
Pat Hartl 2026-05-27 20:42:23 -05:00
parent 81639e9fc2
commit 52dd3f23ba
12 changed files with 443 additions and 84 deletions

View file

@ -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<T>(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<T>(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<T>();
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<IScriptDebugger, Task> action)
{
if (!Debug)
return;
if (Debuggers == null)
Debuggers = ServiceProvider.GetServices<IScriptDebugger>();

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

@ -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)">
<RightToolbar>
@if (_hasPackageScript)
{
<Button OnClick="Package" Loading="_packaging">Package</Button>
}
<Button OnClick="RecalculateFileSizes" Type="@ButtonType.Default">Recalculate File Sizes</Button>
<Button OnClick="UploadArchive" Type="@ButtonType.Primary">Upload Archive</Button>
</RightToolbar>
@ -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<PackagingDialog, PackagingDialogOptions>(modalOptions, options);
modalRef.OnCancel = async () =>
{
await _table.ReloadAsync();
};
_packaging = false;
await InvokeAsync(StateHasChanged);
await Task.Yield();
}
private async Task RecalculateFileSizes()

View file

@ -0,0 +1,217 @@
@using LANCommander.SDK.PowerShell
@using LANCommander.Server.Services.PowerShell
@using XtermBlazor
@using LogLevel = Microsoft.Extensions.Logging.LogLevel
@inherits FeedbackComponent<PackagingDialogOptions>
@inject ScriptDebugger ScriptDebugger
@inject GameService GameService
@inject ToolService ToolService
@inject RedistributableService RedistributableService
@inject ILogger<PackagingDialog> Logger
@implements IDisposable
<div class="packaging-dialog">
@if (_status == PackagingStatus.Complete)
{
<Result Status="ResultStatus.Success"
Title="Packaging Complete"
SubTitle="@($"Completed in {FormatTimeSpan(_elapsed)}")">
</Result>
}
else if (_status == PackagingStatus.Failed)
{
<Result Status="ResultStatus.Error"
Title="Packaging Failed"
SubTitle="@(_errorMessage ?? "The packaging script encountered an error.")">
</Result>
}
else if (_status == PackagingStatus.Stopped)
{
<Result Status="ResultStatus.Warning"
Title="Packaging Stopped"
SubTitle="The packaging script was stopped by the user.">
</Result>
}
<div class="packaging-terminal">
@if (_status == PackagingStatus.Running)
{
<Flex Justify="FlexJustify.SpaceBetween">
<span>@FormatElapsed()</span>
<Tooltip Title="Stop">
<Button Type="ButtonType.Text" Icon="@IconType.Outline.Stop"/>
</Tooltip>
</Flex>
}
<Terminal @ref="_terminal" Id="@_id.ToString()" Options="_terminalOptions" OnFirstRender="OnTerminalReady" />
</div>
</div>
@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();
}
}

View file

@ -27,11 +27,6 @@
</TitleTemplate>
<TitleExtraTemplate>
<Flex Gap="FlexGap.Small" Justify="FlexJustify.End">
@if (context != null && (context.Scripts?.Any(s => s.Type == ScriptType.Package) ?? false))
{
<Button OnClick="Package" Loading="_packaging">Package</Button>
}
@if (context != null && context.Id != Guid.Empty)
{
<Button OnClick="OpenExportDialog" Loading="_openingExportDialog">Export</Button>
@ -172,7 +167,6 @@
bool _loaded;
bool _success;
bool _packaging;
bool _openingExportDialog;
IEnumerable<Engine> Engines = new List<Engine>();
@ -318,12 +312,4 @@
await Task.Yield();
}
private async Task Package()
{
_packaging = true;
await GameService.PackageAsync(Id);
_packaging = false;
}
}

View file

@ -21,11 +21,6 @@
</TitleTemplate>
<TitleExtraTemplate>
<Flex Gap="FlexGap.Small" Justify="FlexJustify.End">
@if (context != null && (context.Scripts?.Any(s => s.Type == ScriptType.Package) ?? false))
{
<Button OnClick="Package" Loading="_packaging">Package</Button>
}
@if (context != null && context.Id != Guid.Empty)
{
<Button OnClick="OpenExportDialog" Loading="_openingExportDialog">Export</Button>
@ -71,7 +66,6 @@
ICollection<Game> 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;
}
}

View file

@ -21,11 +21,6 @@
</TitleTemplate>
<TitleExtraTemplate>
<Flex Gap="FlexGap.Small" Justify="FlexJustify.End">
@if (context != null && (context.Scripts?.Any(s => s.Type == ScriptType.Package) ?? false))
{
<Button OnClick="Package" Loading="_packaging">Package</Button>
}
@if (context != null && context.Id != Guid.Empty)
{
<Button OnClick="OpenExportDialog" Loading="_openingExportDialog">Export</Button>
@ -71,7 +66,6 @@
ICollection<Game> 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;
}
}