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
This commit is contained in:
Aaron Powell 2026-01-19 14:24:49 +11:00
parent 54a120f870
commit 8cf2139ccf
28 changed files with 1954 additions and 6 deletions

View file

@ -111,13 +111,17 @@
<ItemGroup Label="Microsoft Extensions">
<PackageVersion Include="Microsoft.Extensions.Configuration" Version="9.0.8" />
<PackageVersion Include="Microsoft.Extensions.Configuration.Binder" Version="9.0.9" />
<PackageVersion Include="Microsoft.Extensions.DependencyInjection" Version="9.0.1" />
<PackageVersion Include="Microsoft.Extensions.Hosting" Version="9.0.1" />
<PackageVersion Include="Microsoft.Extensions.Hosting.Abstractions" Version="9.0.9" />
<PackageVersion Include="Microsoft.Extensions.Hosting.Systemd" Version="9.0.1" />
<PackageVersion Include="Microsoft.Extensions.Hosting.WindowsServices" Version="9.0.1" />
<PackageVersion Include="Microsoft.Extensions.Http" Version="9.0.1" />
<PackageVersion Include="Microsoft.Extensions.Http.Resilience" Version="9.7.0" />
<PackageVersion Include="Microsoft.Extensions.Localization" Version="9.0.0" />
<PackageVersion Include="Microsoft.Extensions.Logging" Version="9.0.1" />
<PackageVersion Include="Microsoft.Extensions.Logging.Abstractions" Version="9.0.9" />
<PackageVersion Include="Microsoft.Extensions.Logging.Console" Version="9.0.1" />
<PackageVersion Include="Microsoft.Extensions.ServiceDiscovery" Version="9.4.0" />
</ItemGroup>
<ItemGroup Label="OpenTelemetry">
@ -128,6 +132,13 @@
<PackageVersion Include="OpenTelemetry.Instrumentation.Runtime" Version="1.12.0" />
</ItemGroup>
<ItemGroup Label="Photino">
<PackageVersion Include="AsyncImageLoader.Avalonia" Version="3.3.0" />
<PackageVersion Include="Avalonia" Version="11.2.3" />
<PackageVersion Include="Avalonia.Desktop" Version="11.2.3" />
<PackageVersion Include="Avalonia.Themes.Fluent" Version="11.2.3" />
<PackageVersion Include="Avalonia.Fonts.Inter" Version="11.2.3" />
<PackageVersion Include="Avalonia.ReactiveUI" Version="11.2.3" />
<PackageVersion Include="CommunityToolkit.Mvvm" Version="8.4.0" />
<PackageVersion Include="Photino.Blazor" Version="4.0.13" />
<PackageVersion Include="Photino.Blazor.CustomWindow" Version="1.3.1" />
<PackageVersion Include="Photino.NET" Version="4.0.16" />

View file

@ -0,0 +1,9 @@
<Application xmlns="https://github.com/avaloniaui"
xmlns:x="http://schemas.microsoft.com/winfx/2006/xaml"
x:Class="LANCommander.Launcher.Avalonia.App"
RequestedThemeVariant="Dark">
<Application.Styles>
<FluentTheme />
</Application.Styles>
</Application>

View file

@ -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<App>? _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<ILogger<App>>();
_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<MainWindowViewModel>();
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<IConnectionClient>();
var settingsProvider = scope.ServiceProvider.GetRequiredService<SettingsProvider<Settings.Settings>>();
var databaseContext = scope.ServiceProvider.GetRequiredService<Data.DatabaseContext>();
// 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<MainWindowViewModel>();
_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<Settings.Settings>();
var refresher = configurationBuilder.ReadFromServer<Settings.Settings>(configuration);
configuration = configurationBuilder.Build();
services.Configure<Settings.Settings>(configuration);
services.AddSingleton(refresher); // Register without interface, same as main launcher
// Add SDK client and Launcher services
services.AddLANCommanderClient<Settings.Settings>();
services.AddLANCommanderLauncher();
// ViewModels
services.AddSingleton<MainWindowViewModel>();
}
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");
}
}
/// <summary>
/// Simple file logger provider for debugging
/// </summary>
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>(TState state) where TState : notnull => null;
public bool IsEnabled(LogLevel logLevel) => logLevel >= LogLevel.Debug;
public void Log<TState>(LogLevel logLevel, EventId eventId, TState state, Exception? exception, Func<TState, Exception?, string> 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
}
}
}
}

