Packager app improvements, metadata endpoints

- Add ability to connect to server
- Add uploading of finished package to server
- Add metadata lookup when connected to server
- Fix layout when specifying action
- Hide finish button at generate step
This commit is contained in:
Pat Hartl 2026-06-03 19:43:45 -05:00
parent 90d04d1e5e
commit 819cb28d17
23 changed files with 1025 additions and 49 deletions

View file

@ -122,7 +122,7 @@
<ItemGroup Label="Microsoft Extensions">
<PackageVersion Include="Microsoft.Extensions.Configuration" Version="9.0.8" />
<PackageVersion Include="Microsoft.Extensions.Configuration.Binder" Version="9.0.9" />
<PackageVersion Include="Microsoft.Extensions.DependencyInjection" Version="9.0.1" />
<PackageVersion Include="Microsoft.Extensions.DependencyInjection" Version="9.0.8" />
<PackageVersion Include="Microsoft.Extensions.DependencyInjection.Abstractions" Version="9.0.9" />
<PackageVersion Include="Microsoft.Extensions.Hosting" Version="9.0.1" />
<PackageVersion Include="Microsoft.Extensions.Hosting.Abstractions" Version="9.0.9" />

View file

@ -15,7 +15,7 @@ public partial class App : Application
{
if (ApplicationLifetime is IClassicDesktopStyleApplicationLifetime desktop)
{
desktop.MainWindow = new MainWindow(Program.Context);
desktop.MainWindow = new MainWindow(Program.Context, Program.Services);
}
base.OnFrameworkInitializationCompleted();

View file

@ -23,6 +23,14 @@
<PackageReference Include="Avalonia.Fonts.Inter" />
<PackageReference Include="CommandLineParser" />
<PackageReference Include="LANCommander.Interposer" />
<PackageReference Include="Microsoft.Extensions.DependencyInjection" />
<PackageReference Include="Microsoft.Extensions.Http" />
<PackageReference Include="Microsoft.Extensions.Configuration" />
<PackageReference Include="Microsoft.Extensions.Options" />
<PackageReference Include="Microsoft.Extensions.Options.ConfigurationExtensions" />
<PackageReference Include="Microsoft.Extensions.Logging.Console" />
<PackageReference Include="NetEscapades.Configuration.Yaml" />
<PackageReference Include="YamlDotNet" />
</ItemGroup>
<ItemGroup>

View file

@ -9,9 +9,24 @@
Background="{DynamicResource BackgroundBrush}"
Icon="avares://LANCommander.Packager/LANCommanderDark.ico">
<Grid RowDefinitions="*,Auto">
<Grid RowDefinitions="Auto,*,Auto">
<!-- Server connection bar -->
<Border Grid.Row="0" Background="#1A1A1A" BorderBrush="#2A2A2A"
BorderThickness="0,0,0,1" Padding="12,6">
<Grid>
<StackPanel Orientation="Horizontal" VerticalAlignment="Center" Spacing="8">
<Ellipse Name="StatusDot" Width="8" Height="8" Fill="#555555" VerticalAlignment="Center" />
<TextBlock Name="AuthStatusLabel" Text="Not connected"
VerticalAlignment="Center" Opacity="0.5"
Foreground="{DynamicResource TextPrimaryBrush}" FontSize="12" />
</StackPanel>
<Button Name="ConnectButton" Content="Connect to Server"
HorizontalAlignment="Right" FontSize="12" Padding="12,4" />
</Grid>
</Border>
<!-- Main area -->
<Grid ColumnDefinitions="220,*">
<Grid Grid.Row="1" ColumnDefinitions="220,*">
<!-- Step sidebar -->
<Border Background="#1A1A1A" BorderBrush="#2A2A2A" BorderThickness="0,0,1,0">
<ItemsControl Name="StepList" Margin="24,0" VerticalAlignment="Center">
@ -72,7 +87,7 @@
</Grid>
<!-- Bottom navigation -->
<Border Grid.Row="1" Background="#1A1A1A" BorderBrush="#2A2A2A"
<Border Grid.Row="2" Background="#1A1A1A" BorderBrush="#2A2A2A"
BorderThickness="0,1,0,0" Padding="20,12">
<Grid>
<Button Name="CancelButton" Content="Cancel" HorizontalAlignment="Left" />

View file

@ -1,22 +1,30 @@
using System.Collections.ObjectModel;
using Avalonia.Controls;
using Avalonia.Interactivity;
using Avalonia.Media;
using Avalonia.Platform.Storage;
using Avalonia.Threading;
using LANCommander.Packager.Models;
using LANCommander.Packager.Views;
using LANCommander.SDK.Abstractions;
using LANCommander.SDK.Clients;
using LANCommander.SDK.Factories;
using LANCommander.SDK.Services;
using Microsoft.Extensions.DependencyInjection;
namespace LANCommander.Packager;
public partial class MainWindow : Window
{
private readonly PackageContext _context;
private readonly IServiceProvider _services;
private readonly UserControl[] _steps;
private readonly string[] _stepTitles;
private readonly string[] _stepHelps;
private readonly ObservableCollection<WizardStepItem> _stepItems;
private int _currentStep;
private bool _monitoringComplete;
private bool _isAuthenticated;
private readonly MonitoringView _monitoringView;
private readonly InstallDirectoryView _installDirView;
@ -26,20 +34,35 @@ public partial class MainWindow : Window
private readonly ActionView _actionView;
private readonly OutputView _outputView;
public MainWindow() : this(new PackageContext()) { }
private readonly AuthenticationClient _authClient;
private readonly IConnectionClient _connectionClient;
private readonly ISettingsProvider _settingsProvider;
private readonly ITokenProvider _tokenProvider;
private readonly MetadataClient _metadataClient;
private readonly ApiRequestFactory _apiRequestFactory;
public MainWindow(PackageContext context)
public MainWindow() : this(new PackageContext(), null!) { }
public MainWindow(PackageContext context, IServiceProvider services)
{
_context = context;
_services = services;
InitializeComponent();
_authClient = _services.GetRequiredService<AuthenticationClient>();
_connectionClient = _services.GetRequiredService<IConnectionClient>();
_settingsProvider = _services.GetRequiredService<ISettingsProvider>();
_tokenProvider = _services.GetRequiredService<ITokenProvider>();
_metadataClient = _services.GetRequiredService<MetadataClient>();
_apiRequestFactory = _services.GetRequiredService<ApiRequestFactory>();
_monitoringView = new MonitoringView(context);
_installDirView = new InstallDirectoryView(context);
_fileSelectionView = new FileSelectionView(context);
_registrySelectionView = new RegistrySelectionView(context);
_metadataView = new MetadataView(context);
_metadataView = new MetadataView(context, _metadataClient);
_actionView = new ActionView(context);
_outputView = new OutputView(context);
_outputView = new OutputView(context, _apiRequestFactory, _settingsProvider);
_monitoringView.MonitoringCompleted += () =>
{
@ -80,6 +103,7 @@ public partial class MainWindow : Window
BackButton.Click += OnBackClick;
NextButton.Click += OnNextClick;
CancelButton.Click += (_, _) => Close();
ConnectButton.Click += OnConnectClick;
GoToStep(0);
}
@ -87,9 +111,73 @@ public partial class MainWindow : Window
protected override async void OnOpened(EventArgs e)
{
base.OnOpened(e);
await CheckExistingAuthAsync();
await StartFirstStep();
}
private async Task CheckExistingAuthAsync()
{
try
{
var token = _tokenProvider.GetToken();
if (token == null || string.IsNullOrEmpty(token.AccessToken))
return;
var serverAddress = _settingsProvider.CurrentValue.Authentication.ServerAddress;
if (serverAddress != null)
await _connectionClient.UpdateServerAddressAsync(serverAddress);
var valid = await _authClient.ValidateTokenAsync();
if (valid)
SetAuthenticated(true);
}
catch
{
// Token expired or server unreachable - stay disconnected
}
}
private void SetAuthenticated(bool authenticated)
{
_isAuthenticated = authenticated;
Dispatcher.UIThread.Post(() =>
{
if (authenticated)
{
var address = _settingsProvider.CurrentValue.Authentication.ServerAddress;
AuthStatusLabel.Text = $"Connected to {address}";
AuthStatusLabel.Opacity = 0.8;
StatusDot.Fill = new SolidColorBrush(Color.Parse("#49AA19"));
ConnectButton.Content = "Server Settings";
}
else
{
AuthStatusLabel.Text = "Not connected";
AuthStatusLabel.Opacity = 0.5;
StatusDot.Fill = new SolidColorBrush(Color.Parse("#555555"));
ConnectButton.Content = "Connect to Server";
}
_metadataView.SetAuthenticated(authenticated);
_outputView.SetAuthenticated(authenticated);
});
}
private async void OnConnectClick(object? sender, RoutedEventArgs e)
{
var dialog = new ConnectDialog(_authClient, _connectionClient, _settingsProvider);
var result = await dialog.ShowDialog<bool?>(this);
if (result == true && dialog.IsAuthenticated)
SetAuthenticated(true);
else if (!dialog.IsAuthenticated)
SetAuthenticated(false);
}
private async Task StartFirstStep()
{
if (string.IsNullOrWhiteSpace(_context.InstallerPath))
@ -166,7 +254,7 @@ public partial class MainWindow : Window
}
BackButton.IsVisible = step > 0;
NextButton.Content = step == _steps.Length - 1 ? "Finish" : "Next";
NextButton.IsVisible = step != _steps.Length - 1;
NextButton.IsEnabled = step != 0 || _monitoringComplete;
EnterStep(step);

View file

@ -0,0 +1,5 @@
namespace LANCommander.Packager.Models;
public class PackagerSettings : LANCommander.SDK.Models.Settings
{
}

View file

@ -1,12 +1,17 @@
using Avalonia;
using CommandLine;
using LANCommander.Packager.Models;
using LANCommander.SDK.Extensions;
using Microsoft.Extensions.Configuration;
using Microsoft.Extensions.DependencyInjection;
using Microsoft.Extensions.Logging;
namespace LANCommander.Packager;
internal static class Program
{
public static PackageContext Context { get; } = new();
public static IServiceProvider Services { get; private set; } = null!;
[STAThread]
public static void Main(string[] args)
@ -16,14 +21,41 @@ internal static class Program
{
if (!string.IsNullOrWhiteSpace(options.InstallerPath))
Context.InstallerPath = options.InstallerPath;
if (!string.IsNullOrWhiteSpace(options.OutputPath))
Context.OutputPath = options.OutputPath;
});
ConfigureServices();
BuildAvaloniaApp().StartWithClassicDesktopLifetime(args);
}
private static void ConfigureServices()
{
var services = new ServiceCollection();
services.AddLogging(builder =>
{
builder.AddConsole();
builder.SetMinimumLevel(LogLevel.Warning);
});
services.AddHttpClient();
var configurationBuilder = new ConfigurationBuilder();
var configuration = configurationBuilder.ReadFromFile<PackagerSettings>();
var refresher = configurationBuilder.ReadFromServer<PackagerSettings>(configuration);
configuration = configurationBuilder.Build();
services.Configure<PackagerSettings>(configuration);
services.AddSingleton(refresher);
services.AddLANCommanderClient<PackagerSettings>();
Services = services.BuildServiceProvider();
}
public static AppBuilder BuildAvaloniaApp()
=> AppBuilder.Configure<App>()
.UsePlatformDetect()

View file

@ -258,27 +258,43 @@ public class InstallerMonitorService : IDisposable
if (filePaths.Count == 0)
return string.Empty;
var directoryGroups = filePaths
.GroupBy(p => p, StringComparer.OrdinalIgnoreCase)
.OrderByDescending(g => g.Count())
.ToList();
// Filter to non-system, non-ignored paths first
var nonSystemPaths = filePaths.Where(p => !IsIgnoredPath(p) && !IsSystemPath(p)).ToList();
foreach (var group in directoryGroups)
if (nonSystemPaths.Count > 0)
{
var dir = group.Key;
if (!IsIgnoredPath(dir) && !IsSystemPath(dir))
return dir;
// Find the common ancestor of all game file paths. This handles
// installers that write many files into deep subdirectories
// (e.g. Sounds/, _CD/SETUP/) — picking the deepest single
// directory with the most files would miss the actual root.
var ancestor = FindCommonAncestor(nonSystemPaths);
// If the ancestor is meaningful (not just a drive root like "G:\"),
// use it. Otherwise fall back to the most-frequent directory.
if (!string.IsNullOrEmpty(ancestor) && !IsDriveRoot(ancestor))
return ancestor;
// Fall back: pick the most frequent non-system directory
var directoryGroups = nonSystemPaths
.GroupBy(p => p, StringComparer.OrdinalIgnoreCase)
.OrderByDescending(g => g.Count())
.ToList();
if (directoryGroups.Count > 0)
return directoryGroups[0].Key;
}
var nonSystemPaths = filePaths.Where(p => !IsIgnoredPath(p) && !IsSystemPath(p)).ToList();
if (nonSystemPaths.Count > 0)
return FindCommonAncestor(nonSystemPaths);
return filePaths.First();
}
private static bool IsDriveRoot(string path)
{
var root = Path.GetPathRoot(path);
return string.Equals(path.TrimEnd(Path.DirectorySeparatorChar),
root?.TrimEnd(Path.DirectorySeparatorChar),
StringComparison.OrdinalIgnoreCase);
}
private static string FindCommonAncestor(List<string> paths)
{
if (paths.Count == 0)

View file

@ -1,26 +1,29 @@
<UserControl xmlns="https://github.com/avaloniaui"
xmlns:x="http://schemas.microsoft.com/winfx/2006/xaml"
x:Class="LANCommander.Packager.Views.ActionView">
<DockPanel>
<Grid DockPanel.Dock="Bottom" ColumnDefinitions="Auto,*" RowDefinitions="Auto,Auto"
Margin="0,16,0,0">
<TextBlock Text="Action Name" Opacity="0.7" Grid.Row="0" Grid.Column="0"
<Grid RowDefinitions="*,Auto">
<!-- Executable list -->
<DockPanel Grid.Row="0">
<TextBlock DockPanel.Dock="Top" Text="Select primary executable:" Opacity="0.7"
Margin="0,0,0,4" />
<TextBlock Text="Arguments" Opacity="0.7" Grid.Row="0" Grid.Column="1"
Margin="8,0,0,4" />
<TextBox Name="ActionNameField" Text="Play" Grid.Row="1" Grid.Column="0"
Width="150" />
<TextBox Name="ArgumentsField" Grid.Row="1" Grid.Column="1"
Margin="8,0,0,0" />
</Grid>
<StackPanel Spacing="4">
<TextBlock Text="Select primary executable:" Opacity="0.7" />
<ListBox Name="ExeList"
Background="Transparent"
BorderThickness="1"
BorderBrush="#2A2A2A"
CornerRadius="4" />
</StackPanel>
</DockPanel>
</DockPanel>
<!-- Action definition -->
<Grid Grid.Row="1" ColumnDefinitions="Auto,*" RowDefinitions="Auto,Auto"
Margin="0,16,0,0">
<TextBlock Text="Action Name" Opacity="0.7" Grid.Row="0" Grid.Column="0"
Margin="0,0,0,4" />
<TextBlock Text="Arguments" Opacity="0.7" Grid.Row="0" Grid.Column="1"
Margin="8,0,0,4" />
<TextBox Name="ActionNameField" Grid.Row="1" Grid.Column="0"
Width="200" />
<TextBox Name="ArgumentsField" Grid.Row="1" Grid.Column="1"
Margin="8,0,0,0" />
</Grid>
</Grid>
</UserControl>

View file

@ -35,6 +35,9 @@ public partial class ActionView : UserControl
var bestGuess = FindBestExecutable();
ExeList.SelectedIndex = bestGuess;
}
var title = _context.Manifest.Title;
ActionNameField.Text = string.IsNullOrWhiteSpace(title) ? "Play" : $"Play {title}";
}
public void ApplyAction()

