diff --git a/Directory.Packages.props b/Directory.Packages.props
index 27070e87..1d5d2951 100644
--- a/Directory.Packages.props
+++ b/Directory.Packages.props
@@ -122,7 +122,7 @@
-
+
diff --git a/LANCommander.Packager/App.axaml.cs b/LANCommander.Packager/App.axaml.cs
index 264bb3f8..50b7ea57 100644
--- a/LANCommander.Packager/App.axaml.cs
+++ b/LANCommander.Packager/App.axaml.cs
@@ -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();
diff --git a/LANCommander.Packager/LANCommander.Packager.csproj b/LANCommander.Packager/LANCommander.Packager.csproj
index dbc9e51f..72f132cb 100644
--- a/LANCommander.Packager/LANCommander.Packager.csproj
+++ b/LANCommander.Packager/LANCommander.Packager.csproj
@@ -23,6 +23,14 @@
+
+
+
+
+
+
+
+
diff --git a/LANCommander.Packager/MainWindow.axaml b/LANCommander.Packager/MainWindow.axaml
index f71d0be6..5dc5146e 100644
--- a/LANCommander.Packager/MainWindow.axaml
+++ b/LANCommander.Packager/MainWindow.axaml
@@ -9,9 +9,24 @@
Background="{DynamicResource BackgroundBrush}"
Icon="avares://LANCommander.Packager/LANCommanderDark.ico">
-
+
+
+
+
+
+
+
+
+
+
+
+
-
+
@@ -72,7 +87,7 @@
-
diff --git a/LANCommander.Packager/MainWindow.axaml.cs b/LANCommander.Packager/MainWindow.axaml.cs
index e17b6554..dc111da1 100644
--- a/LANCommander.Packager/MainWindow.axaml.cs
+++ b/LANCommander.Packager/MainWindow.axaml.cs
@@ -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 _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();
+ _connectionClient = _services.GetRequiredService();
+ _settingsProvider = _services.GetRequiredService();
+ _tokenProvider = _services.GetRequiredService();
+ _metadataClient = _services.GetRequiredService();
+ _apiRequestFactory = _services.GetRequiredService();
+
_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(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);
diff --git a/LANCommander.Packager/Models/PackagerSettings.cs b/LANCommander.Packager/Models/PackagerSettings.cs
new file mode 100644
index 00000000..21eb8683
--- /dev/null
+++ b/LANCommander.Packager/Models/PackagerSettings.cs
@@ -0,0 +1,5 @@
+namespace LANCommander.Packager.Models;
+
+public class PackagerSettings : LANCommander.SDK.Models.Settings
+{
+}
diff --git a/LANCommander.Packager/Program.cs b/LANCommander.Packager/Program.cs
index 11574596..79c04aa0 100644
--- a/LANCommander.Packager/Program.cs
+++ b/LANCommander.Packager/Program.cs
@@ -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();
+ var refresher = configurationBuilder.ReadFromServer(configuration);
+ configuration = configurationBuilder.Build();
+
+ services.Configure(configuration);
+ services.AddSingleton(refresher);
+
+ services.AddLANCommanderClient();
+
+ Services = services.BuildServiceProvider();
+ }
+
public static AppBuilder BuildAvaloniaApp()
=> AppBuilder.Configure()
.UsePlatformDetect()
diff --git a/LANCommander.Packager/Services/InstallerMonitorService.cs b/LANCommander.Packager/Services/InstallerMonitorService.cs
index ca6f95e8..5b923cca 100644
--- a/LANCommander.Packager/Services/InstallerMonitorService.cs
+++ b/LANCommander.Packager/Services/InstallerMonitorService.cs
@@ -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 paths)
{
if (paths.Count == 0)
diff --git a/LANCommander.Packager/Views/ActionView.axaml b/LANCommander.Packager/Views/ActionView.axaml
index 3666e2c7..710ffdc9 100644
--- a/LANCommander.Packager/Views/ActionView.axaml
+++ b/LANCommander.Packager/Views/ActionView.axaml
@@ -1,26 +1,29 @@
-
-
-
+
+
+
-
-
-
-
-
-
-
-
-
+
+
+
+
+
+
+
+
+
+
diff --git a/LANCommander.Packager/Views/ActionView.axaml.cs b/LANCommander.Packager/Views/ActionView.axaml.cs
index 96238b4e..0fd5045d 100644
--- a/LANCommander.Packager/Views/ActionView.axaml.cs
+++ b/LANCommander.Packager/Views/ActionView.axaml.cs
@@ -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()
diff --git a/LANCommander.Packager/Views/ConnectDialog.axaml b/LANCommander.Packager/Views/ConnectDialog.axaml
new file mode 100644
index 00000000..efb506ff
--- /dev/null
+++ b/LANCommander.Packager/Views/ConnectDialog.axaml
@@ -0,0 +1,39 @@
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
diff --git a/LANCommander.Packager/Views/ConnectDialog.axaml.cs b/LANCommander.Packager/Views/ConnectDialog.axaml.cs
new file mode 100644
index 00000000..94e54edd
--- /dev/null
+++ b/LANCommander.Packager/Views/ConnectDialog.axaml.cs
@@ -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;
+ }
+}
diff --git a/LANCommander.Packager/Views/MetadataSearchDialog.axaml b/LANCommander.Packager/Views/MetadataSearchDialog.axaml
new file mode 100644
index 00000000..e4c19701
--- /dev/null
+++ b/LANCommander.Packager/Views/MetadataSearchDialog.axaml
@@ -0,0 +1,68 @@
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
diff --git a/LANCommander.Packager/Views/MetadataSearchDialog.axaml.cs b/LANCommander.Packager/Views/MetadataSearchDialog.axaml.cs
new file mode 100644
index 00000000..d4776830
--- /dev/null
+++ b/LANCommander.Packager/Views/MetadataSearchDialog.axaml.cs
@@ -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 _results = new();
+ private readonly List _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";
+ }
+ }
+}
diff --git a/LANCommander.Packager/Views/MetadataView.axaml b/LANCommander.Packager/Views/MetadataView.axaml
index 2ba137ad..968aeb77 100644
--- a/LANCommander.Packager/Views/MetadataView.axaml
+++ b/LANCommander.Packager/Views/MetadataView.axaml
@@ -3,6 +3,9 @@
x:Class="LANCommander.Packager.Views.MetadataView">
+
+
diff --git a/LANCommander.Packager/Views/MetadataView.axaml.cs b/LANCommander.Packager/Views/MetadataView.axaml.cs
index 9d0c97b1..67d0e3a4 100644
--- a/LANCommander.Packager/Views/MetadataView.axaml.cs
+++ b/LANCommander.Packager/Views/MetadataView.axaml.cs
@@ -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(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()
diff --git a/LANCommander.Packager/Views/OutputView.axaml b/LANCommander.Packager/Views/OutputView.axaml
index 66d85a16..1a5ebba9 100644
--- a/LANCommander.Packager/Views/OutputView.axaml
+++ b/LANCommander.Packager/Views/OutputView.axaml
@@ -2,17 +2,21 @@
xmlns:x="http://schemas.microsoft.com/winfx/2006/xaml"
x:Class="LANCommander.Packager.Views.OutputView">
-
+
-
-
+
+
+
+
+
diff --git a/LANCommander.Packager/Views/OutputView.axaml.cs b/LANCommander.Packager/Views/OutputView.axaml.cs
index a55ca8c7..432e2820 100644
--- a/LANCommander.Packager/Views/OutputView.axaml.cs
+++ b/LANCommander.Packager/Views/OutputView.axaml.cs
@@ -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(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;
diff --git a/LANCommander.SDK/Clients/MetadataClient.cs b/LANCommander.SDK/Clients/MetadataClient.cs
new file mode 100644
index 00000000..1929ce81
--- /dev/null
+++ b/LANCommander.SDK/Clients/MetadataClient.cs
@@ -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> GetProvidersAsync()
+ {
+ return await apiRequestFactory
+ .Create()
+ .UseAuthenticationToken()
+ .UseVersioning()
+ .UseRoute("/api/Metadata/Providers")
+ .GetAsync>();
+ }
+
+ public async Task> GetSubProvidersAsync(string provider)
+ {
+ return await apiRequestFactory
+ .Create()
+ .UseAuthenticationToken()
+ .UseVersioning()
+ .UseRoute($"/api/Metadata/{provider}/SubProviders")
+ .GetAsync>();
+ }
+
+ public async Task 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();
+ }
+
+ public async Task GetGameAsync(string provider, string gameId)
+ {
+ return await apiRequestFactory
+ .Create()
+ .UseAuthenticationToken()
+ .UseVersioning()
+ .UseRoute($"/api/Metadata/{provider}/{Uri.EscapeDataString(gameId)}")
+ .GetAsync();
+ }
+}
diff --git a/LANCommander.SDK/Extensions/IServiceCollectionExtensions.cs b/LANCommander.SDK/Extensions/IServiceCollectionExtensions.cs
index 0958407a..eddb84b7 100644
--- a/LANCommander.SDK/Extensions/IServiceCollectionExtensions.cs
+++ b/LANCommander.SDK/Extensions/IServiceCollectionExtensions.cs
@@ -53,6 +53,7 @@ public static class IServiceCollectionExtensions
services.AddSingleton();
services.AddSingleton();
services.AddSingleton();
+ services.AddSingleton();
services.AddSingleton();
services.AddSingleton();
diff --git a/LANCommander.SDK/Models/MetadataSearchResult.cs b/LANCommander.SDK/Models/MetadataSearchResult.cs
new file mode 100644
index 00000000..79655d3b
--- /dev/null
+++ b/LANCommander.SDK/Models/MetadataSearchResult.cs
@@ -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 Results { get; set; } = new List();
+ 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;
+}
diff --git a/LANCommander.Server/Endpoints/MetadataEndpoints.cs b/LANCommander.Server/Endpoints/MetadataEndpoints.cs
new file mode 100644
index 00000000..73292d13
--- /dev/null
+++ b/LANCommander.Server/Endpoints/MetadataEndpoints.cs
@@ -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 GetProvidersAsync(
+ [FromServices] MetadataService metadataService)
+ {
+ var providers = metadataService.GetProviderNames();
+
+ return TypedResults.Ok(providers);
+ }
+
+ private static async Task 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