View file

@ -0,0 +1,26 @@
<Project Sdk="Microsoft.NET.Sdk">
<PropertyGroup>
<OutputType>WinExe</OutputType>
<TargetFramework>net9.0</TargetFramework>
<Nullable>enable</Nullable>
<BuiltInComInteropSupport>true</BuiltInComInteropSupport>
<ApplicationManifest>app.manifest</ApplicationManifest>
<AvaloniaUseCompiledBindingsByDefault>true</AvaloniaUseCompiledBindingsByDefault>
</PropertyGroup>
<ItemGroup>
<PackageReference Include="Avalonia" />
<PackageReference Include="Avalonia.Desktop" />
<PackageReference Include="Avalonia.Themes.Fluent" />
<PackageReference Include="Avalonia.Fonts.Inter" />
<PackageReference Include="Avalonia.ReactiveUI" />
<PackageReference Include="CommunityToolkit.Mvvm" />
<PackageReference Include="Microsoft.Extensions.Http" />
<PackageReference Include="Microsoft.Extensions.Logging.Console" />
</ItemGroup>
<ItemGroup>
<ProjectReference Include="..\LANCommander.Launcher.Services\LANCommander.Launcher.Services.csproj" />
<ProjectReference Include="..\LANCommander.Launcher.Settings\LANCommander.Launcher.Settings.csproj" />
</ItemGroup>
</Project>

View file

@ -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<App>()
.UsePlatformDetect()
.WithInterFont()
.LogToTrace();
}

View file

@ -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;
}
/// <summary>
/// Load game from local cache (Data.Models.Game)
/// Used when selecting from the library sidebar
/// </summary>
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<MediaService>();
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;
}
}
/// <summary>
/// Load game from server API (SDK.Models.Game)
/// Used when selecting from the depot/all games list
/// </summary>
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<Data.Models.Media>? 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);
}
}

View file

@ -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<GamesListViewModel> _logger;
// Store the depot items so we can access them when selecting a game
private IEnumerable<ListItem>? _depotItems;
[ObservableProperty]
private ObservableCollection<GameItemViewModel> _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<SDK.Models.Game>? GameSelected;
public GamesListViewModel(IServiceProvider serviceProvider)
{
_serviceProvider = serviceProvider;
_logger = serviceProvider.GetRequiredService<ILogger<GamesListViewModel>>();
}
[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<DepotService>();
_logger.LogDebug("DepotService resolved");
var libraryService = scope.ServiceProvider.GetRequiredService<LibraryService>();
_logger.LogDebug("LibraryService resolved");
var mediaService = scope.ServiceProvider.GetRequiredService<MediaService>();
_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<GameClient>();
_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;
}
}

View file

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

View file

@ -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<Settings.Settings> _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<Settings.Settings> 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;
}
}

View file

@ -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<Settings.Settings> _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<Settings.Settings> 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;
}
}
}

View file

@ -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<ShellViewModel> _logger;
[ObservableProperty]
private ObservableCollection<LibraryItemViewModel> _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<ILogger<ShellViewModel>>();
}
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<ImportService>();
_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<LibraryService>();
var mediaService = scope.ServiceProvider.GetRequiredService<MediaService>();
_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<LibraryService>();
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<AuthenticationService>();
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;
}
}

View file

@ -0,0 +1,7 @@
using CommunityToolkit.Mvvm.ComponentModel;
namespace LANCommander.Launcher.Avalonia.ViewModels;
public abstract partial class ViewModelBase : ObservableObject
{
}

View file