View file

@ -0,0 +1,39 @@
<Window xmlns="https://github.com/avaloniaui"
xmlns:x="http://schemas.microsoft.com/winfx/2006/xaml"
x:Class="LANCommander.Packager.Views.ConnectDialog"
Title="Connect to Server"
Width="400" Height="320"
WindowStartupLocation="CenterOwner"
CanResize="False"
Background="{DynamicResource BackgroundBrush}">
<StackPanel Margin="24" Spacing="16">
<TextBlock Text="Connect to LANCommander Server" FontSize="16" FontWeight="SemiBold"
Foreground="{DynamicResource TextPrimaryBrush}" />
<StackPanel Spacing="4">
<TextBlock Text="Server Address" Opacity="0.7" />
<TextBox Name="ServerAddressField" Watermark="http://192.168.1.100:1337" />
</StackPanel>
<StackPanel Spacing="4">
<TextBlock Text="Username" Opacity="0.7" />
<TextBox Name="UsernameField" />
</StackPanel>
<StackPanel Spacing="4">
<TextBlock Text="Password" Opacity="0.7" />
<TextBox Name="PasswordField" PasswordChar="*" />
</StackPanel>
<TextBlock Name="ErrorLabel" Foreground="{DynamicResource ErrorBrush}"
TextWrapping="Wrap" IsVisible="False" />
<Grid ColumnDefinitions="*,Auto,Auto" Margin="0,8,0,0">
<Button Name="DisconnectButton" Content="Disconnect" Grid.Column="0"
HorizontalAlignment="Left" IsVisible="False" />
<Button Name="CancelButton" Content="Cancel" Grid.Column="1" Margin="0,0,8,0" />
<Button Name="ConnectButton" Content="Connect" Grid.Column="2" Classes="Primary" />
</Grid>
</StackPanel>
</Window>

