From 8cf2139ccf13c778cb280f57d58aec1b5d18a544 Mon Sep 17 00:00:00 2001 From: Aaron Powell Date: Mon, 19 Jan 2026 14:24:49 +1100 Subject: [PATCH] feat: Add Avalonia launcher PoC with cross-platform UI - Create LANCommander.Launcher.Avalonia project using Avalonia MVVM - Implement server selection, login, and game library views - Add library sidebar showing user's games - Add depot/games list view with game details - Integrate with LANCommander.Launcher.Services for: - DepotService, LibraryService, ImportService, MediaService - SDK authentication and connection clients - Fix async initialization pattern to prevent UI thread deadlocks - Add ConfigureAwait(false) to ServerConfigurationProvider.RefreshAsync - Add Microsoft.Extensions.Http package for HttpClient DI Key features: - Server discovery and connection - User authentication with credential persistence - Library import from server to local SQLite cache - Game icons and banners via MediaService - Navigation between library, depot, and game details --- Directory.Packages.props | 11 + LANCommander.Launcher.Avalonia/App.axaml | 9 + LANCommander.Launcher.Avalonia/App.axaml.cs | 257 ++++++++++++++++++ .../LANCommander.Launcher.Avalonia.csproj | 26 ++ LANCommander.Launcher.Avalonia/Program.cs | 21 ++ .../ViewModels/GameDetailViewModel.cs | 196 +++++++++++++ .../ViewModels/GamesListViewModel.cs | 219 +++++++++++++++ .../ViewModels/LoginViewModel.cs | 95 +++++++ .../ViewModels/MainWindowViewModel.cs | 111 ++++++++ .../ViewModels/ServerSelectionViewModel.cs | 86 ++++++ .../ViewModels/ShellViewModel.cs | 248 +++++++++++++++++ .../ViewModels/ViewModelBase.cs | 7 + .../Views/GameDetailView.axaml | 178 ++++++++++++ .../Views/GameDetailView.axaml.cs | 11 + .../Views/GamesListView.axaml | 129 +++++++++ .../Views/GamesListView.axaml.cs | 11 + .../Views/LoginView.axaml | 67 +++++ .../Views/LoginView.axaml.cs | 11 + .../Views/MainWindow.axaml | 27 ++ .../Views/MainWindow.axaml.cs | 11 + .../Views/ServerSelectionView.axaml | 48 ++++ .../Views/ServerSelectionView.axaml.cs | 11 + .../Views/ShellView.axaml | 124 +++++++++ .../Views/ShellView.axaml.cs | 11 + LANCommander.Launcher.Avalonia/app.manifest | 20 ++ LANCommander.Launcher.Models/ListItem.cs | 1 + .../Providers/ServerConfigurationProvider.cs | 13 +- LANCommander.slnx | 1 + 28 files changed, 1954 insertions(+), 6 deletions(-) create mode 100644 LANCommander.Launcher.Avalonia/App.axaml create mode 100644 LANCommander.Launcher.Avalonia/App.axaml.cs create mode 100644 LANCommander.Launcher.Avalonia/LANCommander.Launcher.Avalonia.csproj create mode 100644 LANCommander.Launcher.Avalonia/Program.cs create mode 100644 LANCommander.Launcher.Avalonia/ViewModels/GameDetailViewModel.cs create mode 100644 LANCommander.Launcher.Avalonia/ViewModels/GamesListViewModel.cs create mode 100644 LANCommander.Launcher.Avalonia/ViewModels/LoginViewModel.cs create mode 100644 LANCommander.Launcher.Avalonia/ViewModels/MainWindowViewModel.cs create mode 100644 LANCommander.Launcher.Avalonia/ViewModels/ServerSelectionViewModel.cs create mode 100644 LANCommander.Launcher.Avalonia/ViewModels/ShellViewModel.cs create mode 100644 LANCommander.Launcher.Avalonia/ViewModels/ViewModelBase.cs create mode 100644 LANCommander.Launcher.Avalonia/Views/GameDetailView.axaml create mode 100644 LANCommander.Launcher.Avalonia/Views/GameDetailView.axaml.cs create mode 100644 LANCommander.Launcher.Avalonia/Views/GamesListView.axaml create mode 100644 LANCommander.Launcher.Avalonia/Views/GamesListView.axaml.cs create mode 100644 LANCommander.Launcher.Avalonia/Views/LoginView.axaml create mode 100644 LANCommander.Launcher.Avalonia/Views/LoginView.axaml.cs create mode 100644 LANCommander.Launcher.Avalonia/Views/MainWindow.axaml create mode 100644 LANCommander.Launcher.Avalonia/Views/MainWindow.axaml.cs create mode 100644 LANCommander.Launcher.Avalonia/Views/ServerSelectionView.axaml create mode 100644 LANCommander.Launcher.Avalonia/Views/ServerSelectionView.axaml.cs create mode 100644 LANCommander.Launcher.Avalonia/Views/ShellView.axaml create mode 100644 LANCommander.Launcher.Avalonia/Views/ShellView.axaml.cs create mode 100644 LANCommander.Launcher.Avalonia/app.manifest diff --git a/Directory.Packages.props b/Directory.Packages.props index e3fd2632..fc9a4a3b 100644 --- a/Directory.Packages.props +++ b/Directory.Packages.props @@ -111,13 +111,17 @@ + + + + @@ -128,6 +132,13 @@ + + + + + + + diff --git a/LANCommander.Launcher.Avalonia/App.axaml b/LANCommander.Launcher.Avalonia/App.axaml new file mode 100644 index 00000000..e12b6b40 --- /dev/null +++ b/LANCommander.Launcher.Avalonia/App.axaml @@ -0,0 +1,9 @@ + + + + + + diff --git a/LANCommander.Launcher.Avalonia/App.axaml.cs b/LANCommander.Launcher.Avalonia/App.axaml.cs new file mode 100644 index 00000000..0611184b --- /dev/null +++ b/LANCommander.Launcher.Avalonia/App.axaml.cs @@ -0,0 +1,257 @@ +using System; +using System.IO; +using System.Runtime.InteropServices; +using System.Threading.Tasks; +using Avalonia; +using Avalonia.Controls; +using Avalonia.Controls.ApplicationLifetimes; +using Avalonia.Data.Core.Plugins; +using Avalonia.Markup.Xaml; +using LANCommander.Launcher.Avalonia.ViewModels; +using LANCommander.Launcher.Avalonia.Views; +using LANCommander.Launcher.Services.Extensions; +using LANCommander.SDK; +using LANCommander.SDK.Extensions; +using LANCommander.SDK.Providers; +using LANCommander.SDK.Services; +using Microsoft.EntityFrameworkCore; +using Microsoft.Extensions.Configuration; +using Microsoft.Extensions.DependencyInjection; +using Microsoft.Extensions.Logging; + +namespace LANCommander.Launcher.Avalonia; + +public partial class App : Application +{ + public static IServiceProvider? Services { get; private set; } + private static ILogger? _logger; + + public override void Initialize() + { + AvaloniaXamlLoader.Load(this); + } + + public override void OnFrameworkInitializationCompleted() + { + try + { + // Configure services + var services = new ServiceCollection(); + ConfigureServices(services); + Services = services.BuildServiceProvider(); + + _logger = Services.GetRequiredService>(); + _logger.LogInformation("LANCommander Avalonia Launcher starting..."); + + // Remove Avalonia's built-in data validation plugin to avoid duplicate validations + var dataValidationPlugins = BindingPlugins.DataValidators; + for (var i = dataValidationPlugins.Count - 1; i >= 0; i--) + { + if (dataValidationPlugins[i] is DataAnnotationsValidationPlugin) + dataValidationPlugins.RemoveAt(i); + } + + if (ApplicationLifetime is IClassicDesktopStyleApplicationLifetime desktop) + { + desktop.ShutdownMode = ShutdownMode.OnMainWindowClose; + + var mainViewModel = Services.GetRequiredService(); + + var mainWindow = new MainWindow + { + DataContext = mainViewModel + }; + + mainWindow.Closed += (sender, args) => + { + _logger?.LogWarning("MainWindow Closed event fired"); + }; + + mainWindow.Closing += (sender, args) => + { + _logger?.LogWarning("MainWindow Closing event fired"); + }; + + desktop.MainWindow = mainWindow; + mainWindow.Show(); + + _logger.LogInformation("Main window created and shown, IsVisible={IsVisible}", mainWindow.IsVisible); + } + + base.OnFrameworkInitializationCompleted(); + + // Perform async initialization AFTER framework initialization is complete + // This ensures the window is shown and the message loop is running + _ = InitializeApplicationAsync(); + } + catch (Exception ex) + { + _logger?.LogCritical(ex, "Fatal error during initialization"); + Console.Error.WriteLine($"Fatal error during initialization: {ex}"); + throw; + } + } + + private async Task InitializeApplicationAsync() + { + try + { + _logger?.LogInformation("Starting async initialization..."); + + // Initialize application (same order as main Launcher/Program.cs) + using (var scope = Services!.CreateScope()) + { + var connectionClient = scope.ServiceProvider.GetRequiredService(); + var settingsProvider = scope.ServiceProvider.GetRequiredService>(); + var databaseContext = scope.ServiceProvider.GetRequiredService(); + + // Connect to server + _logger?.LogInformation("Connecting to server..."); + await connectionClient.ConnectAsync().ConfigureAwait(false); + + if (!await connectionClient.PingAsync().ConfigureAwait(false)) + { + _logger?.LogWarning("Server not reachable, enabling offline mode"); + await connectionClient.EnableOfflineModeAsync().ConfigureAwait(false); + } + + // Set default install directory if not configured + if (settingsProvider.CurrentValue.Games.InstallDirectories.Length == 0) + { + _logger?.LogInformation("Setting default install directory"); + settingsProvider.Update(static s => s.Games.InstallDirectories = GetOSPlatform() switch + { + var platform when platform == OSPlatform.Windows => [Path.Combine(Path.GetPathRoot(AppContext.BaseDirectory) ?? "C:", "Games")], + var platform when platform == OSPlatform.Linux => [Path.Combine(Environment.GetFolderPath(Environment.SpecialFolder.UserProfile), "Games")], + var platform when platform == OSPlatform.OSX => [Path.Combine(Environment.GetFolderPath(Environment.SpecialFolder.UserProfile), "Games")], + _ => throw new NotSupportedException("Unsupported OS platform") + }); + } + + // Run database migrations + _logger?.LogInformation("Running database migrations..."); + await databaseContext.Database.MigrateAsync().ConfigureAwait(false); + _logger?.LogInformation("Database migrations complete"); + } + + // Initialize the view model on the UI thread + var mainViewModel = Services!.GetRequiredService(); + _logger?.LogInformation("Initializing view model..."); + await mainViewModel.InitializeAsync().ConfigureAwait(false); + _logger?.LogInformation("View model initialized, application ready"); + } + catch (Exception ex) + { + _logger?.LogCritical(ex, "Fatal error during async initialization"); + Console.Error.WriteLine($"Fatal error during async initialization: {ex}"); + } + } + + private static void ConfigureServices(IServiceCollection services) + { + // Configure logging to console and file + var logDirectory = Path.Combine(AppPaths.GetConfigDirectory(), "Logs"); + Directory.CreateDirectory(logDirectory); + var logFilePath = Path.Combine(logDirectory, $"avalonia-launcher-{DateTime.Now:yyyy-MM-dd}.log"); + + services.AddLogging(builder => + { + builder.SetMinimumLevel(LogLevel.Debug); + builder.AddConsole(); + builder.AddSimpleConsole(options => + { + options.IncludeScopes = true; + options.TimestampFormat = "[HH:mm:ss] "; + }); + // Add file logging via a simple provider + builder.AddProvider(new FileLoggerProvider(logFilePath)); + }); + + // Add HttpClient (required by SDK services) + services.AddHttpClient(); + + // Configure settings from file (same as main launcher's AddSettings()) + var configurationBuilder = new ConfigurationBuilder(); + var configuration = configurationBuilder.ReadFromFile(); + var refresher = configurationBuilder.ReadFromServer(configuration); + configuration = configurationBuilder.Build(); + + services.Configure(configuration); + services.AddSingleton(refresher); // Register without interface, same as main launcher + + // Add SDK client and Launcher services + services.AddLANCommanderClient(); + services.AddLANCommanderLauncher(); + + // ViewModels + services.AddSingleton(); + } + + private static OSPlatform GetOSPlatform() + { + if (RuntimeInformation.IsOSPlatform(OSPlatform.Windows)) + return OSPlatform.Windows; + if (RuntimeInformation.IsOSPlatform(OSPlatform.Linux)) + return OSPlatform.Linux; + if (RuntimeInformation.IsOSPlatform(OSPlatform.OSX)) + return OSPlatform.OSX; + throw new NotSupportedException("Unsupported OS platform"); + } +} + +/// +/// Simple file logger provider for debugging +/// +public class FileLoggerProvider : ILoggerProvider +{ + private readonly string _filePath; + private readonly object _lock = new(); + + public FileLoggerProvider(string filePath) + { + _filePath = filePath; + } + + public ILogger CreateLogger(string categoryName) => new FileLogger(_filePath, categoryName, _lock); + + public void Dispose() { } +} + +public class FileLogger : ILogger +{ + private readonly string _filePath; + private readonly string _categoryName; + private readonly object _lock; + + public FileLogger(string filePath, string categoryName, object lockObj) + { + _filePath = filePath; + _categoryName = categoryName; + _lock = lockObj; + } + + public IDisposable? BeginScope(TState state) where TState : notnull => null; + + public bool IsEnabled(LogLevel logLevel) => logLevel >= LogLevel.Debug; + + public void Log(LogLevel logLevel, EventId eventId, TState state, Exception? exception, Func formatter) + { + if (!IsEnabled(logLevel)) return; + + var message = $"[{DateTime.Now:HH:mm:ss}] [{logLevel}] [{_categoryName}] {formatter(state, exception)}"; + if (exception != null) + message += Environment.NewLine + exception; + + lock (_lock) + { + try + { + File.AppendAllText(_filePath, message + Environment.NewLine); + } + catch + { + // Ignore file write errors + } + } + } +} diff --git a/LANCommander.Launcher.Avalonia/LANCommander.Launcher.Avalonia.csproj b/LANCommander.Launcher.Avalonia/LANCommander.Launcher.Avalonia.csproj new file mode 100644 index 00000000..872197bd --- /dev/null +++ b/LANCommander.Launcher.Avalonia/LANCommander.Launcher.Avalonia.csproj @@ -0,0 +1,26 @@ + + + WinExe + net9.0 + enable + true + app.manifest + true + + + + + + + + + + + + + + + + + + diff --git a/LANCommander.Launcher.Avalonia/Program.cs b/LANCommander.Launcher.Avalonia/Program.cs new file mode 100644 index 00000000..c5972586 --- /dev/null +++ b/LANCommander.Launcher.Avalonia/Program.cs @@ -0,0 +1,21 @@ +using Avalonia; +using System; + +namespace LANCommander.Launcher.Avalonia; + +class Program +{ + // Initialization code. Don't use any Avalonia, third-party APIs or any + // SynchronizationContext-reliant code before AppMain is called: things aren't initialized + // yet and stuff might break. + [STAThread] + public static void Main(string[] args) => BuildAvaloniaApp() + .StartWithClassicDesktopLifetime(args); + + // Avalonia configuration, don't remove; also used by visual designer. + public static AppBuilder BuildAvaloniaApp() + => AppBuilder.Configure() + .UsePlatformDetect() + .WithInterFont() + .LogToTrace(); +} diff --git a/LANCommander.Launcher.Avalonia/ViewModels/GameDetailViewModel.cs b/LANCommander.Launcher.Avalonia/ViewModels/GameDetailViewModel.cs new file mode 100644 index 00000000..d32f18f0 --- /dev/null +++ b/LANCommander.Launcher.Avalonia/ViewModels/GameDetailViewModel.cs @@ -0,0 +1,196 @@ +using System; +using System.Linq; +using CommunityToolkit.Mvvm.ComponentModel; +using CommunityToolkit.Mvvm.Input; +using LANCommander.Launcher.Services; +using LANCommander.SDK.Enums; +using Microsoft.Extensions.DependencyInjection; + +namespace LANCommander.Launcher.Avalonia.ViewModels; + +public partial class GameDetailViewModel : ViewModelBase +{ + private readonly IServiceProvider _serviceProvider; + + [ObservableProperty] + private Guid _id; + + [ObservableProperty] + private string _title = string.Empty; + + [ObservableProperty] + private string _description = string.Empty; + + [ObservableProperty] + private string? _bannerPath; + + [ObservableProperty] + private string? _backgroundPath; + + [ObservableProperty] + private string? _iconPath; + + [ObservableProperty] + private DateTime _releasedOn; + + [ObservableProperty] + private string _releaseYear = string.Empty; + + [ObservableProperty] + private bool _singleplayer; + + [ObservableProperty] + private string _genres = string.Empty; + + [ObservableProperty] + private string _developers = string.Empty; + + [ObservableProperty] + private string _publishers = string.Empty; + + [ObservableProperty] + private string _platforms = string.Empty; + + [ObservableProperty] + private string _multiplayerModes = string.Empty; + + [ObservableProperty] + private string _tags = string.Empty; + + [ObservableProperty] + private bool _hasMultiplayer; + + public event EventHandler? BackRequested; + + public GameDetailViewModel(IServiceProvider serviceProvider) + { + _serviceProvider = serviceProvider; + } + + /// + /// Load game from local cache (Data.Models.Game) + /// Used when selecting from the library sidebar + /// + public void LoadGame(Data.Models.Game game) + { + Id = game.Id; + Title = game.Title ?? "Unknown"; + Description = game.Description ?? string.Empty; + ReleasedOn = game.ReleasedOn ?? DateTime.MinValue; + ReleaseYear = game.ReleasedOn?.Year > 1 ? game.ReleasedOn.Value.Year.ToString() : "Unknown"; + Singleplayer = game.Singleplayer; + + // Get media paths from local storage + using var scope = _serviceProvider.CreateScope(); + var mediaService = scope.ServiceProvider.GetRequiredService(); + + BannerPath = GetLocalMediaPath(game.Media, MediaType.Cover, mediaService); + BackgroundPath = GetLocalMediaPath(game.Media, MediaType.Background, mediaService); + IconPath = GetLocalMediaPath(game.Media, MediaType.Icon, mediaService); + + // Collections + Genres = game.Genres != null + ? string.Join(", ", game.Genres.Select(g => g.Name)) + : string.Empty; + + Developers = game.Developers != null + ? string.Join(", ", game.Developers.Select(d => d.Name)) + : string.Empty; + + Publishers = game.Publishers != null + ? string.Join(", ", game.Publishers.Select(p => p.Name)) + : string.Empty; + + Platforms = game.Platforms != null + ? string.Join(", ", game.Platforms.Select(p => p.Name)) + : string.Empty; + + Tags = game.Tags != null + ? string.Join(", ", game.Tags.Select(t => t.Name)) + : string.Empty; + + // Multiplayer info + HasMultiplayer = game.MultiplayerModes != null && game.MultiplayerModes.Any(); + if (HasMultiplayer) + { + var modes = game.MultiplayerModes! + .Select(m => m.Type.ToString()) + .Distinct(); + MultiplayerModes = string.Join(", ", modes); + } + else + { + MultiplayerModes = string.Empty; + } + } + + /// + /// Load game from server API (SDK.Models.Game) + /// Used when selecting from the depot/all games list + /// + public void LoadGame(SDK.Models.Game game) + { + Id = game.Id; + Title = game.Title ?? "Unknown"; + Description = game.Description ?? string.Empty; + ReleasedOn = game.ReleasedOn; + ReleaseYear = game.ReleasedOn.Year > 1 ? game.ReleasedOn.Year.ToString() : "Unknown"; + Singleplayer = game.Singleplayer; + + // For server games, we need to construct URLs or download media + // For now, we won't have local paths - could use server URLs if needed + BannerPath = null; + BackgroundPath = null; + IconPath = null; + + // Collections + Genres = game.Genres != null + ? string.Join(", ", game.Genres.Select(g => g.Name)) + : string.Empty; + + Developers = game.Developers != null + ? string.Join(", ", game.Developers.Select(d => d.Name)) + : string.Empty; + + Publishers = game.Publishers != null + ? string.Join(", ", game.Publishers.Select(p => p.Name)) + : string.Empty; + + Platforms = game.Platforms != null + ? string.Join(", ", game.Platforms.Select(p => p.Name)) + : string.Empty; + + Tags = game.Tags != null + ? string.Join(", ", game.Tags.Select(t => t.Name)) + : string.Empty; + + // Multiplayer info + HasMultiplayer = game.MultiplayerModes != null && game.MultiplayerModes.Any(); + if (HasMultiplayer) + { + var modes = game.MultiplayerModes! + .Select(m => m.Type.ToString()) + .Distinct(); + MultiplayerModes = string.Join(", ", modes); + } + else + { + MultiplayerModes = string.Empty; + } + } + + private string? GetLocalMediaPath(System.Collections.Generic.ICollection? mediaCollection, MediaType type, MediaService mediaService) + { + var media = mediaCollection?.FirstOrDefault(m => m.Type == type); + if (media == null) return null; + + var path = mediaService.GetImagePath(media); + return mediaService.FileExists(media) ? path : null; + } + + [RelayCommand] + private void GoBack() + { + BackRequested?.Invoke(this, EventArgs.Empty); + } +} diff --git a/LANCommander.Launcher.Avalonia/ViewModels/GamesListViewModel.cs b/LANCommander.Launcher.Avalonia/ViewModels/GamesListViewModel.cs new file mode 100644 index 00000000..d0127e80 --- /dev/null +++ b/LANCommander.Launcher.Avalonia/ViewModels/GamesListViewModel.cs @@ -0,0 +1,219 @@ +using System; +using System.Collections.Generic; +using System.Collections.ObjectModel; +using System.Linq; +using System.Threading.Tasks; +using CommunityToolkit.Mvvm.ComponentModel; +using CommunityToolkit.Mvvm.Input; +using LANCommander.Launcher.Data.Models; +using LANCommander.Launcher.Models; +using LANCommander.Launcher.Services; +using LANCommander.SDK.Enums; +using LANCommander.SDK.Services; +using Microsoft.Extensions.DependencyInjection; +using Microsoft.Extensions.Logging; + +namespace LANCommander.Launcher.Avalonia.ViewModels; + +public partial class GamesListViewModel : ViewModelBase +{ + private readonly IServiceProvider _serviceProvider; + private readonly ILogger _logger; + + // Store the depot items so we can access them when selecting a game + private IEnumerable? _depotItems; + + [ObservableProperty] + private ObservableCollection _games = new(); + + [ObservableProperty] + private string _statusMessage = string.Empty; + + [ObservableProperty] + private bool _isLoading; + + [ObservableProperty] + private bool _hasError; + + [ObservableProperty] + private GameItemViewModel? _selectedGame; + + [ObservableProperty] + private string _searchText = string.Empty; + + // Event now passes the SDK Game model fetched from server + public event EventHandler? GameSelected; + + public GamesListViewModel(IServiceProvider serviceProvider) + { + _serviceProvider = serviceProvider; + _logger = serviceProvider.GetRequiredService>(); + } + + [RelayCommand] + private async Task LoadGamesInternalAsync() + { + IsLoading = true; + HasError = false; + StatusMessage = "Loading games..."; + Games.Clear(); + _logger.LogInformation("Loading games from depot..."); + + try + { + using var scope = _serviceProvider.CreateScope(); + _logger.LogDebug("Created scope, resolving services..."); + + var depotService = scope.ServiceProvider.GetRequiredService(); + _logger.LogDebug("DepotService resolved"); + + var libraryService = scope.ServiceProvider.GetRequiredService(); + _logger.LogDebug("LibraryService resolved"); + + var mediaService = scope.ServiceProvider.GetRequiredService(); + _logger.LogDebug("MediaService resolved"); + + _logger.LogDebug("Calling depotService.GetItemsAsync()..."); + _depotItems = await depotService.GetItemsAsync(); + _logger.LogDebug("Got {Count} depot items", _depotItems?.Count() ?? 0); + + foreach (var item in _depotItems ?? []) + { + _logger.LogDebug("Processing depot item: Type={Type}, DataItem={DataItemType}", + item.GetType().Name, item.DataItem?.GetType().Name ?? "null"); + + if (item.DataItem is SDK.Models.DepotGame depotGame) + { + var inLibrary = libraryService.IsInLibrary(depotGame.Id); + var iconPath = await mediaService.GetImagePath(item.IconId); + Games.Add(new GameItemViewModel(depotGame, iconPath, inLibrary)); + } + } + + StatusMessage = $"{Games.Count} games available"; + _logger.LogInformation("Loaded {Count} games from depot", Games.Count); + } + catch (Exception ex) + { + _logger.LogError(ex, "Failed to load games from depot"); + StatusMessage = $"Failed to load games: {ex.Message}"; + HasError = true; + } + finally + { + IsLoading = false; + } + } + + public Task LoadGamesAsync() => LoadGamesInternalAsync(); + + [RelayCommand] + private async Task ViewGameDetailsAsync(GameItemViewModel? gameItem) + { + if (gameItem == null) return; + + _logger.LogDebug("Viewing game details for {GameId}", gameItem.Id); + + try + { + using var scope = _serviceProvider.CreateScope(); + + // Fetch the full game details from the SERVER using GameClient + // This is how the original Blazor launcher does it in DepotGameDetails.razor + var gameClient = scope.ServiceProvider.GetRequiredService(); + _logger.LogDebug("Fetching game {GameId} from server...", gameItem.Id); + + var game = await gameClient.GetAsync(gameItem.Id); + + if (game != null) + { + _logger.LogDebug("Got game from server: {Title}", game.Title); + GameSelected?.Invoke(this, game); + } + else + { + _logger.LogWarning("Game {GameId} not found on server", gameItem.Id); + } + } + catch (Exception ex) + { + _logger.LogError(ex, "Failed to fetch game {GameId} from server", gameItem.Id); + } + } + + partial void OnSearchTextChanged(string value) + { + // Simple client-side filtering - in a real app you'd want to debounce this + } +} + +public partial class GameItemViewModel : ViewModelBase +{ + [ObservableProperty] + private Guid _id; + + [ObservableProperty] + private string _title = string.Empty; + + [ObservableProperty] + private string _description = string.Empty; + + [ObservableProperty] + private string _sortTitle = string.Empty; + + [ObservableProperty] + private DateTime _releasedOn; + + [ObservableProperty] + private bool _singleplayer; + + [ObservableProperty] + private string _genres = string.Empty; + + [ObservableProperty] + private string _developers = string.Empty; + + [ObservableProperty] + private string _publishers = string.Empty; + + [ObservableProperty] + private string? _iconPath; + + [ObservableProperty] + private bool _hasIcon; + + [ObservableProperty] + private bool _inLibrary; + + public GameItemViewModel(SDK.Models.DepotGame game, string? iconPath = null, bool inLibrary = false) + { + Id = game.Id; + Title = game.Title ?? "Unknown"; + Description = game.Description ?? string.Empty; + SortTitle = game.SortTitle ?? game.Title ?? string.Empty; + ReleasedOn = game.ReleasedOn; + Singleplayer = game.Singleplayer; + Genres = game.Genres != null ? string.Join(", ", game.Genres.Select(g => g.Name)) : string.Empty; + Developers = game.Developers != null ? string.Join(", ", game.Developers.Select(d => d.Name)) : string.Empty; + Publishers = game.Publishers != null ? string.Join(", ", game.Publishers.Select(p => p.Name)) : string.Empty; + IconPath = iconPath; + HasIcon = !string.IsNullOrEmpty(iconPath); + InLibrary = inLibrary; + } + + public GameItemViewModel(Game game, string? iconPath = null, bool inLibrary = false) + { + Id = game.Id; + Title = game.Title ?? "Unknown"; + Description = game.Description ?? string.Empty; + SortTitle = game.SortTitle ?? game.Title ?? string.Empty; + ReleasedOn = game.ReleasedOn ?? DateTime.MinValue; + Singleplayer = game.Singleplayer; + Genres = game.Genres != null ? string.Join(", ", game.Genres.Select(g => g.Name)) : string.Empty; + Developers = game.Developers != null ? string.Join(", ", game.Developers.Select(d => d.Name)) : string.Empty; + Publishers = game.Publishers != null ? string.Join(", ", game.Publishers.Select(p => p.Name)) : string.Empty; + IconPath = iconPath; + HasIcon = !string.IsNullOrEmpty(iconPath); + InLibrary = inLibrary; + } +} diff --git a/LANCommander.Launcher.Avalonia/ViewModels/LoginViewModel.cs b/LANCommander.Launcher.Avalonia/ViewModels/LoginViewModel.cs new file mode 100644 index 00000000..04835782 --- /dev/null +++ b/LANCommander.Launcher.Avalonia/ViewModels/LoginViewModel.cs @@ -0,0 +1,95 @@ +using System; +using System.Threading.Tasks; +using CommunityToolkit.Mvvm.ComponentModel; +using CommunityToolkit.Mvvm.Input; +using LANCommander.Launcher.Services; +using LANCommander.SDK.Providers; +using LANCommander.SDK.Services; + +namespace LANCommander.Launcher.Avalonia.ViewModels; + +public partial class LoginViewModel : ViewModelBase +{ + private readonly IConnectionClient _connectionClient; + private readonly AuthenticationService _authenticationService; + private readonly SettingsProvider _settingsProvider; + + [ObservableProperty] + private string _username = string.Empty; + + [ObservableProperty] + private string _password = string.Empty; + + [ObservableProperty] + private string _statusMessage = string.Empty; + + [ObservableProperty] + private bool _isLoading; + + [ObservableProperty] + private bool _hasError; + + [ObservableProperty] + private string _serverAddress = string.Empty; + + public event EventHandler? LoginSucceeded; + public event EventHandler? ChangeServerRequested; + + public LoginViewModel( + IConnectionClient connectionClient, + AuthenticationService authenticationService, + SettingsProvider settingsProvider) + { + _connectionClient = connectionClient; + _authenticationService = authenticationService; + _settingsProvider = settingsProvider; + + ServerAddress = _connectionClient.GetServerAddress()?.ToString() ?? "Not connected"; + } + + [RelayCommand] + private async Task LoginAsync() + { + if (string.IsNullOrWhiteSpace(Username) || string.IsNullOrWhiteSpace(Password)) + { + StatusMessage = "Please enter username and password"; + HasError = true; + return; + } + + IsLoading = true; + HasError = false; + StatusMessage = "Logging in..."; + + try + { + var serverAddress = _connectionClient.GetServerAddress(); + if (serverAddress == null) + { + StatusMessage = "No server configured"; + HasError = true; + return; + } + + await _authenticationService.Login(serverAddress, Username, Password); + + StatusMessage = "Login successful!"; + LoginSucceeded?.Invoke(this, EventArgs.Empty); + } + catch (Exception ex) + { + StatusMessage = $"Login failed: {ex.Message}"; + HasError = true; + } + finally + { + IsLoading = false; + } + } + + [RelayCommand] + private void ChangeServer() + { + ChangeServerRequested?.Invoke(this, EventArgs.Empty); + } +} diff --git a/LANCommander.Launcher.Avalonia/ViewModels/MainWindowViewModel.cs b/LANCommander.Launcher.Avalonia/ViewModels/MainWindowViewModel.cs new file mode 100644 index 00000000..6ea9776a --- /dev/null +++ b/LANCommander.Launcher.Avalonia/ViewModels/MainWindowViewModel.cs @@ -0,0 +1,111 @@ +using System; +using System.Threading.Tasks; +using CommunityToolkit.Mvvm.ComponentModel; +using LANCommander.Launcher.Services; +using LANCommander.SDK.Providers; +using LANCommander.SDK.Services; +using Microsoft.Extensions.DependencyInjection; + +namespace LANCommander.Launcher.Avalonia.ViewModels; + +public partial class MainWindowViewModel : ViewModelBase +{ + private readonly IServiceProvider _serviceProvider; + private readonly IConnectionClient _connectionClient; + private readonly AuthenticationService _authenticationService; + private readonly SettingsProvider _settingsProvider; + + [ObservableProperty] + private ViewModelBase _currentView; + + [ObservableProperty] + private string _title = "LANCommander Launcher"; + + public ServerSelectionViewModel ServerSelectionViewModel { get; } + public LoginViewModel LoginViewModel { get; } + public ShellViewModel ShellViewModel { get; } + + public MainWindowViewModel( + IServiceProvider serviceProvider, + IConnectionClient connectionClient, + AuthenticationService authenticationService, + SettingsProvider settingsProvider) + { + _serviceProvider = serviceProvider; + _connectionClient = connectionClient; + _authenticationService = authenticationService; + _settingsProvider = settingsProvider; + + ServerSelectionViewModel = new ServerSelectionViewModel(connectionClient, settingsProvider); + LoginViewModel = new LoginViewModel(connectionClient, authenticationService, settingsProvider); + ShellViewModel = new ShellViewModel(serviceProvider); + + // Wire up navigation events + ServerSelectionViewModel.ServerConnected += OnServerConnected; + LoginViewModel.LoginSucceeded += OnLoginSucceeded; + LoginViewModel.ChangeServerRequested += OnChangeServerRequested; + ShellViewModel.LogoutRequested += OnLogoutRequested; + + // Start with server selection + _currentView = ServerSelectionViewModel; + } + + public async Task InitializeAsync() + { + // Check if we have a saved server address and valid token + var settings = _settingsProvider.CurrentValue; + + if (settings.Authentication?.ServerAddress != null) + { + await _connectionClient.UpdateServerAddressAsync(settings.Authentication.ServerAddress.ToString()); + + if (_authenticationService.HasStoredCredentials()) + { + try + { + // Try to login with stored credentials + await _authenticationService.Login(); + + if (_connectionClient.IsConnected()) + { + // Token is valid - go directly to shell + CurrentView = ShellViewModel; + await ShellViewModel.InitializeAsync(); + return; + } + } + catch + { + // Token validation failed - continue to login + } + } + + // We have a server but no valid token - go to login + LoginViewModel.ServerAddress = settings.Authentication.ServerAddress.ToString(); + CurrentView = LoginViewModel; + } + // Otherwise stay on server selection (default) + } + + private void OnServerConnected(object? sender, EventArgs e) + { + LoginViewModel.ServerAddress = _connectionClient.GetServerAddress()?.ToString() ?? string.Empty; + CurrentView = LoginViewModel; + } + + private async void OnLoginSucceeded(object? sender, EventArgs e) + { + CurrentView = ShellViewModel; + await ShellViewModel.InitializeAsync(); + } + + private void OnChangeServerRequested(object? sender, EventArgs e) + { + CurrentView = ServerSelectionViewModel; + } + + private void OnLogoutRequested(object? sender, EventArgs e) + { + CurrentView = LoginViewModel; + } +} diff --git a/LANCommander.Launcher.Avalonia/ViewModels/ServerSelectionViewModel.cs b/LANCommander.Launcher.Avalonia/ViewModels/ServerSelectionViewModel.cs new file mode 100644 index 00000000..8f153c04 --- /dev/null +++ b/LANCommander.Launcher.Avalonia/ViewModels/ServerSelectionViewModel.cs @@ -0,0 +1,86 @@ +using System; +using System.Threading.Tasks; +using CommunityToolkit.Mvvm.ComponentModel; +using CommunityToolkit.Mvvm.Input; +using LANCommander.SDK.Providers; +using LANCommander.SDK.Services; + +namespace LANCommander.Launcher.Avalonia.ViewModels; + +public partial class ServerSelectionViewModel : ViewModelBase +{ + private readonly IConnectionClient _connectionClient; + private readonly SettingsProvider _settingsProvider; + + [ObservableProperty] + private string _serverAddress = string.Empty; + + [ObservableProperty] + private string _statusMessage = string.Empty; + + [ObservableProperty] + private bool _isLoading; + + [ObservableProperty] + private bool _hasError; + + public event EventHandler? ServerConnected; + + public ServerSelectionViewModel( + IConnectionClient connectionClient, + SettingsProvider settingsProvider) + { + _connectionClient = connectionClient; + _settingsProvider = settingsProvider; + + // Load saved server address if available + if (_settingsProvider.CurrentValue.Authentication?.ServerAddress != null) + ServerAddress = _settingsProvider.CurrentValue.Authentication.ServerAddress.ToString(); + } + + [RelayCommand] + private async Task ConnectAsync() + { + if (string.IsNullOrWhiteSpace(ServerAddress)) + { + StatusMessage = "Please enter a server address"; + HasError = true; + return; + } + + IsLoading = true; + HasError = false; + StatusMessage = "Testing connection..."; + + try + { + await _connectionClient.UpdateServerAddressAsync(ServerAddress); + var canConnect = await _connectionClient.PingAsync(); + + if (canConnect) + { + _settingsProvider.Update(s => + { + s.Authentication.ServerAddress = new Uri(ServerAddress); + }); + + StatusMessage = "Connected!"; + ServerConnected?.Invoke(this, EventArgs.Empty); + } + else + { + StatusMessage = "Could not connect to server. Please check the address and try again."; + HasError = true; + } + } + catch (Exception ex) + { + StatusMessage = $"Connection failed: {ex.Message}"; + HasError = true; + } + finally + { + IsLoading = false; + } + } +} diff --git a/LANCommander.Launcher.Avalonia/ViewModels/ShellViewModel.cs b/LANCommander.Launcher.Avalonia/ViewModels/ShellViewModel.cs new file mode 100644 index 00000000..e2b13cd2 --- /dev/null +++ b/LANCommander.Launcher.Avalonia/ViewModels/ShellViewModel.cs @@ -0,0 +1,248 @@ +using System; +using System.Collections.ObjectModel; +using System.Linq; +using System.Threading.Tasks; +using CommunityToolkit.Mvvm.ComponentModel; +using CommunityToolkit.Mvvm.Input; +using LANCommander.Launcher.Data.Models; +using LANCommander.Launcher.Models; +using LANCommander.Launcher.Services; +using LANCommander.SDK.Enums; +using Microsoft.Extensions.DependencyInjection; +using Microsoft.Extensions.Logging; + +namespace LANCommander.Launcher.Avalonia.ViewModels; + +public partial class ShellViewModel : ViewModelBase +{ + private readonly IServiceProvider _serviceProvider; + private readonly ILogger _logger; + + [ObservableProperty] + private ObservableCollection _libraryItems = new(); + + [ObservableProperty] + private LibraryItemViewModel? _selectedLibraryItem; + + [ObservableProperty] + private ViewModelBase? _contentView; + + [ObservableProperty] + private bool _isLibraryLoading; + + [ObservableProperty] + private bool _isDepotSelected; + + [ObservableProperty] + private string _statusMessage = string.Empty; + + // Child view models + public GamesListViewModel GamesListViewModel { get; private set; } = null!; + public GameDetailViewModel GameDetailViewModel { get; private set; } = null!; + + public event EventHandler? LogoutRequested; + + public ShellViewModel(IServiceProvider serviceProvider) + { + _serviceProvider = serviceProvider; + _logger = serviceProvider.GetRequiredService>(); + } + + public async Task InitializeAsync() + { + _logger.LogInformation("ShellViewModel initializing..."); + + // Create child view models with proper scoped services + GamesListViewModel = new GamesListViewModel(_serviceProvider); + GameDetailViewModel = new GameDetailViewModel(_serviceProvider); + + // Wire up events from child view models + GamesListViewModel.GameSelected += OnGameSelected; + GameDetailViewModel.BackRequested += OnBackFromGameDetail; + + // Import library from server and load data + await ImportAndLoadAsync(); + + // Default to showing depot/games list + ShowDepot(); + + _logger.LogInformation("ShellViewModel initialization complete"); + } + + private async Task ImportAndLoadAsync() + { + IsLibraryLoading = true; + StatusMessage = "Importing library..."; + _logger.LogInformation("Starting library import..."); + + try + { + // Use ImportService to import library data from server to local database + using var scope = _serviceProvider.CreateScope(); + var importService = scope.ServiceProvider.GetRequiredService(); + + _logger.LogDebug("ImportService resolved, calling ImportLibraryAsync..."); + await importService.ImportLibraryAsync(); + _logger.LogInformation("Library import complete"); + + // Now load from local database + await LoadLibraryAsync(); + await GamesListViewModel.LoadGamesAsync(); + } + catch (Exception ex) + { + _logger.LogError(ex, "Import failed"); + StatusMessage = $"Import failed: {ex.Message}"; + } + finally + { + IsLibraryLoading = false; + } + } + + [RelayCommand] + private async Task LoadLibraryAsync() + { + IsLibraryLoading = true; + LibraryItems.Clear(); + _logger.LogInformation("Loading library items..."); + + try + { + using var scope = _serviceProvider.CreateScope(); + var libraryService = scope.ServiceProvider.GetRequiredService(); + var mediaService = scope.ServiceProvider.GetRequiredService(); + + _logger.LogDebug("LibraryService and MediaService resolved"); + var items = await libraryService.GetItemsAsync(); + _logger.LogDebug("Got {Count} items from LibraryService", items?.Count() ?? 0); + + foreach (var item in items ?? []) + { + if (item.DataItem is Game game) + { + var iconPath = GetIconPath(game, mediaService); + LibraryItems.Add(new LibraryItemViewModel(game, iconPath)); + } + } + + StatusMessage = $"{LibraryItems.Count} games in library"; + _logger.LogInformation("Loaded {Count} games into library", LibraryItems.Count); + } + catch (Exception ex) + { + _logger.LogError(ex, "Failed to load library"); + StatusMessage = $"Failed to load library: {ex.Message}"; + } + finally + { + IsLibraryLoading = false; + } + } + + private string? GetIconPath(Game game, MediaService mediaService) + { + var icon = game.Media?.FirstOrDefault(m => m.Type == MediaType.Icon); + if (icon == null) return null; + + var path = mediaService.GetImagePath(icon); + return mediaService.FileExists(icon) ? path : null; + } + + [RelayCommand] + private void ShowDepot() + { + SelectedLibraryItem = null; + IsDepotSelected = true; + ContentView = GamesListViewModel; + _logger.LogDebug("Showing depot view"); + } + + [RelayCommand] + private async Task SelectLibraryItemAsync(LibraryItemViewModel? item) + { + if (item == null) return; + + SelectedLibraryItem = item; + IsDepotSelected = false; + + using var scope = _serviceProvider.CreateScope(); + var libraryService = scope.ServiceProvider.GetRequiredService(); + + var listItem = await libraryService.GetItemAsync(item.Id); + if (listItem?.DataItem is Game game) + { + GameDetailViewModel.LoadGame(game); + ContentView = GameDetailViewModel; + } + } + + [RelayCommand] + private async Task RefreshAsync() + { + await ImportAndLoadAsync(); + } + + [RelayCommand] + private async Task LogoutAsync() + { + using var scope = _serviceProvider.CreateScope(); + var authService = scope.ServiceProvider.GetRequiredService(); + await authService.Logout(); + + LogoutRequested?.Invoke(this, EventArgs.Empty); + } + + private void OnGameSelected(object? sender, SDK.Models.Game game) + { + // Check if game is in library and select it in the sidebar + var libraryItem = LibraryItems.FirstOrDefault(li => li.Id == game.Id); + if (libraryItem != null) + { + SelectedLibraryItem = libraryItem; + } + else + { + SelectedLibraryItem = null; + } + + IsDepotSelected = false; + GameDetailViewModel.LoadGame(game); + ContentView = GameDetailViewModel; + } + + private void OnBackFromGameDetail(object? sender, EventArgs e) + { + ShowDepot(); + } +} + +public partial class LibraryItemViewModel : ViewModelBase +{ + [ObservableProperty] + private Guid _id; + + [ObservableProperty] + private string _title = string.Empty; + + [ObservableProperty] + private string? _iconPath; + + [ObservableProperty] + private bool _hasIcon; + + public LibraryItemViewModel(Game game, string? iconPath = null) + { + Id = game.Id; + Title = game.Title ?? "Unknown"; + IconPath = iconPath; + HasIcon = !string.IsNullOrEmpty(iconPath); + } + + public LibraryItemViewModel(Guid id, string name) + { + Id = id; + Title = name; + HasIcon = false; + } +} diff --git a/LANCommander.Launcher.Avalonia/ViewModels/ViewModelBase.cs b/LANCommander.Launcher.Avalonia/ViewModels/ViewModelBase.cs new file mode 100644 index 00000000..2871e970 --- /dev/null +++ b/LANCommander.Launcher.Avalonia/ViewModels/ViewModelBase.cs @@ -0,0 +1,7 @@ +using CommunityToolkit.Mvvm.ComponentModel; + +namespace LANCommander.Launcher.Avalonia.ViewModels; + +public abstract partial class ViewModelBase : ObservableObject +{ +} diff --git a/LANCommander.Launcher.Avalonia/Views/GameDetailView.axaml b/LANCommander.Launcher.Avalonia/Views/GameDetailView.axaml new file mode 100644 index 00000000..76a0ea67 --- /dev/null +++ b/LANCommander.Launcher.Avalonia/Views/GameDetailView.axaml @@ -0,0 +1,178 @@ + + + + + + + + + + + + diff --git a/LANCommander.Launcher.Avalonia/Views/GamesListView.axaml.cs b/LANCommander.Launcher.Avalonia/Views/GamesListView.axaml.cs new file mode 100644 index 00000000..7d1aa43d --- /dev/null +++ b/LANCommander.Launcher.Avalonia/Views/GamesListView.axaml.cs @@ -0,0 +1,11 @@ +using Avalonia.Controls; + +namespace LANCommander.Launcher.Avalonia.Views; + +public partial class GamesListView : UserControl +{ + public GamesListView() + { + InitializeComponent(); + } +} diff --git a/LANCommander.Launcher.Avalonia/Views/LoginView.axaml b/LANCommander.Launcher.Avalonia/Views/LoginView.axaml new file mode 100644 index 00000000..3ca68483 --- /dev/null +++ b/LANCommander.Launcher.Avalonia/Views/LoginView.axaml @@ -0,0 +1,67 @@ + + + + + + + + + + + + + + + + + + + diff --git a/LANCommander.Launcher.Avalonia/Views/LoginView.axaml.cs b/LANCommander.Launcher.Avalonia/Views/LoginView.axaml.cs new file mode 100644 index 00000000..c49b74ef --- /dev/null +++ b/LANCommander.Launcher.Avalonia/Views/LoginView.axaml.cs @@ -0,0 +1,11 @@ +using Avalonia.Controls; + +namespace LANCommander.Launcher.Avalonia.Views; + +public partial class LoginView : UserControl +{ + public LoginView() + { + InitializeComponent(); + } +} diff --git a/LANCommander.Launcher.Avalonia/Views/MainWindow.axaml b/LANCommander.Launcher.Avalonia/Views/MainWindow.axaml new file mode 100644 index 00000000..bf293207 --- /dev/null +++ b/LANCommander.Launcher.Avalonia/Views/MainWindow.axaml @@ -0,0 +1,27 @@ + + + + + + + + + + + + + + + + diff --git a/LANCommander.Launcher.Avalonia/Views/MainWindow.axaml.cs b/LANCommander.Launcher.Avalonia/Views/MainWindow.axaml.cs new file mode 100644 index 00000000..624c2b34 --- /dev/null +++ b/LANCommander.Launcher.Avalonia/Views/MainWindow.axaml.cs @@ -0,0 +1,11 @@ +using Avalonia.Controls; + +namespace LANCommander.Launcher.Avalonia.Views; + +public partial class MainWindow : Window +{ + public MainWindow() + { + InitializeComponent(); + } +} diff --git a/LANCommander.Launcher.Avalonia/Views/ServerSelectionView.axaml b/LANCommander.Launcher.Avalonia/Views/ServerSelectionView.axaml new file mode 100644 index 00000000..42f70d47 --- /dev/null +++ b/LANCommander.Launcher.Avalonia/Views/ServerSelectionView.axaml @@ -0,0 +1,48 @@ + + + + + + + + + + + + + + + + + + + + + diff --git a/LANCommander.Launcher.Avalonia/Views/ServerSelectionView.axaml.cs b/LANCommander.Launcher.Avalonia/Views/ServerSelectionView.axaml.cs new file mode 100644 index 00000000..64913130 --- /dev/null +++ b/LANCommander.Launcher.Avalonia/Views/ServerSelectionView.axaml.cs @@ -0,0 +1,11 @@ +using Avalonia.Controls; + +namespace LANCommander.Launcher.Avalonia.Views; + +public partial class ServerSelectionView : UserControl +{ + public ServerSelectionView() + { + InitializeComponent(); + } +} diff --git a/LANCommander.Launcher.Avalonia/Views/ShellView.axaml b/LANCommander.Launcher.Avalonia/Views/ShellView.axaml new file mode 100644 index 00000000..ddcf771c --- /dev/null +++ b/LANCommander.Launcher.Avalonia/Views/ShellView.axaml @@ -0,0 +1,124 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/LANCommander.Launcher.Avalonia/Views/ShellView.axaml.cs b/LANCommander.Launcher.Avalonia/Views/ShellView.axaml.cs new file mode 100644 index 00000000..6898eab2 --- /dev/null +++ b/LANCommander.Launcher.Avalonia/Views/ShellView.axaml.cs @@ -0,0 +1,11 @@ +using Avalonia.Controls; + +namespace LANCommander.Launcher.Avalonia.Views; + +public partial class ShellView : UserControl +{ + public ShellView() + { + InitializeComponent(); + } +} diff --git a/LANCommander.Launcher.Avalonia/app.manifest b/LANCommander.Launcher.Avalonia/app.manifest new file mode 100644 index 00000000..62180a80 --- /dev/null +++ b/LANCommander.Launcher.Avalonia/app.manifest @@ -0,0 +1,20 @@ + + + + + + + + + + + + + + + + + + + + diff --git a/LANCommander.Launcher.Models/ListItem.cs b/LANCommander.Launcher.Models/ListItem.cs index 98c56135..ddd98715 100644 --- a/LANCommander.Launcher.Models/ListItem.cs +++ b/LANCommander.Launcher.Models/ListItem.cs @@ -46,6 +46,7 @@ namespace LANCommander.Launcher.Models public ListItem(Game game) { + Key = game.Id; Type = ListItemType.Game; Name = game.Title; diff --git a/LANCommander.SDK/Providers/ServerConfigurationProvider.cs b/LANCommander.SDK/Providers/ServerConfigurationProvider.cs index 432955a7..fa4d506f 100644 --- a/LANCommander.SDK/Providers/ServerConfigurationProvider.cs +++ b/LANCommander.SDK/Providers/ServerConfigurationProvider.cs @@ -57,9 +57,9 @@ public sealed class ServerConfigurationProvider : ConfigurationProvider _source.Configuration.Bind(settings); - if (settings.Authentication.ServerAddress is null) - { - return; + if (settings.Authentication.ServerAddress is null) + { + return; } var request = new HttpRequestMessage(HttpMethod.Get, settings.Authentication.ServerAddress.Join("/api/Settings")); @@ -69,12 +69,13 @@ public sealed class ServerConfigurationProvider : ConfigurationProvider request.Headers.Add("Authorization", $"Bearer {settings.Authentication.Token.AccessToken}"); } - var response = await _httpClient.SendAsync(request, cancellationToken); + // Use ConfigureAwait(false) to prevent deadlocks when called from UI threads + var response = await _httpClient.SendAsync(request, cancellationToken).ConfigureAwait(false); response.EnsureSuccessStatusCode(); - await using var stream = await response.Content.ReadAsStreamAsync(cancellationToken); - var payload = await JsonNode.ParseAsync(stream, cancellationToken: cancellationToken) ?? new JsonObject(); + await using var stream = await response.Content.ReadAsStreamAsync(cancellationToken).ConfigureAwait(false); + var payload = await JsonNode.ParseAsync(stream, cancellationToken: cancellationToken).ConfigureAwait(false) ?? new JsonObject(); var prefix = ""; var data = new Dictionary(StringComparer.OrdinalIgnoreCase); diff --git a/LANCommander.slnx b/LANCommander.slnx index 763ce03b..96f9344d 100644 --- a/LANCommander.slnx +++ b/LANCommander.slnx @@ -3,6 +3,7 @@ +