@ -0,0 +1,178 @@
<UserControl xmlns="https://github.com/avaloniaui"
xmlns:x="http://schemas.microsoft.com/winfx/2006/xaml"
xmlns:vm="using:LANCommander.Launcher.Avalonia.ViewModels"
x:Class="LANCommander.Launcher.Avalonia.Views.GameDetailView"
x:DataType="vm:GameDetailViewModel">
<Grid RowDefinitions="Auto,*">
<!-- Header with Back Button -->
<Border Grid.Row="0" Padding="16" Background="{DynamicResource SystemControlBackgroundChromeMediumBrush}">
<DockPanel>
<Button DockPanel.Dock="Left"
Content="← Back to Depot"
Command="{Binding GoBackCommand}"
Padding="12,8" />
<TextBlock Text="{Binding Title}"
FontSize="20"
FontWeight="Bold"
VerticalAlignment="Center"
Margin="16,0,0,0" />
</DockPanel>
</Border>
<!-- Main Content -->
<ScrollViewer Grid.Row="1" HorizontalScrollBarVisibility="Disabled">
<Grid RowDefinitions="Auto,*">
<!-- Banner/Cover Image -->
<Border Grid.Row="0"
Height="300"
Background="{DynamicResource SystemControlBackgroundChromeMediumLowBrush}">
<Panel>
<!-- Background image (if available) -->
<Image Source="{Binding BackgroundPath}"
Stretch="UniformToFill"
Opacity="0.3"
IsVisible="{Binding BackgroundPath, Converter={x:Static StringConverters.IsNotNullOrEmpty}}" />
<!-- Cover/Banner overlay -->
<Border HorizontalAlignment="Center"
VerticalAlignment="Center"
CornerRadius="8"
ClipToBounds="True"
MaxHeight="280"
BoxShadow="0 4 16 0 #40000000">
<Image Source="{Binding BannerPath}"
Stretch="Uniform"
MaxHeight="280"
IsVisible="{Binding BannerPath, Converter={x:Static StringConverters.IsNotNullOrEmpty}}" />
</Border>
<!-- Fallback when no banner -->
<Border HorizontalAlignment="Center"
VerticalAlignment="Center"
Background="{DynamicResource SystemAccentColor}"
CornerRadius="8"
Padding="40,20"
IsVisible="{Binding BannerPath, Converter={x:Static StringConverters.IsNullOrEmpty}}">
<StackPanel>
<TextBlock Text="🎮" FontSize="48" HorizontalAlignment="Center" />
<TextBlock Text="{Binding Title}"
FontSize="24"
FontWeight="Bold"
HorizontalAlignment="Center"
Foreground="White" />
</StackPanel>
</Border>
</Panel>
</Border>
<!-- Game Details -->
<StackPanel Grid.Row="1" Margin="24" Spacing="16">
<!-- Title and Year -->
<StackPanel Spacing="4">
<TextBlock Text="{Binding Title}"
FontSize="28"
FontWeight="Bold" />
<TextBlock Text="{Binding ReleaseYear}"
FontSize="16"
Opacity="0.7" />
</StackPanel>
<!-- Quick Info Cards -->
<WrapPanel Orientation="Horizontal">
<!-- Developers -->
<Border Margin="0,0,8,8"
Padding="12,8"
CornerRadius="4"
Background="{DynamicResource SystemControlBackgroundChromeMediumLowBrush}"
IsVisible="{Binding Developers, Converter={x:Static StringConverters.IsNotNullOrEmpty}}">
<StackPanel Orientation="Horizontal" Spacing="8">
<TextBlock Text="👨‍💻" />
<TextBlock Text="{Binding Developers}" />
</StackPanel>
</Border>
<!-- Publishers -->
<Border Margin="0,0,8,8"
Padding="12,8"
CornerRadius="4"
Background="{DynamicResource SystemControlBackgroundChromeMediumLowBrush}"
IsVisible="{Binding Publishers, Converter={x:Static StringConverters.IsNotNullOrEmpty}}">
<StackPanel Orientation="Horizontal" Spacing="8">
<TextBlock Text="🏢" />
<TextBlock Text="{Binding Publishers}" />
</StackPanel>
</Border>
<!-- Singleplayer -->
<Border Margin="0,0,8,8"
Padding="12,8"
CornerRadius="4"
Background="{DynamicResource SystemControlBackgroundChromeMediumLowBrush}"
IsVisible="{Binding Singleplayer}">
<StackPanel Orientation="Horizontal" Spacing="8">
<TextBlock Text="👤" />
<TextBlock Text="Singleplayer" />
</StackPanel>
</Border>
<!-- Multiplayer -->
<Border Margin="0,0,8,8"
Padding="12,8"
CornerRadius="4"
Background="{DynamicResource SystemControlBackgroundChromeMediumLowBrush}"
IsVisible="{Binding HasMultiplayer}">
<StackPanel Orientation="Horizontal" Spacing="8">
<TextBlock Text="👥" />
<TextBlock Text="{Binding MultiplayerModes}" />
</StackPanel>
</Border>
</WrapPanel>
<!-- Genres -->
<StackPanel Spacing="8"
IsVisible="{Binding Genres, Converter={x:Static StringConverters.IsNotNullOrEmpty}}">
<TextBlock Text="Genres" FontWeight="SemiBold" FontSize="14" Opacity="0.7" />
<TextBlock Text="{Binding Genres}" TextWrapping="Wrap" />
</StackPanel>
<!-- Platforms -->
<StackPanel Spacing="8"
IsVisible="{Binding Platforms, Converter={x:Static StringConverters.IsNotNullOrEmpty}}">
<TextBlock Text="Platforms" FontWeight="SemiBold" FontSize="14" Opacity="0.7" />
<TextBlock Text="{Binding Platforms}" TextWrapping="Wrap" />
</StackPanel>
<!-- Tags -->
<StackPanel Spacing="8"
IsVisible="{Binding Tags, Converter={x:Static StringConverters.IsNotNullOrEmpty}}">
<TextBlock Text="Tags" FontWeight="SemiBold" FontSize="14" Opacity="0.7" />
<TextBlock Text="{Binding Tags}" TextWrapping="Wrap" />
</StackPanel>
<!-- Description -->
<StackPanel Spacing="8"
IsVisible="{Binding Description, Converter={x:Static StringConverters.IsNotNullOrEmpty}}">
<TextBlock Text="Description" FontWeight="SemiBold" FontSize="14" Opacity="0.7" />
<TextBlock Text="{Binding Description}"
TextWrapping="Wrap"
LineHeight="24" />
</StackPanel>
<!-- Placeholder for future actions -->
<Border Margin="0,16,0,0"
Padding="16"
CornerRadius="8"
Background="{DynamicResource SystemControlBackgroundChromeMediumLowBrush}">
<StackPanel Spacing="8">
<TextBlock Text="Actions" FontWeight="SemiBold" />
<TextBlock Text="Install, Play, and other game actions will be available here in a future update."
Opacity="0.6"
TextWrapping="Wrap" />
</StackPanel>
</Border>
</StackPanel>
</Grid>
</ScrollViewer>
</Grid>
</UserControl>