View file

@ -0,0 +1,109 @@
using Avalonia.Controls;
using Avalonia.Interactivity;
using LANCommander.SDK.Abstractions;
using LANCommander.SDK.Clients;
using LANCommander.SDK.Services;
namespace LANCommander.Packager.Views;
public partial class ConnectDialog : Window
{
private readonly AuthenticationClient _authClient;
private readonly IConnectionClient _connectionClient;
private readonly ISettingsProvider _settingsProvider;
public bool IsAuthenticated { get; private set; }
public ConnectDialog()
{
InitializeComponent();
}
public ConnectDialog(
AuthenticationClient authClient,
IConnectionClient connectionClient,
ISettingsProvider settingsProvider)
{
_authClient = authClient;
_connectionClient = connectionClient;
_settingsProvider = settingsProvider;
InitializeComponent();
var currentAddress = _settingsProvider.CurrentValue.Authentication.ServerAddress;
if (currentAddress != null)
ServerAddressField.Text = currentAddress.ToString();
CancelButton.Click += (_, _) => Close(false);
ConnectButton.Click += OnConnectClick;
DisconnectButton.Click += OnDisconnectClick;
var token = _settingsProvider.CurrentValue.Authentication.Token;
if (token != null && !string.IsNullOrEmpty(token.AccessToken))
{
DisconnectButton.IsVisible = true;
}
}
private async void OnConnectClick(object? sender, RoutedEventArgs e)
{
ErrorLabel.IsVisible = false;
ConnectButton.IsEnabled = false;
ConnectButton.Content = "Connecting...";
try
{
var address = ServerAddressField.Text?.Trim();
var username = UsernameField.Text?.Trim();
var password = PasswordField.Text;
if (string.IsNullOrWhiteSpace(address))
{
ShowError("Please enter a server address.");
return;
}
if (string.IsNullOrWhiteSpace(username) || string.IsNullOrWhiteSpace(password))
{
ShowError("Please enter username and password.");
return;
}
if (!Uri.TryCreate(address, UriKind.Absolute, out var uri))
uri = new Uri($"http://{address}");
await _connectionClient.UpdateServerAddressAsync(uri);
await _authClient.AuthenticateAsync(username, password, uri);
IsAuthenticated = true;
Close(true);
}
catch (Exception ex)
{
ShowError(ex.Message);
}
finally
{
ConnectButton.IsEnabled = true;
ConnectButton.Content = "Connect";
}
}
private void OnDisconnectClick(object? sender, RoutedEventArgs e)
{
_settingsProvider.Update(s =>
{
s.Authentication.Token = null;
s.Authentication.ServerAddress = null;
});
IsAuthenticated = false;
DisconnectButton.IsVisible = false;
Close(false);
}
private void ShowError(string message)
{
ErrorLabel.Text = message;
ErrorLabel.IsVisible = true;
}
}

