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/GameDetailView.axaml.cs b/LANCommander.Launcher.Avalonia/Views/GameDetailView.axaml.cs
new file mode 100644
index 00000000..4966c032
--- /dev/null
+++ b/LANCommander.Launcher.Avalonia/Views/GameDetailView.axaml.cs
@@ -0,0 +1,11 @@
+using Avalonia.Controls;
+
+namespace LANCommander.Launcher.Avalonia.Views;
+
+public partial class GameDetailView : UserControl
+{
+ public GameDetailView()
+ {
+ InitializeComponent();
+ }
+}
diff --git a/LANCommander.Launcher.Avalonia/Views/GamesListView.axaml b/LANCommander.Launcher.Avalonia/Views/GamesListView.axaml
new file mode 100644
index 00000000..11dc946c
--- /dev/null
+++ b/LANCommander.Launcher.Avalonia/Views/GamesListView.axaml
@@ -0,0 +1,129 @@
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
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 @@
+