View file

@ -0,0 +1,11 @@
using Avalonia.Controls;
namespace LANCommander.Launcher.Avalonia.Views;
public partial class GameDetailView : UserControl
{
public GameDetailView()
{
InitializeComponent();
}
}

View file

@ -0,0 +1,129 @@
<UserControl xmlns="https://github.com/avaloniaui"
xmlns:x="http://schemas.microsoft.com/winfx/2006/xaml"
xmlns:vm="using:LANCommander.Launcher.Avalonia.ViewModels"
x:Class="LANCommander.Launcher.Avalonia.Views.GamesListView"
x:DataType="vm:GamesListViewModel">
<DockPanel>
<!-- Header -->
<Border DockPanel.Dock="Top" Padding="16" Background="{DynamicResource SystemControlBackgroundChromeMediumBrush}">
<DockPanel>
<StackPanel DockPanel.Dock="Right" Orientation="Horizontal" Spacing="8">
<Button Content="Refresh" Command="{Binding LoadGamesInternalCommand}" />
</StackPanel>
<StackPanel>
<TextBlock Text="Depot - All Games" FontSize="20" FontWeight="Bold" />
<TextBlock Text="{Binding StatusMessage}" Opacity="0.7" FontSize="12" />
</StackPanel>
</DockPanel>
</Border>
<!-- Search Bar -->
<Border DockPanel.Dock="Top" Padding="16,8">
<TextBox Watermark="Search games..."
Text="{Binding SearchText}" />
</Border>
<!-- Loading Indicator -->
<Border DockPanel.Dock="Top"
IsVisible="{Binding IsLoading}"
Padding="16"
HorizontalAlignment="Center">
<TextBlock Text="Loading games..." />
</Border>
<!-- Error Message -->
<Border DockPanel.Dock="Top"
IsVisible="{Binding HasError}"
Padding="16"
Margin="16,0"
CornerRadius="4">
<TextBlock Text="{Binding StatusMessage}" TextWrapping="Wrap" Foreground="IndianRed" />
</Border>
<!-- Games List -->
<ListBox ItemsSource="{Binding Games}"
SelectedItem="{Binding SelectedGame}"
Padding="8"
SelectionMode="Single">
<ListBox.ItemTemplate>
<DataTemplate DataType="vm:GameItemViewModel">
<Button Command="{Binding $parent[ListBox].((vm:GamesListViewModel)DataContext).ViewGameDetailsCommand}"
CommandParameter="{Binding}"
Background="Transparent"
Padding="0"
HorizontalAlignment="Stretch"
HorizontalContentAlignment="Stretch"
Cursor="Hand">
<Border Padding="12" Margin="4" CornerRadius="8"
Background="{DynamicResource SystemControlBackgroundChromeMediumLowBrush}">
<DockPanel>
<!-- Game Icon -->
<Border DockPanel.Dock="Left"
Width="64" Height="64"
CornerRadius="8"
ClipToBounds="True"
Margin="0,0,16,0">
<Panel>
<!-- Icon from local file -->
<Image Source="{Binding IconPath}"
Stretch="UniformToFill"
IsVisible="{Binding HasIcon}" />
<!-- Fallback icon -->
<Border Background="{DynamicResource SystemAccentColor}"
IsVisible="{Binding !HasIcon}">
<TextBlock Text="🎮"
FontSize="24"
HorizontalAlignment="Center"
VerticalAlignment="Center" />
</Border>
</Panel>
</Border>
<!-- Game Info -->
<StackPanel Spacing="4" VerticalAlignment="Center">
<TextBlock Text="{Binding Title}"
FontSize="16"
FontWeight="SemiBold" />
<StackPanel Orientation="Horizontal" Spacing="8" Opacity="0.7">
<TextBlock Text="{Binding ReleasedOn, StringFormat='{}{0:yyyy}'}" />
<TextBlock Text="•"
IsVisible="{Binding Developers, Converter={x:Static StringConverters.IsNotNullOrEmpty}}" />
<TextBlock Text="{Binding Developers}"
IsVisible="{Binding Developers, Converter={x:Static StringConverters.IsNotNullOrEmpty}}" />
</StackPanel>
<StackPanel Orientation="Horizontal" Spacing="8">
<TextBlock Text="{Binding Genres}"
FontSize="12"
Opacity="0.6"
IsVisible="{Binding Genres, Converter={x:Static StringConverters.IsNotNullOrEmpty}}" />
<!-- In Library indicator -->
<Border Background="{DynamicResource SystemAccentColor}"
CornerRadius="4"
Padding="6,2"
IsVisible="{Binding InLibrary}">
<TextBlock Text="In Library" FontSize="10" Foreground="White" />
</Border>
</StackPanel>
</StackPanel>
<!-- Arrow indicator -->
<TextBlock DockPanel.Dock="Right"
Text=""
FontSize="24"
Opacity="0.5"
VerticalAlignment="Center"
Margin="8,0" />
</DockPanel>
</Border>
</Button>
</DataTemplate>
</ListBox.ItemTemplate>
</ListBox>
</DockPanel>
</UserControl>