View file

@ -0,0 +1,68 @@
<Window xmlns="https://github.com/avaloniaui"
xmlns:x="http://schemas.microsoft.com/winfx/2006/xaml"
x:Class="LANCommander.Packager.Views.MetadataSearchDialog"
Title="Metadata Lookup"
Width="650" Height="500"
WindowStartupLocation="CenterOwner"
Background="{DynamicResource BackgroundBrush}">
<Grid RowDefinitions="Auto,*,Auto" Margin="20">
<!-- Search controls -->
<StackPanel Grid.Row="0" Spacing="12" Margin="0,0,0,16">
<StackPanel Orientation="Horizontal" Spacing="8">
<ComboBox Name="ProviderCombo" HorizontalAlignment="Stretch" MinWidth="200" />
<ComboBox Name="SubProviderCombo" Width="180"
IsVisible="False" />
</StackPanel>
<StackPanel Orientation="Horizontal" Spacing="8">
<TextBox Name="SearchField" Watermark="Search for a game..."
KeyDown="OnSearchKeyDown" MinWidth="400" />
<Button Name="SearchButton" Content="Search" Classes="Primary"
Padding="16,6" />
</StackPanel>
</StackPanel>
<!-- Results -->
<Border Grid.Row="1" BorderBrush="#2A2A2A" BorderThickness="1" CornerRadius="4">
<Grid>
<ListBox Name="ResultsList" Background="Transparent"
SelectionMode="Single">
<ListBox.ItemTemplate>
<DataTemplate>
<Grid ColumnDefinitions="*,Auto" Margin="4,6">
<TextBlock Text="{Binding Title}" FontSize="14"
Foreground="{DynamicResource TextPrimaryBrush}"
VerticalAlignment="Center" />
<TextBlock Grid.Column="1"
Text="{Binding ReleasedOn, StringFormat='{}{0:yyyy-MM-dd}'}"
Opacity="0.5" FontSize="12"
VerticalAlignment="Center" />
</Grid>
</DataTemplate>
</ListBox.ItemTemplate>
</ListBox>
<TextBlock Name="EmptyLabel" Text="Search for a game to see results"
HorizontalAlignment="Center" VerticalAlignment="Center"
Opacity="0.4" IsVisible="True" />
<StackPanel Name="LoadingPanel" Orientation="Horizontal" Spacing="8"
HorizontalAlignment="Center" VerticalAlignment="Center"
IsVisible="False">
<TextBlock Text="Searching..." Opacity="0.5" />
</StackPanel>
</Grid>
</Border>
<!-- Pagination and actions -->
<Grid Grid.Row="2" ColumnDefinitions="Auto,*,Auto,Auto" Margin="0,12,0,0">
<StackPanel Grid.Column="0" Orientation="Horizontal" Spacing="8">
<Button Name="PrevButton" Content="Previous" IsVisible="False" />
<Button Name="MoreButton" Content="Load More" IsVisible="False" />
</StackPanel>
<Button Name="CancelDialogButton" Content="Cancel" Grid.Column="2" Margin="0,0,8,0" />
<Button Name="SelectButton" Content="Select" Grid.Column="3" Classes="Primary"
IsEnabled="False" />
</Grid>
</Grid>
</Window>