View file

@ -0,0 +1,11 @@
using Avalonia.Controls;
namespace LANCommander.Launcher.Avalonia.Views;
public partial class GamesListView : UserControl
{
public GamesListView()
{
InitializeComponent();
}
}

View file

@ -0,0 +1,67 @@
<UserControl xmlns="https://github.com/avaloniaui"
xmlns:x="http://schemas.microsoft.com/winfx/2006/xaml"
xmlns:vm="using:LANCommander.Launcher.Avalonia.ViewModels"
x:Class="LANCommander.Launcher.Avalonia.Views.LoginView"
x:DataType="vm:LoginViewModel">
<Border Padding="40" VerticalAlignment="Center" HorizontalAlignment="Center">
<StackPanel Spacing="20" Width="400">
<TextBlock Text="LANCommander"
FontSize="32"
FontWeight="Bold"
HorizontalAlignment="Center" />
<TextBlock Text="Sign In"
FontSize="18"
HorizontalAlignment="Center"
Opacity="0.7" />
<StackPanel Orientation="Horizontal"
HorizontalAlignment="Center"
Spacing="8"
Opacity="0.6">
<TextBlock Text="Connected to:" />
<TextBlock Text="{Binding ServerAddress}" FontWeight="SemiBold" />
<Button Content="Change"
Command="{Binding ChangeServerCommand}"
Padding="4,0"
Classes="hyperlink" />
</StackPanel>
<StackPanel Spacing="8" Margin="0,20,0,0">
<TextBlock Text="Username" FontWeight="SemiBold" />
<TextBox Watermark="Enter your username"
Text="{Binding Username}"
IsEnabled="{Binding !IsLoading}" />
</StackPanel>
<StackPanel Spacing="8">
<TextBlock Text="Password" FontWeight="SemiBold" />
<TextBox Watermark="Enter your password"
PasswordChar="•"
Text="{Binding Password}"
IsEnabled="{Binding !IsLoading}" />
</StackPanel>
<Button Command="{Binding LoginCommand}"
HorizontalAlignment="Stretch"
HorizontalContentAlignment="Center"
Classes="accent"
Padding="12"
IsEnabled="{Binding !IsLoading}">
<Panel>
<TextBlock Text="Sign In" IsVisible="{Binding !IsLoading}" />
<TextBlock Text="Signing in..." IsVisible="{Binding IsLoading}" />
</Panel>
</Button>
<Border IsVisible="{Binding StatusMessage, Converter={x:Static StringConverters.IsNotNullOrEmpty}}"
Padding="12"
CornerRadius="4">
<TextBlock Text="{Binding StatusMessage}"
TextWrapping="Wrap"
HorizontalAlignment="Center" />
</Border>
</StackPanel>
</Border>
</UserControl>