View file

@ -0,0 +1,224 @@
using System.Collections.ObjectModel;
using Avalonia.Controls;
using Avalonia.Input;
using Avalonia.Interactivity;
using LANCommander.SDK.Models;
using LANCommander.SDK.Services;
using Game = LANCommander.SDK.Models.Manifest.Game;
using Key = Avalonia.Input.Key;
namespace LANCommander.Packager.Views;
public partial class MetadataSearchDialog : Window
{
private readonly MetadataClient _metadataClient;
private readonly ObservableCollection<Game> _results = new();
private readonly List<MetadataSearchResult> _rawResults = new();
private string? _selectedProvider;
private int _offset;
private bool _hasMore;
public Game? SelectedGame { get; private set; }
public MetadataSearchDialog()
{
InitializeComponent();
}
public MetadataSearchDialog(MetadataClient metadataClient, string? defaultSearch = null)
{
_metadataClient = metadataClient;
InitializeComponent();
ResultsList.ItemsSource = _results;
ResultsList.SelectionChanged += (_, _) =>
SelectButton.IsEnabled = ResultsList.SelectedItem != null;
SearchButton.Click += async (_, _) => await SearchAsync();
SelectButton.Click += OnSelectClick;
CancelDialogButton.Click += (_, _) => Close(null);
MoreButton.Click += async (_, _) => await LoadMoreAsync();
ProviderCombo.SelectionChanged += async (_, _) =>
{
_selectedProvider = ProviderCombo.SelectedItem as string;
if (_selectedProvider != null)
await LoadSubProvidersAsync(_selectedProvider);
};
if (!string.IsNullOrWhiteSpace(defaultSearch))
SearchField.Text = defaultSearch;
_ = LoadProvidersAsync();
}
private async Task LoadProvidersAsync()
{
try
{
var providers = await _metadataClient.GetProvidersAsync();
var list = providers.ToList();
ProviderCombo.ItemsSource = list;
if (list.Count > 0)
ProviderCombo.SelectedIndex = 0;
}
catch (Exception ex)
{
EmptyLabel.Text = $"Failed to load providers: {ex.Message}";
}
}
private async Task LoadSubProvidersAsync(string provider)
{
try
{
var subProviders = await _metadataClient.GetSubProvidersAsync(provider);
var list = subProviders?.ToList();
if (list != null && list.Count > 0)
{
SubProviderCombo.ItemsSource = list.Select(sp => sp.Name).ToList();
SubProviderCombo.IsVisible = true;
SubProviderCombo.SelectedIndex = 0;
}
else
{
SubProviderCombo.IsVisible = false;
SubProviderCombo.SelectedItem = null;
}
}
catch
{
SubProviderCombo.IsVisible = false;
}
}
private void OnSearchKeyDown(object? sender, KeyEventArgs e)
{
if (e.Key == Key.Enter)
_ = SearchAsync();
}
private async Task SearchAsync()
{
var query = SearchField.Text?.Trim();
if (string.IsNullOrWhiteSpace(query) || _selectedProvider == null)
return;
_offset = 0;
_results.Clear();
_rawResults.Clear();
LoadingPanel.IsVisible = true;
EmptyLabel.IsVisible = false;
SearchButton.IsEnabled = false;
try
{
var subProvider = SubProviderCombo.IsVisible
? SubProviderCombo.SelectedItem as string
: null;
var results = await _metadataClient.SearchAsync(_selectedProvider, query, subProvider, 10, 0);
if (results?.Results != null)
{
foreach (var result in results.Results)
{
_rawResults.Add(result);
_results.Add(result.Data);
}
_hasMore = results.More;
_offset = results.Offset + results.Limit;
MoreButton.IsVisible = _hasMore;
}
if (_results.Count == 0)
EmptyLabel.Text = "No results found.";
EmptyLabel.IsVisible = _results.Count == 0;
}
catch (Exception ex)
{
EmptyLabel.Text = $"Search failed: {ex.Message}";
EmptyLabel.IsVisible = true;
}
finally
{
LoadingPanel.IsVisible = false;
SearchButton.IsEnabled = true;
}
}
private async Task LoadMoreAsync()
{
if (_selectedProvider == null)
return;
var query = SearchField.Text?.Trim();
if (string.IsNullOrWhiteSpace(query))
return;
MoreButton.IsEnabled = false;
try
{
var subProvider = SubProviderCombo.IsVisible
? SubProviderCombo.SelectedItem as string
: null;
var results = await _metadataClient.SearchAsync(_selectedProvider, query, subProvider, 10, _offset);
if (results?.Results != null)
{
foreach (var result in results.Results)
{
_rawResults.Add(result);
_results.Add(result.Data);
}
_hasMore = results.More;
_offset = results.Offset + results.Limit;
}
MoreButton.IsVisible = _hasMore;
}
catch
{
MoreButton.IsVisible = false;
}
finally
{
MoreButton.IsEnabled = true;
}
}
private async void OnSelectClick(object? sender, RoutedEventArgs e)
{
var selectedIndex = ResultsList.SelectedIndex;
if (selectedIndex < 0 || selectedIndex >= _rawResults.Count || _selectedProvider == null)
return;
SelectButton.IsEnabled = false;
SelectButton.Content = "Loading...";
try
{
var rawResult = _rawResults[selectedIndex];
var fullGame = await _metadataClient.GetGameAsync(_selectedProvider, rawResult.Id);
SelectedGame = fullGame;
Close(fullGame);
}
catch (Exception ex)
{
EmptyLabel.Text = $"Failed to load game details: {ex.Message}";
EmptyLabel.IsVisible = true;
SelectButton.IsEnabled = true;
SelectButton.Content = "Select";
}
}
}