View file

@ -0,0 +1,11 @@
using Avalonia.Controls;
namespace LANCommander.Launcher.Avalonia.Views;
public partial class LoginView : UserControl
{
public LoginView()
{
InitializeComponent();
}
}

View file

@ -0,0 +1,27 @@
<Window xmlns="https://github.com/avaloniaui"
xmlns:x="http://schemas.microsoft.com/winfx/2006/xaml"
xmlns:vm="using:LANCommander.Launcher.Avalonia.ViewModels"
xmlns:views="using:LANCommander.Launcher.Avalonia.Views"
x:Class="LANCommander.Launcher.Avalonia.Views.MainWindow"
x:DataType="vm:MainWindowViewModel"
Title="{Binding Title}"
Width="1200"
Height="800"
MinWidth="900"
MinHeight="600"
WindowStartupLocation="CenterScreen">
<ContentControl Content="{Binding CurrentView}">
<ContentControl.DataTemplates>
<DataTemplate DataType="vm:ServerSelectionViewModel">
<views:ServerSelectionView />
</DataTemplate>
<DataTemplate DataType="vm:LoginViewModel">
<views:LoginView />
</DataTemplate>
<DataTemplate DataType="vm:ShellViewModel">
<views:ShellView />
</DataTemplate>
</ContentControl.DataTemplates>
</ContentControl>
</Window>

View file

@ -0,0 +1,11 @@
using Avalonia.Controls;
namespace LANCommander.Launcher.Avalonia.Views;
public partial class MainWindow : Window
{
public MainWindow()
{
InitializeComponent();
}
}

View file

@ -0,0 +1,48 @@
<UserControl xmlns="https://github.com/avaloniaui"
xmlns:x="http://schemas.microsoft.com/winfx/2006/xaml"
xmlns:vm="using:LANCommander.Launcher.Avalonia.ViewModels"
x:Class="LANCommander.Launcher.Avalonia.Views.ServerSelectionView"
x:DataType="vm:ServerSelectionViewModel">
<Border Padding="40" VerticalAlignment="Center" HorizontalAlignment="Center">
<StackPanel Spacing="20" Width="400">
<TextBlock Text="LANCommander"
FontSize="32"
FontWeight="Bold"
HorizontalAlignment="Center" />
<TextBlock Text="Connect to Server"
FontSize="18"
HorizontalAlignment="Center"
Opacity="0.7" />
<StackPanel Spacing="8" Margin="0,20,0,0">
<TextBlock Text="Server Address" FontWeight="SemiBold" />
<TextBox Watermark="e.g., 192.168.1.100:5000 or lancommander.local"
Text="{Binding ServerAddress}"
IsEnabled="{Binding !IsLoading}" />
</StackPanel>
<Button Command="{Binding ConnectCommand}"
HorizontalAlignment="Stretch"
HorizontalContentAlignment="Center"
Classes="accent"
Padding="12"
IsEnabled="{Binding !IsLoading}">
<Panel>
<TextBlock Text="Connect" IsVisible="{Binding !IsLoading}" />
<TextBlock Text="Connecting..." IsVisible="{Binding IsLoading}" />
</Panel>
</Button>
<Border IsVisible="{Binding StatusMessage, Converter={x:Static StringConverters.IsNotNullOrEmpty}}"
Padding="12"
CornerRadius="4"
Background="{DynamicResource SystemControlErrorTextForegroundBrush}">
<TextBlock Text="{Binding StatusMessage}"
TextWrapping="Wrap"
HorizontalAlignment="Center" />
</Border>
</StackPanel>
</Border>
</UserControl>

View file

@ -0,0 +1,11 @@
using Avalonia.Controls;
namespace LANCommander.Launcher.Avalonia.Views;
public partial class ServerSelectionView : UserControl
{
public ServerSelectionView()
{
InitializeComponent();
}
}

View file

@ -0,0 +1,124 @@
<UserControl xmlns="https://github.com/avaloniaui"
xmlns:x="http://schemas.microsoft.com/winfx/2006/xaml"
xmlns:vm="using:LANCommander.Launcher.Avalonia.ViewModels"
xmlns:views="using:LANCommander.Launcher.Avalonia.Views"
x:Class="LANCommander.Launcher.Avalonia.Views.ShellView"
x:DataType="vm:ShellViewModel">
<Grid ColumnDefinitions="280,*">
<!-- Left Sidebar -->
<Border Grid.Column="0" Background="{DynamicResource SystemControlBackgroundChromeMediumLowBrush}">
<DockPanel>
<!-- Header -->
<Border DockPanel.Dock="Top" Padding="16" Background="{DynamicResource SystemControlBackgroundChromeMediumBrush}">
<DockPanel>
<Button DockPanel.Dock="Right"
Content="⟳"
Command="{Binding RefreshCommand}"
ToolTip.Tip="Refresh"
Padding="8,4" />
<TextBlock Text="LANCommander"
FontSize="20"
FontWeight="Bold"
VerticalAlignment="Center" />
</DockPanel>
</Border>
<!-- Navigation Buttons -->
<StackPanel DockPanel.Dock="Top" Margin="8,8,8,0">
<Button Command="{Binding ShowDepotCommand}"
HorizontalAlignment="Stretch"
HorizontalContentAlignment="Left"
Padding="12,10"
Margin="0,0,0,4"
Classes.selected="{Binding IsDepotSelected}">
<StackPanel Orientation="Horizontal" Spacing="12">
<TextBlock Text="🏪" FontSize="16" />
<TextBlock Text="Depot (All Games)" VerticalAlignment="Center" />
</StackPanel>
</Button>
</StackPanel>
<!-- Library Section Header -->
<Border DockPanel.Dock="Top" Padding="16,16,16,8">
<TextBlock Text="MY LIBRARY"
FontSize="11"
FontWeight="SemiBold"
Opacity="0.5" />
</Border>
<!-- Footer -->
<Border DockPanel.Dock="Bottom" Padding="8" Background="{DynamicResource SystemControlBackgroundChromeMediumBrush}">
<DockPanel>
<Button DockPanel.Dock="Right"
Content="Logout"
Command="{Binding LogoutCommand}"
Padding="8,4" />
<TextBlock Text="{Binding StatusMessage}"
Opacity="0.6"
FontSize="11"
VerticalAlignment="Center"
TextTrimming="CharacterEllipsis" />
</DockPanel>
</Border>
<!-- Library Items List -->
<ListBox ItemsSource="{Binding LibraryItems}"
SelectedItem="{Binding SelectedLibraryItem}"
Padding="4"
Background="Transparent">
<ListBox.ItemTemplate>
<DataTemplate DataType="vm:LibraryItemViewModel">
<Button Command="{Binding $parent[ListBox].((vm:ShellViewModel)DataContext).SelectLibraryItemCommand}"
CommandParameter="{Binding}"
Background="Transparent"
Padding="8,6"
HorizontalAlignment="Stretch"
HorizontalContentAlignment="Left"
Cursor="Hand">
<StackPanel Orientation="Horizontal" Spacing="10">
<!-- Icon -->
<Border Width="24" Height="24"
CornerRadius="4"
ClipToBounds="True">
<Panel>
<Image Source="{Binding IconPath}"
Stretch="UniformToFill"
IsVisible="{Binding HasIcon}" />
<Border Background="{DynamicResource SystemAccentColor}"
IsVisible="{Binding !HasIcon}">
<TextBlock Text="🎮"
FontSize="12"
HorizontalAlignment="Center"
VerticalAlignment="Center" />
</Border>
</Panel>
</Border>
<!-- Title -->
<TextBlock Text="{Binding Title}"
VerticalAlignment="Center"
TextTrimming="CharacterEllipsis" />
</StackPanel>
</Button>
</DataTemplate>
</ListBox.ItemTemplate>
</ListBox>
</DockPanel>
</Border>
<!-- Main Content Area -->
<Border Grid.Column="1">
<ContentControl Content="{Binding ContentView}">
<ContentControl.DataTemplates>
<DataTemplate DataType="vm:GamesListViewModel">
<views:GamesListView />
</DataTemplate>
<DataTemplate DataType="vm:GameDetailViewModel">
<views:GameDetailView />
</DataTemplate>
</ContentControl.DataTemplates>
</ContentControl>
</Border>
</Grid>
</UserControl>