View file

@ -3,6 +3,9 @@
x:Class="LANCommander.Packager.Views.MetadataView">
<ScrollViewer>
<StackPanel Spacing="12" MaxWidth="500">
<Button Name="LookupButton" Content="Lookup Metadata..." IsEnabled="False"
HorizontalAlignment="Left" Padding="12,6" Margin="0,0,0,4" />
<StackPanel Spacing="4">
<TextBlock Text="Title *" Opacity="0.7" />
<TextBox Name="TitleField" />

View file

@ -1,22 +1,106 @@
using Avalonia.Controls;
using LANCommander.Packager.Models;
using LANCommander.SDK.Models.Manifest;
using LANCommander.SDK.Services;
namespace LANCommander.Packager.Views;
public partial class MetadataView : UserControl
{
private readonly PackageContext _context;
private readonly MetadataClient? _metadataClient;
public MetadataView(PackageContext context)
public MetadataView(PackageContext context) : this(context, null) { }
public MetadataView(PackageContext context, MetadataClient? metadataClient)
{
_context = context;
_metadataClient = metadataClient;
InitializeComponent();
ReleasedOnPicker.SelectedDate = DateTime.Today;
LookupButton.Click += async (_, _) => await OnLookupClick();
}
public void SetAuthenticated(bool authenticated)
{
LookupButton.IsEnabled = authenticated && _metadataClient != null;
}
public void SetDefaultTitle(string title)
{
TitleField.Text = title;
if (string.IsNullOrWhiteSpace(TitleField.Text))
TitleField.Text = title;
}
private async Task OnLookupClick()
{
if (_metadataClient == null)
return;
var topLevel = TopLevel.GetTopLevel(this);
if (topLevel is not Window window)
return;
var dialog = new MetadataSearchDialog(_metadataClient, TitleField.Text);
var result = await dialog.ShowDialog<Game?>(window);
if (result == null)
return;
PopulateFromGame(result);
}
private void PopulateFromGame(Game game)
{
if (!string.IsNullOrWhiteSpace(game.Title))
TitleField.Text = game.Title;
if (!string.IsNullOrWhiteSpace(game.SortTitle))
SortTitleField.Text = game.SortTitle;
if (!string.IsNullOrWhiteSpace(game.Description))
DescriptionField.Text = game.Description;
if (!string.IsNullOrWhiteSpace(game.Notes))
NotesField.Text = game.Notes;
if (game.ReleasedOn != default)
ReleasedOnPicker.SelectedDate = game.ReleasedOn;
SingleplayerCheckbox.IsChecked = game.Singleplayer;
var manifest = _context.Manifest;
if (game.Genres.Count > 0)
manifest.Genres = game.Genres;
if (game.Tags.Count > 0)
manifest.Tags = game.Tags;
if (game.Developers.Count > 0)
manifest.Developers = game.Developers;
if (game.Publishers.Count > 0)
manifest.Publishers = game.Publishers;
if (game.Platforms.Count > 0)
manifest.Platforms = game.Platforms;
if (game.MultiplayerModes.Count > 0)
manifest.MultiplayerModes = game.MultiplayerModes;
if (game.Collections.Count > 0)
manifest.Collections = game.Collections;
if (game.ExternalIds.Count > 0)
manifest.ExternalIds = game.ExternalIds;
if (game.Engine != null)
manifest.Engine = game.Engine;
if (game.Type != default)
manifest.Type = game.Type;
}
public void ApplyMetadata()

View file

@ -2,17 +2,21 @@
xmlns:x="http://schemas.microsoft.com/winfx/2006/xaml"
x:Class="LANCommander.Packager.Views.OutputView">
<DockPanel>
<!-- Generate button and progress pinned to bottom -->
<!-- Generate button, upload button, and progress pinned to bottom -->
<StackPanel DockPanel.Dock="Bottom" Spacing="8">
<Button Name="GenerateButton" Content="Generate .LCX" Classes="Primary"
HorizontalAlignment="Center" Padding="32,12" FontSize="16"
Margin="0,8" />
<ProgressBar Name="Progress" Minimum="0" Maximum="1" Value="0" Height="8"
IsVisible="False" />
<TextBlock Name="StatusLabel" TextWrapping="Wrap" HorizontalAlignment="Center"
Foreground="{DynamicResource TextSecondaryBrush}" />
<StackPanel Orientation="Horizontal" HorizontalAlignment="Center" Spacing="12"
Margin="0,8">
<Button Name="GenerateButton" Content="Generate .LCX" Classes="Primary"
Padding="32,12" FontSize="16" />
<Button Name="UploadButton" Content="Upload to Server" Classes="Success"
Padding="32,12" FontSize="16" IsVisible="False" />
</StackPanel>
</StackPanel>
<!-- Scrollable content area -->

View file

@ -5,16 +5,26 @@ using Avalonia.Platform.Storage;
using Avalonia.Threading;
using LANCommander.Packager.Models;
using LANCommander.Packager.Services;
using LANCommander.SDK.Abstractions;
using LANCommander.SDK.Factories;
using LANCommander.SDK.Services;
namespace LANCommander.Packager.Views;
public partial class OutputView : UserControl
{
private readonly PackageContext _context;
private readonly ApiRequestFactory? _apiRequestFactory;
private readonly ISettingsProvider? _settingsProvider;
private bool _packageGenerated;
public OutputView(PackageContext context)
public OutputView(PackageContext context) : this(context, null, null) { }
public OutputView(PackageContext context, ApiRequestFactory? apiRequestFactory, ISettingsProvider? settingsProvider)
{
_context = context;
_apiRequestFactory = apiRequestFactory;
_settingsProvider = settingsProvider;
InitializeComponent();
GenerateButton.Click += async (s, e) =>
@ -22,6 +32,11 @@ public partial class OutputView : UserControl
await GeneratePackageAsync();
};
UploadButton.Click += async (s, e) =>
{
await UploadPackageAsync();
};
BrowseButton.Click += async (s, e) =>
{
var topLevel = TopLevel.GetTopLevel(this);
@ -40,6 +55,11 @@ public partial class OutputView : UserControl
};
}
public void SetAuthenticated(bool authenticated)
{
UploadButton.IsVisible = authenticated && _apiRequestFactory != null;
}
public void SetDefaultOutputPath()
{
var title = _context.Manifest.Title ?? "Game";
@ -75,8 +95,10 @@ public partial class OutputView : UserControl
ApplyOptions();
GenerateButton.IsEnabled = false;
UploadButton.IsEnabled = false;
Progress.IsVisible = true;
Progress.Value = 0;
_packageGenerated = false;
var progress = new Progress<string>(message =>
{
@ -106,10 +128,13 @@ public partial class OutputView : UserControl
if (_context.WriteSummaryLog)
WriteSummaryLog(outputPath, sizeMb);
_packageGenerated = true;
Dispatcher.UIThread.Post(() =>
{
StatusLabel.Text = $"Package created successfully!\n{outputPath}\nSize: {sizeMb:F2} MB";
GenerateButton.IsEnabled = true;
UploadButton.IsEnabled = true;
Progress.IsVisible = false;
});
}
@ -119,11 +144,99 @@ public partial class OutputView : UserControl
{
StatusLabel.Text = $"Error: {ex.Message}";
GenerateButton.IsEnabled = true;
UploadButton.IsEnabled = true;
Progress.IsVisible = false;
});
}
}
private async Task UploadPackageAsync()
{
if (_apiRequestFactory == null || _settingsProvider == null)
return;
var outputPath = _context.OutputPath;
if (!_packageGenerated || string.IsNullOrWhiteSpace(outputPath) || !File.Exists(outputPath))
{
await GeneratePackageAsync();
if (!_packageGenerated)
return;
outputPath = _context.OutputPath;
}
UploadButton.IsEnabled = false;
GenerateButton.IsEnabled = false;
Progress.IsVisible = true;
Progress.Value = 0;
try
{
StatusLabel.Text = "Uploading package to server...";
Progress.IsIndeterminate = true;
var chunkSize = _settingsProvider.CurrentValue.Archives.UploadChunkSize;
var objectKey = await Task.Run(async () =>
{
using var fs = new FileStream(outputPath, FileMode.Open, FileAccess.Read);
return await _apiRequestFactory
.Create()
.UseAuthenticationToken()
.UseVersioning()
.UploadInChunksAsync(chunkSize, fs);
});
if (objectKey == Guid.Empty)
throw new Exception("Upload failed. Check that the server is reachable and you have permission to import games.");
Dispatcher.UIThread.Post(() => StatusLabel.Text = "Importing package on server...");
await Task.Run(async () =>
{
await _apiRequestFactory
.Create()
.UseAuthenticationToken()
.UseVersioning()
.UseRoute($"/api/Games/Import/{objectKey}")
.PostAsync();
});
try
{
if (File.Exists(outputPath))
File.Delete(outputPath);
}
catch { }
_packageGenerated = false;
Dispatcher.UIThread.Post(() =>
{
StatusLabel.Text = "Package uploaded and imported successfully!";
Progress.IsIndeterminate = false;
Progress.Value = 1;
Progress.IsVisible = false;
UploadButton.IsEnabled = true;
GenerateButton.IsEnabled = true;
});
}
catch (Exception ex)
{
Dispatcher.UIThread.Post(() =>
{
StatusLabel.Text = $"Upload failed: {ex.Message}";
Progress.IsIndeterminate = false;
Progress.IsVisible = false;
UploadButton.IsEnabled = true;
GenerateButton.IsEnabled = true;
});
}
}
private void WriteSummaryLog(string outputPath, double sizeMb)
{
var manifest = _context.Manifest;

View file

@ -0,0 +1,57 @@
using System;
using System.Collections.Generic;
using System.Threading.Tasks;
using LANCommander.SDK.Factories;
using LANCommander.SDK.Models;
using Game = LANCommander.SDK.Models.Manifest.Game;
namespace LANCommander.SDK.Services;
public class MetadataClient(ApiRequestFactory apiRequestFactory)
{
public async Task<IEnumerable<string>> GetProvidersAsync()
{
return await apiRequestFactory
.Create()
.UseAuthenticationToken()
.UseVersioning()
.UseRoute("/api/Metadata/Providers")
.GetAsync<IEnumerable<string>>();
}
public async Task<IEnumerable<MetadataSubProvider>> GetSubProvidersAsync(string provider)
{
return await apiRequestFactory
.Create()
.UseAuthenticationToken()
.UseVersioning()
.UseRoute($"/api/Metadata/{provider}/SubProviders")
.GetAsync<IEnumerable<MetadataSubProvider>>();
}
public async Task<MetadataSearchResultsCollection> SearchAsync(
string provider, string query, string? subProvider = null, int limit = 10, int offset = 0)
{
var route = $"/api/Metadata/{provider}/Search?query={Uri.EscapeDataString(query)}&limit={limit}&offset={offset}";
if (!string.IsNullOrWhiteSpace(subProvider))
route += $"&subProvider={Uri.EscapeDataString(subProvider)}";
return await apiRequestFactory
.Create()
.UseAuthenticationToken()
.UseVersioning()
.UseRoute(route)
.GetAsync<MetadataSearchResultsCollection>();
}
public async Task<Game> GetGameAsync(string provider, string gameId)
{
return await apiRequestFactory
.Create()
.UseAuthenticationToken()
.UseVersioning()
.UseRoute($"/api/Metadata/{provider}/{Uri.EscapeDataString(gameId)}")
.GetAsync<Game>();
}
}

View file

@ -53,6 +53,7 @@ public static class IServiceCollectionExtensions
services.AddSingleton<ServerClient>();
services.AddSingleton<TagClient>();
services.AddSingleton<ToolClient>();
services.AddSingleton<MetadataClient>();
services.AddSingleton<MigrationHistoryService>();
services.AddSingleton<MigrationService>();

View file

@ -0,0 +1,23 @@
using System.Collections.Generic;
namespace LANCommander.SDK.Models;
public class MetadataSearchResult
{
public string Id { get; set; } = string.Empty;
public Manifest.Game Data { get; set; } = new();
}
public class MetadataSearchResultsCollection
{
public ICollection<MetadataSearchResult> Results { get; set; } = new List<MetadataSearchResult>();
public bool More { get; set; }
public int Limit { get; set; }
public int Offset { get; set; }
}
public class MetadataSubProvider
{
public string Slug { get; set; } = string.Empty;
public string Name { get; set; } = string.Empty;
}

View file

@ -0,0 +1,80 @@
using LANCommander.Server.Services;
using Microsoft.AspNetCore.Mvc;
namespace LANCommander.Server.Endpoints;
public static class MetadataEndpoints
{
public static void MapMetadataEndpoints(this IEndpointRouteBuilder routes)
{
var group = routes.MapGroup("/api/Metadata").RequireAuthorization();
group.MapGet("/Providers", GetProvidersAsync);
group.MapGet("/{provider}/SubProviders", GetSubProvidersAsync);
group.MapGet("/{provider}/Search", SearchAsync);
group.MapGet("/{provider}/{gameId}", GetGameAsync);
}
private static async Task<IResult> GetProvidersAsync(
[FromServices] MetadataService metadataService)
{
var providers = metadataService.GetProviderNames();
return TypedResults.Ok(providers);
}
private static async Task<IResult> GetSubProvidersAsync(
string provider,
[FromServices] MetadataService metadataService)
{
var metadataProvider = metadataService.GetProvider(provider);
if (metadataProvider == null)
return TypedResults.NotFound();
var subProviders = await metadataProvider.GetSubProvidersAsync();
if (subProviders == null)
return TypedResults.Ok(Array.Empty<object>());
return TypedResults.Ok(subProviders.Select(sp => new { sp.Slug, sp.Name }));
}
private static async Task<IResult> SearchAsync(
string provider,
[FromQuery] string query,
[FromQuery] string? subProvider,
[FromQuery] int limit = 10,
[FromQuery] int offset = 0,
[FromServices] MetadataService metadataService = default!)
{
var metadataProvider = metadataService.GetProvider(provider);
if (metadataProvider == null)
return TypedResults.NotFound();
var results = string.IsNullOrWhiteSpace(subProvider)
? await metadataProvider.SearchGamesAsync(query, limit, offset)
: await metadataProvider.SearchGamesAsync(query, subProvider, limit, offset);
return TypedResults.Ok(results);
}
private static async Task<IResult> GetGameAsync(
string provider,
string gameId,
[FromServices] MetadataService metadataService)
{
var metadataProvider = metadataService.GetProvider(provider);
if (metadataProvider == null)
return TypedResults.NotFound();
var game = await metadataProvider.GetGameAsync(gameId);
if (game == null)
return TypedResults.NotFound();
return TypedResults.Ok(game);
}
}

View file

@ -39,6 +39,7 @@ public static class Endpoints
endpoints.MapSettingsEndpoints();
endpoints.MapLogEndpoints();
endpoints.MapHqEndpoints();
endpoints.MapMetadataEndpoints();
endpoints.MapTagEndpoints();
endpoints.MapControllers();
endpoints.MapFallbackToPage("/_Host");