View file

@ -0,0 +1,11 @@
using Avalonia.Controls;
namespace LANCommander.Launcher.Avalonia.Views;
public partial class ShellView : UserControl
{
public ShellView()
{
InitializeComponent();
}
}

View file

@ -0,0 +1,20 @@
<?xml version="1.0" encoding="utf-8"?>
<assembly manifestVersion="1.0" xmlns="urn:schemas-microsoft-com:asm.v1">
<assemblyIdentity version="1.0.0.0" name="LANCommander.Launcher.Avalonia"/>
<trustInfo xmlns="urn:schemas-microsoft-com:asm.v2">
<security>
<requestedPrivileges xmlns="urn:schemas-microsoft-com:asm.v3">
<requestedExecutionLevel level="asInvoker" uiAccess="false"/>
</requestedPrivileges>
</security>
</trustInfo>
<compatibility xmlns="urn:schemas-microsoft-com:compatibility.v1">
<application>
<supportedOS Id="{e2011457-1546-43c5-a5fe-008deee3d3f0}" />
<supportedOS Id="{35138b9a-5d96-4fbd-8e2d-a2440225f93a}" />
<supportedOS Id="{4a2f28e3-53b9-4441-ba9c-d69d4a4a6e38}" />
<supportedOS Id="{1f676c76-80e1-4239-95bb-83d0f6d0da78}" />
<supportedOS Id="{8e0f7a12-bfb3-4fe8-b9a5-48fd50a15a9a}" />
</application>
</compatibility>
</assembly>

View file

@ -46,6 +46,7 @@ namespace LANCommander.Launcher.Models
public ListItem(Game game)
{
Key = game.Id;
Type = ListItemType.Game;
Name = game.Title;

View file

@ -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<string, string?>(StringComparer.OrdinalIgnoreCase);

View file

@ -3,6 +3,7 @@
<File Path="Directory.Packages.props" />
</Folder>
<Folder Name="/LANCommander.Launcher/">
<Project Path="LANCommander.Launcher.Avalonia\LANCommander.Launcher.Avalonia.csproj" />
<Project Path="LANCommander.Launcher.CLI\LANCommander.Launcher.CLI.csproj" />
<Project Path="LANCommander.Launcher.Data\LANCommander.Launcher.Data.csproj" />
<Project Path="LANCommander.Launcher.Models\LANCommander.Launcher.Models.csproj" />