using System; using System.Collections.Generic; using System.Collections.ObjectModel; using System.Diagnostics; using System.IO; using System.Linq; using System.Threading.Tasks; using Avalonia; using Avalonia.Controls.ApplicationLifetimes; using Avalonia.Controls.Primitives; using Avalonia.Data; using Avalonia.Threading; using CommunityToolkit.Mvvm.ComponentModel; using CommunityToolkit.Mvvm.Input; using LANCommander.Launcher.Views; using LANCommander.SDK.Clients; using LANCommander.Launcher.Data.Models; using LANCommander.Launcher.Services; using LANCommander.Launcher.Services.PowerShell; using LANCommander.SDK.Abstractions; using Microsoft.EntityFrameworkCore; using LANCommander.SDK.Enums; using LANCommander.SDK.Helpers; using LANCommander.SDK.Services; using Microsoft.Extensions.DependencyInjection; using Microsoft.Extensions.Logging; namespace LANCommander.Launcher.ViewModels.Components; /// /// ViewModel for the game action bar component. /// Handles play, install, uninstall, and library management actions. /// public partial class GameActionBarViewModel : ViewModelBase, IDisposable { private readonly IServiceProvider _serviceProvider; private readonly ILogger _logger; [ObservableProperty] private Guid _gameId; [ObservableProperty] private string _title = string.Empty; // Library state [ObservableProperty] private bool _isInLibrary; [ObservableProperty] private bool _isAddingToLibrary; [ObservableProperty] private bool _isRemovingFromLibrary; // Install state [ObservableProperty] [NotifyPropertyChangedFor(nameof(ShowSimplePlayButton))] [NotifyPropertyChangedFor(nameof(CanInstall))] private bool _isInstalled; [ObservableProperty] [NotifyPropertyChangedFor(nameof(CanInstall))] private bool _isInstalling; [ObservableProperty] private bool _isUninstalling; [ObservableProperty] private bool _isVerifyingFiles; [ObservableProperty] private string? _installDirectory; // Play state [ObservableProperty] [NotifyPropertyChangedFor(nameof(ShowUpdateLabel))] [NotifyPropertyChangedFor(nameof(ShowPlayLabel))] private bool _isRunning; [ObservableProperty] [NotifyPropertyChangedFor(nameof(ShowUpdateLabel))] [NotifyPropertyChangedFor(nameof(ShowPlayLabel))] private bool _isStarting; [ObservableProperty] private bool _isStopping; // Stats [ObservableProperty] private string _playTime = Localize("PlayStatNone"); [ObservableProperty] private string _lastPlayed = Localize("LastPlayedNever"); // Status [ObservableProperty] private string? _statusMessage; // Download size (from server archive metadata) [ObservableProperty] private string _downloadSizeText = string.Empty; // Available game actions [ObservableProperty] private ObservableCollection _actions = new(); [ObservableProperty] [NotifyPropertyChangedFor(nameof(ShowSimplePlayButton))] private bool _hasMultipleActions; // Non-primary (secondary) actions shown in the split-button dropdown [ObservableProperty] private ObservableCollection _secondaryActions = new(); [ObservableProperty] private bool _hasSecondaryActions; // Manuals [ObservableProperty] [NotifyPropertyChangedFor(nameof(OpenFirstManualCommand))] private ObservableCollection _manuals = new(); [ObservableProperty] [NotifyPropertyChangedFor(nameof(OpenFirstManualCommand))] private bool _hasManuals; /// /// Command to open the first manual. Returns null if no manuals exist. /// public IRelayCommand? OpenFirstManualCommand => Manuals.FirstOrDefault()?.OpenCommand; // Script debugging [ObservableProperty] private bool _isScriptDebuggingEnabled; // Update available [ObservableProperty] [NotifyPropertyChangedFor(nameof(ShowSimplePlayButton))] [NotifyPropertyChangedFor(nameof(ShowUpdateLabel))] [NotifyPropertyChangedFor(nameof(ShowPlayLabel))] private bool _isUpdateAvailable; // Offline mode - disables install [ObservableProperty] [NotifyPropertyChangedFor(nameof(CanInstall))] private bool _isOfflineMode; /// /// Shows the simple play button when installed but has only one or zero actions /// public bool ShowSimplePlayButton => IsInstalled && !HasMultipleActions; /// /// Shows "Update" label when update available and game is idle (not running/starting) /// public bool ShowUpdateLabel => IsUpdateAvailable && !IsRunning && !IsStarting; /// /// Shows "Play" label when no update available and game is idle (not running/starting) /// public bool ShowPlayLabel => !IsRunning && (!IsUpdateAvailable || IsStarting); /// /// Can install only when online and not already installed /// public bool CanInstall => !IsOfflineMode && !IsInstalled && !IsInstalling; public bool PlayButtonIsEnabled => !IsStopping && !IsStarting; // Timer for checking running state private System.Threading.Timer? _runningCheckTimer; // Timer for refreshing the "Last Played" relative time text private System.Threading.Timer? _lastPlayedTimer; // End time of the most recent play session, used to recompute the relative text private DateTime? _lastSessionEnd; // Events public event EventHandler? LibraryChanged; public event EventHandler? InstallRequested; public GameActionBarViewModel(IServiceProvider serviceProvider) { _serviceProvider = serviceProvider; _logger = serviceProvider.GetRequiredService>(); } /// /// Loads the action bar state for a game from local database /// public async Task LoadFromLocalGameAsync(Game game) { GameId = game.Id; Title = game.Title ?? "Unknown"; IsInstalled = game.Installed; InstallDirectory = game.InstallDirectory; IsUpdateAvailable = game.Installed && !string.IsNullOrWhiteSpace(game.LatestVersion) && game.InstalledVersion != game.LatestVersion; using var scope = _serviceProvider.CreateScope(); var libraryService = scope.ServiceProvider.GetRequiredService(); var settingsProvider = scope.ServiceProvider.GetRequiredService(); IsInLibrary = await libraryService.IsInLibraryAsync(game.Id); IsScriptDebuggingEnabled = settingsProvider.CurrentValue.Debug.EnableScriptDebugging; await LoadPlayStatsAsync(game.Id); LoadManuals(game); await LoadActionsAsync(); StartRunningCheck(); // Check server for updates if installed and not already detected locally if (game.Installed && !IsUpdateAvailable) _ = CheckForUpdateFromServerAsync(game.Id, game.InstalledVersion); } /// /// Loads action bar state for a transient context menu (right-click / gamepad) without /// starting the running-state polling timers. Seeds from the list item, then enriches /// from the local database when the game is present locally. /// public async Task LoadForMenuAsync(GameItemViewModel item) { GameId = item.Id; Title = item.Title; IsInstalled = item.IsInstalled; IsInLibrary = item.InLibrary; IsUpdateAvailable = item.IsUpdateAvailable; using var scope = _serviceProvider.CreateScope(); var libraryService = scope.ServiceProvider.GetRequiredService(); var gameService = scope.ServiceProvider.GetRequiredService(); var settingsProvider = scope.ServiceProvider.GetRequiredService(); IsInLibrary = await libraryService.IsInLibraryAsync(item.Id); IsScriptDebuggingEnabled = settingsProvider.CurrentValue.Debug.EnableScriptDebugging; var localGame = await gameService.GetAsync(item.Id); if (localGame != null) { IsInstalled = localGame.Installed; InstallDirectory = localGame.InstallDirectory; IsUpdateAvailable = localGame.Installed && !string.IsNullOrWhiteSpace(localGame.LatestVersion) && localGame.InstalledVersion != localGame.LatestVersion; await LoadPlayStatsAsync(localGame.Id); LoadManuals(localGame); await LoadActionsAsync(); } } /// /// Loads the action bar state for a game from SDK model /// public async Task LoadFromSdkGameAsync(SDK.Models.Game game) { GameId = game.Id; Title = game.Title ?? "Unknown"; using var scope = _serviceProvider.CreateScope(); var libraryService = scope.ServiceProvider.GetRequiredService(); var gameService = scope.ServiceProvider.GetRequiredService(); var settingsProvider = scope.ServiceProvider.GetRequiredService(); IsInLibrary = await libraryService.IsInLibraryAsync(game.Id); IsScriptDebuggingEnabled = settingsProvider.CurrentValue.Debug.EnableScriptDebugging; // Check if installed from local database var localGame = await gameService.GetAsync(game.Id); if (localGame != null) { IsInstalled = localGame.Installed; InstallDirectory = localGame.InstallDirectory; IsUpdateAvailable = localGame.Installed && !string.IsNullOrWhiteSpace(localGame.LatestVersion) && localGame.InstalledVersion != localGame.LatestVersion; await LoadPlayStatsAsync(localGame.Id); LoadManuals(localGame); } else { IsInstalled = false; InstallDirectory = null; IsUpdateAvailable = false; PlayTime = Localize("PlayStatNone"); LastPlayed = Localize("LastPlayedNever"); Manuals.Clear(); HasManuals = false; } // Download size from latest archive (only show when not installed) if (!IsInstalled) { var latestArchive = game.Archives?.OrderByDescending(a => a.CreatedOn).FirstOrDefault(); DownloadSizeText = latestArchive?.CompressedSize > 0 ? FormatBytes(latestArchive.CompressedSize) : string.Empty; } else { DownloadSizeText = string.Empty; } await LoadActionsAsync(); StartRunningCheck(); // Check server for updates if installed and not already detected locally if (localGame != null && localGame.Installed && !IsUpdateAvailable) _ = CheckForUpdateFromServerAsync(localGame.Id, localGame.InstalledVersion); } private static string FormatBytes(long bytes) { string[] sizes = { "B", "KB", "MB", "GB", "TB" }; int order = 0; double size = bytes; while (size >= 1024 && order < sizes.Length - 1) { order++; size /= 1024; } return $"{size:0.##} {sizes[order]}"; } /// /// Loads available actions for the current game /// private async Task LoadActionsAsync() { Actions.Clear(); SecondaryActions.Clear(); HasMultipleActions = false; HasSecondaryActions = false; if (!IsInstalled || string.IsNullOrEmpty(InstallDirectory)) return; try { using var scope = _serviceProvider.CreateScope(); var gameClient = scope.ServiceProvider.GetRequiredService(); var actions = await gameClient.GetActionsAsync(InstallDirectory, GameId); if (actions != null && actions.Any()) { foreach (var action in actions.Where(a => a.IsPrimaryAction).OrderBy(a => a.SortOrder)) Actions.Add(new GameActionViewModel(action, RunActionAsync)); foreach (var action in actions.Where(a => !a.IsPrimaryAction).OrderBy(a => a.SortOrder)) SecondaryActions.Add(new GameActionViewModel(action, RunActionAsync)); HasMultipleActions = Actions.Count > 1; HasSecondaryActions = SecondaryActions.Count > 0; } } catch (Exception ex) { _logger.LogError(ex, "Failed to load actions for game {GameId}", GameId); } } /// /// Refreshes the state from the database. /// Called after an installation completes. /// public async Task RefreshAsync() { if (GameId == Guid.Empty) return; try { using var scope = _serviceProvider.CreateScope(); var gameService = scope.ServiceProvider.GetRequiredService(); var libraryService = scope.ServiceProvider.GetRequiredService(); var localGame = await gameService.GetAsync(GameId); if (localGame != null) { IsInstalled = localGame.Installed; InstallDirectory = localGame.InstallDirectory; IsUpdateAvailable = localGame.Installed && !string.IsNullOrWhiteSpace(localGame.LatestVersion) && localGame.InstalledVersion != localGame.LatestVersion; IsInLibrary = await libraryService.IsInLibraryAsync(GameId); await LoadPlayStatsAsync(localGame.Id); LoadManuals(localGame); await LoadActionsAsync(); StatusMessage = IsInstalled ? "Installation complete!" : null; _logger.LogInformation("Refreshed action bar for {Title}: Installed={Installed}", Title, IsInstalled); } } catch (Exception ex) { _logger.LogError(ex, "Failed to refresh action bar for {GameId}", GameId); } } public void StartRunningCheck() { _runningCheckTimer?.Dispose(); _runningCheckTimer = new System.Threading.Timer( _ => CheckRunningState(), null, TimeSpan.Zero, TimeSpan.FromMilliseconds(500)); _lastPlayedTimer?.Dispose(); _lastPlayedTimer = new System.Threading.Timer( _ => Dispatcher.UIThread.Post(UpdateLastPlayedText), null, TimeSpan.FromMinutes(1), TimeSpan.FromMinutes(1)); } public void StopRunningCheck() { _runningCheckTimer?.Dispose(); _runningCheckTimer = null; _lastPlayedTimer?.Dispose(); _lastPlayedTimer = null; } /// /// Stops any polling timers. Used by transient menu-backing instances so they don't /// leave background timers running after the menu closes. /// public void Dispose() { StopRunningCheck(); } private void CheckRunningState() { if (GameId == Guid.Empty) return; try { using var scope = _serviceProvider.CreateScope(); var gameClient = scope.ServiceProvider.GetRequiredService(); var wasRunning = IsRunning; var nowRunning = gameClient.IsRunning(GameId); // If game stopped running, reset states and refresh stats if (wasRunning && !nowRunning) { // Dispatch UI updates to the UI thread Dispatcher.UIThread.Post(async () => { IsRunning = false; IsStarting = false; IsStopping = false; // Refresh play stats await RefreshPlayStatsAsync(); }); } else if (nowRunning != IsRunning) { // Update running state on UI thread Dispatcher.UIThread.Post(() => IsRunning = nowRunning); } } catch (Exception ex) { _logger.LogError(ex, "Error checking running state"); } } private async Task RefreshPlayStatsAsync() { try { if (Dispatcher.UIThread.CheckAccess()) { await LoadPlayStatsAsync(GameId); } else { await Dispatcher.UIThread.InvokeAsync(() => LoadPlayStatsAsync(GameId)); } } catch (Exception ex) { _logger.LogError(ex, "Failed to refresh play stats"); } } private async Task LoadPlayStatsAsync(Guid gameId) { using var scope = _serviceProvider.CreateScope(); var dbContext = scope.ServiceProvider.GetRequiredService(); var playSessions = await dbContext.Set() .Where(ps => ps.GameId == gameId && ps.Start != null && ps.End != null) .ToListAsync(); if (playSessions.Any()) { var totalTime = new TimeSpan(playSessions .Select(ps => ps.End!.Value.Subtract(ps.Start!.Value)) .Sum(ts => ts.Ticks)); if (totalTime.TotalMinutes < 1) PlayTime = Localize("PlayStatNone"); else if (totalTime.TotalHours < 1) PlayTime = Localize("PlayTimeMinutes", $"{totalTime.TotalMinutes:0}"); else PlayTime = Localize("PlayTimeHours", $"{totalTime.TotalHours:0.#}"); var lastSession = playSessions .OrderByDescending(ps => ps.End) .First(); _lastSessionEnd = lastSession.End!.Value; UpdateLastPlayedText(); } else { PlayTime = Localize("PlayStatNone"); _lastSessionEnd = null; LastPlayed = Localize("LastPlayedNever"); } } /// /// Recomputes the relative "Last Played" text from the cached last session end time. /// Called on load and periodically so the text stays current without re-querying. /// private void UpdateLastPlayedText() { if (_lastSessionEnd is not { } end) { LastPlayed = Localize("LastPlayedNever"); return; } var elapsed = DateTime.Now - end; if (elapsed.TotalMinutes < 1) LastPlayed = Localize("LastPlayedJustNow"); else if (elapsed.TotalHours < 1) { var minutes = (int)elapsed.TotalMinutes; LastPlayed = Localize(minutes == 1 ? "LastPlayedMinuteAgo" : "LastPlayedMinutesAgo", minutes); } else if (elapsed.TotalDays < 1) { var hours = (int)elapsed.TotalHours; LastPlayed = Localize(hours == 1 ? "LastPlayedHourAgo" : "LastPlayedHoursAgo", hours); } else if (elapsed.TotalDays < 7) { var days = (int)elapsed.TotalDays; LastPlayed = Localize(days == 1 ? "LastPlayedDayAgo" : "LastPlayedDaysAgo", days); } else LastPlayed = end.ToString("MMM d, yyyy"); } /// /// Checks the server for available updates and updates local state if found. /// If no update is available, refreshes the on-disk manifest and scripts. /// Runs in the background (fire-and-forget) so it doesn't block UI loading. /// private async Task CheckForUpdateFromServerAsync(Guid gameId, string installedVersion) { try { using var scope = _serviceProvider.CreateScope(); var gameClient = scope.ServiceProvider.GetRequiredService(); var gameService = scope.ServiceProvider.GetRequiredService(); var importService = scope.ServiceProvider.GetRequiredService(); var redistributableClient = scope.ServiceProvider.GetRequiredService(); var hasUpdate = await gameClient.CheckForUpdateAsync(gameId, installedVersion); if (!hasUpdate && !string.IsNullOrEmpty(InstallDirectory)) { // Check redistributables for updates var localGame = await gameService.GetAsync(gameId); if (localGame?.Redistributables != null) { foreach (var redistributable in localGame.Redistributables) { try { var redistManifest = await ManifestHelper.ReadAsync(InstallDirectory, redistributable.Id); if (redistManifest == null || string.IsNullOrWhiteSpace(redistManifest.Version)) continue; var redistHasUpdate = await redistributableClient.CheckForUpdateAsync(redistributable.Id, redistManifest.Version); if (redistHasUpdate) { _logger.LogInformation("Redistributable {RedistName} ({RedistId}) has an update available for game {GameId}", redistributable.Name, redistributable.Id, gameId); hasUpdate = true; break; } } catch (Exception ex) { _logger.LogDebug(ex, "Could not check for redistributable {RedistId} updates", redistributable.Id); } } } } if (hasUpdate) { _logger.LogInformation("Server reports update available for game {GameId}", gameId); // Re-import the game to pull latest version info into local DB await importService.ImportGameAsync(gameId); var localGame = await gameService.GetAsync(gameId); if (localGame != null) { await Dispatcher.UIThread.InvokeAsync(() => { IsUpdateAvailable = true; }); } } else if (!string.IsNullOrEmpty(InstallDirectory)) { // No update available — refresh manifest and scripts to keep them in sync _logger.LogDebug("Refreshing manifest and scripts for game {GameId}", gameId); await gameClient.RefreshManifestAndScriptsAsync(InstallDirectory, gameId); } } catch (Exception ex) { _logger.LogDebug(ex, "Could not check for updates for game {GameId}", gameId); } } [RelayCommand] private async Task UpdateGameAsync() { if (!IsInstalled || !IsUpdateAvailable || IsInstalling) return; IsInstalling = true; StatusMessage = "Preparing to update..."; try { using var scope = _serviceProvider.CreateScope(); var gameService = scope.ServiceProvider.GetRequiredService(); var installService = scope.ServiceProvider.GetRequiredService(); var localGame = await gameService.GetAsync(GameId); if (localGame == null) throw new InvalidOperationException("Game not found in local database"); _logger.LogInformation("Adding game {GameId} ({Title}) to update queue", GameId, Title); await installService.Add(localGame, localGame.InstallDirectory); StatusMessage = "Added to download queue"; InstallRequested?.Invoke(this, EventArgs.Empty); } catch (Exception ex) { _logger.LogError(ex, "Failed to start update for game {GameId} ({Title})", GameId, Title); StatusMessage = $"Failed to update: {ex.Message}"; await Views.AlertOverlay.ShowAsync("Failed to Update", ex.Message); } finally { IsInstalling = false; } } [RelayCommand(AllowConcurrentExecutions = true)] private Task PrimaryActionAsync() { if (IsRunning) return StopAsync(); if (IsUpdateAvailable) return UpdateGameAsync(); return PlayAsync(); } [RelayCommand(AllowConcurrentExecutions = true)] private Task PlayOrStopAsync() => IsRunning ? StopAsync() : PlayAsync(); [RelayCommand] private async Task PlayAsync() { if (!IsInstalled || IsStarting || IsRunning) return; // If we have actions loaded, either pick directly or show a chooser if (Actions.Any()) { SDK.Models.Manifest.Action? chosen; if (HasMultipleActions) { var tcs = new System.Threading.Tasks.TaskCompletionSource(); await Dispatcher.UIThread.InvokeAsync(() => { var overlayVm = new GameActionsOverlayViewModel(Title, Actions); var overlay = new Views.GameActionsOverlay { DataContext = overlayVm, HorizontalAlignment = global::Avalonia.Layout.HorizontalAlignment.Stretch, VerticalAlignment = global::Avalonia.Layout.VerticalAlignment.Stretch, }; overlay.ActionSelected += (_, action) => tcs.TrySetResult(action); var mainWindow = (Application.Current?.ApplicationLifetime as IClassicDesktopStyleApplicationLifetime)?.MainWindow; var layer = OverlayLayer.GetOverlayLayer(mainWindow); if (layer is not null) { overlay.Bind(global::Avalonia.Layout.Layoutable.WidthProperty, new Binding("Bounds.Width") { Source = layer }); overlay.Bind(global::Avalonia.Layout.Layoutable.HeightProperty, new Binding("Bounds.Height") { Source = layer }); layer.Children.Add(overlay); } else { // No overlay layer available — fall back to first action tcs.TrySetResult(Actions.First().Action); } }); chosen = await tcs.Task; } else { chosen = Actions.First().Action; } if (chosen != null) await RunActionAsync(chosen); return; } // Fallback: load actions on demand and run the first primary one IsStarting = true; { StatusMessage = "Starting..."; var discordClient = _serviceProvider.GetRequiredService(); try { using var scope = _serviceProvider.CreateScope(); var gameService = scope.ServiceProvider.GetRequiredService(); var gameClient = scope.ServiceProvider.GetRequiredService(); var localGame = await gameService.GetAsync(GameId); if (localGame == null) { throw new InvalidOperationException("Game not found in local database"); } // Get available actions var actions = await gameClient.GetActionsAsync(localGame.InstallDirectory, GameId); if (actions == null || !actions.Any()) { throw new InvalidOperationException("No actions available for this game"); } // Find primary action or first action var primaryAction = actions.FirstOrDefault(a => a.IsPrimaryAction) ?? actions.First(); _logger.LogInformation("Running action {ActionName} for game {GameId}", primaryAction.Name, GameId); var discordAppId = localGame.ExternalIds? .FirstOrDefault(e => string.Equals(e.Provider, "Discord", StringComparison.OrdinalIgnoreCase)) ?.ExternalId; discordClient.UpdatePresence(localGame.Title, discordAppId); // Run the game await gameService.Run(localGame, primaryAction); StatusMessage = null; } catch (Exception ex) { _logger.LogError(ex, "Failed to play game {GameId}", GameId); StatusMessage = $"Failed to start: {ex.Message}"; await Views.AlertOverlay.ShowAsync("Failed to Launch", ex.Message); } finally { discordClient.ClearPresence(); IsStarting = false; } } } /// /// Runs a specific action for the game /// public async Task RunActionAsync(SDK.Models.Manifest.Action action) { if (!IsInstalled || IsStarting || IsRunning) return; IsStarting = true; StatusMessage = $"Starting {action.Name}..."; var discordClient = _serviceProvider.GetRequiredService(); try { using var scope = _serviceProvider.CreateScope(); var gameService = scope.ServiceProvider.GetRequiredService(); var localGame = await gameService.GetAsync(GameId); if (localGame == null) { throw new InvalidOperationException("Game not found in local database"); } _logger.LogInformation("Running action {ActionName} for game {GameId}", action.Name, GameId); var discordAppId = localGame.ExternalIds? .FirstOrDefault(e => string.Equals(e.Provider, "Discord", StringComparison.OrdinalIgnoreCase)) ?.ExternalId; discordClient.UpdatePresence(localGame.Title, discordAppId); // Run the game with the specific action await gameService.Run(localGame, action); StatusMessage = null; } catch (Exception ex) { _logger.LogError(ex, "Failed to run action {ActionName} for game {GameId}", action.Name, GameId); StatusMessage = $"Failed to start: {ex.Message}"; await Views.AlertOverlay.ShowAsync("Failed to Launch", ex.Message); } finally { discordClient.ClearPresence(); IsStarting = false; } } [RelayCommand] private async Task StopAsync() { if (!IsRunning || IsStopping) return; IsStopping = true; StatusMessage = "Stopping..."; try { using var scope = _serviceProvider.CreateScope(); var gameClient = scope.ServiceProvider.GetRequiredService(); gameClient.Stop(GameId); _logger.LogInformation("Stop requested for game {GameId}", GameId); // Wait briefly for process to stop await Task.Delay(500); StatusMessage = null; } catch (Exception ex) { _logger.LogError(ex, "Failed to stop game {GameId}", GameId); StatusMessage = $"Failed to stop: {ex.Message}"; } finally { IsStopping = false; } } [RelayCommand] private async Task InstallAsync() { if (IsInstalling) return; IsInstalling = true; StatusMessage = "Preparing to install..."; try { using var scope = _serviceProvider.CreateScope(); var importService = scope.ServiceProvider.GetRequiredService(); var libraryService = scope.ServiceProvider.GetRequiredService(); var gameService = scope.ServiceProvider.GetRequiredService(); var installService = scope.ServiceProvider.GetRequiredService(); var gameClient = scope.ServiceProvider.GetRequiredService(); var settingsProvider = scope.ServiceProvider.GetRequiredService(); // Ensure game is in library if (!IsInLibrary) { _logger.LogInformation("Game {GameId} ({Title}) not in library, adding first", GameId, Title); StatusMessage = "Adding to library..."; await importService.ImportGameAsync(GameId); await libraryService.AddToLibraryAsync(GameId); await libraryService.RefreshItemsAsync(); IsInLibrary = true; LibraryChanged?.Invoke(this, EventArgs.Empty); } var localGame = await gameService.GetAsync(GameId); if (localGame == null) throw new InvalidOperationException("Game not found in local database after import"); // ── Gather options ───────────────────────────────────────────────── StatusMessage = "Checking available options..."; var installDirectories = settingsProvider.CurrentValue.Games.InstallDirectories ?? []; var availableAddons = Array.Empty(); var availableTools = Array.Empty(); try { var addons = await gameClient.GetAddonsAsync(GameId); availableAddons = addons?.ToArray() ?? []; } catch (Exception ex) { _logger.LogWarning(ex, "Could not fetch addons for {GameId}", GameId); } try { var tools = await gameClient.GetToolsAsync(GameId); availableTools = tools?.Where(t => t.Archives?.Any() ?? false).ToArray() ?? []; } catch (Exception ex) { _logger.LogWarning(ex, "Could not fetch tools for {GameId}", GameId); } var needsDialog = availableAddons.Length > 0 || availableTools.Length > 0 || installDirectories.Length > 1; // ── Build options VM ─────────────────────────────────────────────── var optionsVm = new InstallOptionsViewModel(); foreach (var dir in installDirectories) optionsVm.InstallDirectories.Add(dir); optionsVm.SelectedInstallDirectory = installDirectories.FirstOrDefault() ?? string.Empty; optionsVm.GameTitle = Title ?? "Game"; optionsVm.DialogTitle = $"Install {optionsVm.GameTitle}"; optionsVm.ConfirmButtonText = "Install"; // Fetch base game archive sizes try { var game = await gameClient.GetAsync(GameId); var archives = game?.Archives?.ToArray() ?? []; optionsVm.BaseDownloadSize = archives.Sum(a => a.CompressedSize); optionsVm.BaseSpaceRequired = archives.Sum(a => a.UncompressedSize); } catch { /* sizes will show as 0 */ } // Add addons sorted by type, then name foreach (var addon in availableAddons .OrderBy(a => a.Type) .ThenBy(a => a.Title ?? string.Empty)) optionsVm.Addons.Add(new InstallAddonItemViewModel(addon, selectedByDefault: false)); // Add tools sorted by name foreach (var tool in availableTools.OrderBy(t => t.Name ?? string.Empty)) optionsVm.Tools.Add(new InstallToolItemViewModel(tool, selectedByDefault: false)); // ── Show dialog if needed ────────────────────────────────────────── if (needsDialog) { var tcs = new System.Threading.Tasks.TaskCompletionSource(); await Dispatcher.UIThread.InvokeAsync(() => { var overlay = new Views.InstallOptionsOverlay { DataContext = optionsVm, HorizontalAlignment = global::Avalonia.Layout.HorizontalAlignment.Stretch, VerticalAlignment = global::Avalonia.Layout.VerticalAlignment.Stretch, }; overlay.DialogClosed += (_, result) => tcs.TrySetResult(result); var mainWindow = (Application.Current?.ApplicationLifetime as IClassicDesktopStyleApplicationLifetime)?.MainWindow; var layer = OverlayLayer.GetOverlayLayer(mainWindow); if (layer is not null) { overlay.Bind(global::Avalonia.Layout.Layoutable.WidthProperty, new Binding("Bounds.Width") { Source = layer }); overlay.Bind(global::Avalonia.Layout.Layoutable.HeightProperty, new Binding("Bounds.Height") { Source = layer }); layer.Children.Add(overlay); } }); var confirmed = await tcs.Task; if (confirmed != true) { StatusMessage = null; return; } } // ── Queue the install ────────────────────────────────────────────── StatusMessage = "Starting installation..."; _logger.LogInformation("Adding game {GameId} ({Title}) to install queue", GameId, Title); await installService.Add( localGame, optionsVm.SelectedInstallDirectory, optionsVm.SelectedAddons.Length > 0 ? optionsVm.SelectedAddons : null, optionsVm.SelectedTools.Length > 0 ? optionsVm.SelectedTools : null); StatusMessage = "Added to download queue"; InstallRequested?.Invoke(this, EventArgs.Empty); } catch (Exception ex) { _logger.LogError(ex, "Failed to start installation for game {GameId} ({Title})", GameId, Title); StatusMessage = $"Failed to install: {ex.Message}"; await Views.AlertOverlay.ShowAsync("Failed to Install", ex.Message); } finally { IsInstalling = false; } } [RelayCommand] private async Task ModifyAsync() { if (!IsInstalled) return; try { using var scope = _serviceProvider.CreateScope(); var gameService = scope.ServiceProvider.GetRequiredService(); var installService = scope.ServiceProvider.GetRequiredService(); var gameClient = scope.ServiceProvider.GetRequiredService(); var settingsProvider = scope.ServiceProvider.GetRequiredService(); var dbContext = scope.ServiceProvider.GetRequiredService(); var localGame = await dbContext.Set() .Include(g => g.DependentGames) .Include(g => g.GameTools) .FirstOrDefaultAsync(g => g.Id == GameId); if (localGame == null) throw new InvalidOperationException("Game not found in local database"); // ── Gather options ───────────────────────────────────────────────── var installDirectories = settingsProvider.CurrentValue.Games.InstallDirectories ?? []; var availableAddons = Array.Empty(); var availableTools = Array.Empty(); try { var addons = await gameClient.GetAddonsAsync(GameId); availableAddons = addons?.ToArray() ?? []; } catch (Exception ex) { _logger.LogWarning(ex, "Could not fetch addons for {GameId}", GameId); } try { var tools = await gameClient.GetToolsAsync(GameId); availableTools = tools?.Where(t => t.Archives?.Any() ?? false).ToArray() ?? []; } catch (Exception ex) { _logger.LogWarning(ex, "Could not fetch tools for {GameId}", GameId); } // Build set of currently installed addon IDs var installedAddonIds = new HashSet( (localGame.DependentGames ?? []) .Where(a => a.Installed) .Select(a => a.Id)); // Build set of currently installed tool IDs (tracked per game) var installedToolIds = new HashSet( (localGame.GameTools ?? []) .Where(gt => gt.Installed) .Select(gt => gt.ToolId)); // ── Build options VM ─────────────────────────────────────────────── var optionsVm = new InstallOptionsViewModel(); foreach (var dir in installDirectories) optionsVm.InstallDirectories.Add(dir); // If current install directory isn't in the list, add it if (!string.IsNullOrEmpty(localGame.InstallDirectory)) { var currentDir = System.IO.Path.GetDirectoryName(localGame.InstallDirectory) ?? localGame.InstallDirectory; if (!optionsVm.InstallDirectories.Contains(currentDir)) optionsVm.InstallDirectories.Insert(0, currentDir); optionsVm.SelectedInstallDirectory = currentDir; } else { optionsVm.SelectedInstallDirectory = installDirectories.FirstOrDefault() ?? string.Empty; } optionsVm.GameTitle = Title ?? "Game"; optionsVm.DialogTitle = $"Modify {optionsVm.GameTitle}"; optionsVm.ConfirmButtonText = "Apply"; optionsVm.AlwaysShowDirectory = true; // Fetch base game archive sizes try { var game = await gameClient.GetAsync(GameId); var archives = game?.Archives?.ToArray() ?? []; optionsVm.BaseDownloadSize = archives.Sum(a => a.CompressedSize); optionsVm.BaseSpaceRequired = archives.Sum(a => a.UncompressedSize); } catch { /* sizes will show as 0 */ } // Add addons sorted by type, then name; pre-select currently installed ones foreach (var addon in availableAddons .OrderBy(a => a.Type) .ThenBy(a => a.Title ?? string.Empty)) optionsVm.Addons.Add(new InstallAddonItemViewModel(addon, selectedByDefault: installedAddonIds.Contains(addon.Id))); // Add tools sorted by name; pre-select currently installed ones foreach (var tool in availableTools.OrderBy(t => t.Name ?? string.Empty)) optionsVm.Tools.Add(new InstallToolItemViewModel(tool, selectedByDefault: installedToolIds.Contains(tool.Id))); // ── Show dialog ─────────────────────────────────────────────────── var tcs = new System.Threading.Tasks.TaskCompletionSource(); await Dispatcher.UIThread.InvokeAsync(() => { var overlay = new Views.InstallOptionsOverlay { DataContext = optionsVm, HorizontalAlignment = global::Avalonia.Layout.HorizontalAlignment.Stretch, VerticalAlignment = global::Avalonia.Layout.VerticalAlignment.Stretch, }; overlay.DialogClosed += (_, result) => tcs.TrySetResult(result); var mainWindow = (Application.Current?.ApplicationLifetime as IClassicDesktopStyleApplicationLifetime)?.MainWindow; var layer = OverlayLayer.GetOverlayLayer(mainWindow); if (layer is not null) { overlay.Bind(global::Avalonia.Layout.Layoutable.WidthProperty, new Binding("Bounds.Width") { Source = layer }); overlay.Bind(global::Avalonia.Layout.Layoutable.HeightProperty, new Binding("Bounds.Height") { Source = layer }); layer.Children.Add(overlay); } }); var confirmed = await tcs.Task; if (confirmed != true) return; // ── Queue the modification ──────────────────────────────────────── _logger.LogInformation("Modifying game {GameId} ({Title}): install dir={Dir}, addons={AddonCount}, tools={ToolCount}", GameId, Title, optionsVm.SelectedInstallDirectory, optionsVm.SelectedAddons.Length, optionsVm.SelectedTools.Length); await installService.Add( localGame, optionsVm.SelectedInstallDirectory, optionsVm.SelectedAddons.Length > 0 ? optionsVm.SelectedAddons : null, optionsVm.SelectedTools.Length > 0 ? optionsVm.SelectedTools : null); StatusMessage = "Added to download queue"; InstallRequested?.Invoke(this, EventArgs.Empty); } catch (Exception ex) { _logger.LogError(ex, "Failed to modify game {GameId} ({Title})", GameId, Title); StatusMessage = $"Failed to modify: {ex.Message}"; } } [RelayCommand] private async Task UninstallAsync() { if (!IsInstalled || IsUninstalling) return; IsUninstalling = true; StatusMessage = "Uninstalling..."; try { using var scope = _serviceProvider.CreateScope(); var gameService = scope.ServiceProvider.GetRequiredService(); var localGame = await gameService.GetAsync(GameId); if (localGame == null) { throw new InvalidOperationException("Game not found in local database"); } _logger.LogInformation("Uninstalling game {GameId} ({Title})", GameId, Title); await gameService.UninstallAsync(localGame); IsInstalled = false; InstallDirectory = null; StatusMessage = "Uninstalled"; _logger.LogInformation("Game {GameId} ({Title}) uninstalled", GameId, Title); // Notify that library has changed (install status changed) LibraryChanged?.Invoke(this, EventArgs.Empty); } catch (Exception ex) { _logger.LogError(ex, "Failed to uninstall game {GameId} ({Title})", GameId, Title); StatusMessage = $"Failed to uninstall: {ex.Message}"; } finally { IsUninstalling = false; } } [RelayCommand] private async Task VerifyFilesAsync() { if (!IsInstalled || IsVerifyingFiles || string.IsNullOrEmpty(InstallDirectory)) return; IsVerifyingFiles = true; StatusMessage = "Verifying files..."; try { using var scope = _serviceProvider.CreateScope(); var gameClient = scope.ServiceProvider.GetRequiredService(); var conflicts = await gameClient.ValidateFilesAsync(InstallDirectory, GameId); var conflictList = conflicts?.ToList() ?? new(); if (conflictList.Count == 0) { StatusMessage = "All files verified successfully"; _logger.LogInformation("File verification passed for game {GameId} ({Title})", GameId, Title); } else { StatusMessage = $"{conflictList.Count} file(s) need repair, restoring..."; _logger.LogInformation("File verification found {Count} conflict(s) for game {GameId} ({Title}), restoring", conflictList.Count, GameId, Title); await gameClient.RestoreFilesAsync(InstallDirectory, GameId, conflictList.Select(c => c.FullName)); StatusMessage = $"{conflictList.Count} file(s) restored"; _logger.LogInformation("File restoration complete for game {GameId} ({Title})", GameId, Title); } } catch (Exception ex) { _logger.LogError(ex, "Failed to verify files for game {GameId} ({Title})", GameId, Title); StatusMessage = $"Verification failed: {ex.Message}"; } finally { IsVerifyingFiles = false; } } [RelayCommand] private async Task AddToLibraryAsync() { if (IsInLibrary || IsAddingToLibrary) return; IsAddingToLibrary = true; StatusMessage = "Adding to library..."; try { using var scope = _serviceProvider.CreateScope(); var importService = scope.ServiceProvider.GetRequiredService(); var libraryService = scope.ServiceProvider.GetRequiredService(); await importService.ImportGameAsync(GameId); await libraryService.AddToLibraryAsync(GameId); await libraryService.RefreshItemsAsync(); IsInLibrary = true; StatusMessage = "Added to library"; _logger.LogInformation("Game {GameId} ({Title}) added to library", GameId, Title); LibraryChanged?.Invoke(this, EventArgs.Empty); } catch (Exception ex) { _logger.LogError(ex, "Failed to add game {GameId} ({Title}) to library", GameId, Title); StatusMessage = $"Failed to add: {ex.Message}"; await Views.AlertOverlay.ShowAsync("Failed to Add to Library", ex.Message); } finally { IsAddingToLibrary = false; } } [RelayCommand] private async Task RemoveFromLibraryAsync() { if (!IsInLibrary || IsRemovingFromLibrary) return; IsRemovingFromLibrary = true; StatusMessage = "Removing from library..."; try { using var scope = _serviceProvider.CreateScope(); var libraryService = scope.ServiceProvider.GetRequiredService(); await libraryService.RemoveFromLibraryAsync(GameId); await libraryService.RefreshItemsAsync(); IsInLibrary = false; StatusMessage = "Removed from library"; _logger.LogInformation("Game {GameId} ({Title}) removed from library", GameId, Title); LibraryChanged?.Invoke(this, EventArgs.Empty); } catch (Exception ex) { _logger.LogError(ex, "Failed to remove game {GameId} ({Title}) from library", GameId, Title); StatusMessage = $"Failed to remove: {ex.Message}"; await Views.AlertOverlay.ShowAsync("Failed to Remove from Library", ex.Message); } finally { IsRemovingFromLibrary = false; } } [RelayCommand] private void BrowseFiles() { if (string.IsNullOrEmpty(InstallDirectory) || !Directory.Exists(InstallDirectory)) { StatusMessage = "Install directory not found"; return; } try { Process.Start(new ProcessStartInfo { FileName = InstallDirectory, UseShellExecute = true }); } catch (Exception ex) { _logger.LogError(ex, "Failed to open install directory"); StatusMessage = $"Failed to open folder: {ex.Message}"; } } private void LoadManuals(Game game) { Manuals.Clear(); if (game.Media == null || !game.Media.Any()) { HasManuals = false; return; } using var scope = _serviceProvider.CreateScope(); var mediaService = scope.ServiceProvider.GetRequiredService(); var manualMedia = game.Media .Where(m => m.Type == SDK.Enums.MediaType.Manual) .ToList(); foreach (var manual in manualMedia) { var filePath = mediaService.GetImagePath(manual); if (File.Exists(filePath)) { var title = string.IsNullOrWhiteSpace(manual.Name) ? "Manual" : manual.Name; Manuals.Add(new ManualViewModel(title, filePath, OpenManual)); } } HasManuals = Manuals.Count > 0; } private void OpenManual(ManualViewModel manual) { try { var items = new List(); int startIndex = 0; for (int i = 0; i < Manuals.Count; i++) { items.Add(new LightboxItem { Type = LightboxItemType.Pdf, Path = Manuals[i].FilePath, Title = Manuals[i].Title, }); if (Manuals[i] == manual) startIndex = i; } LightboxOverlay.ShowOverlay(items, startIndex); } catch (Exception ex) { _logger.LogError(ex, "Failed to open manual {Title}", manual.Title); StatusMessage = $"Failed to open manual: {ex.Message}"; } } /// /// Opens a PowerShell console in the game's install directory /// [RelayCommand] private void OpenPowerShellConsole() { if (string.IsNullOrEmpty(InstallDirectory) || !Directory.Exists(InstallDirectory)) { StatusMessage = "Game is not installed"; return; } try { // Open a standalone PowerShell window for the install directory var processInfo = new ProcessStartInfo { FileName = "powershell.exe", Arguments = $"-NoLogo -NoExit -WorkingDirectory \"{InstallDirectory}\"", UseShellExecute = true }; Process.Start(processInfo); } catch (Exception ex) { _logger.LogError(ex, "Failed to open PowerShell console for {Title}", Title); StatusMessage = $"Failed to open console: {ex.Message}"; } } /// /// Opens a console window, runs the specified script type using the SDK's script execution, then stays interactive /// private async Task OpenScriptTerminalAsync(ScriptType scriptType, string scriptTypeName) { if (string.IsNullOrEmpty(InstallDirectory) || !Directory.Exists(InstallDirectory)) { StatusMessage = "Game is not installed"; return; } try { using var scope = _serviceProvider.CreateScope(); var scriptClient = scope.ServiceProvider.GetRequiredService(); var scriptDebugger = _serviceProvider.GetRequiredService(); // Create and show the console window var viewModel = new PowerShellConsoleViewModel($"{scriptTypeName} Scripts - {Title}", InstallDirectory); var window = new Views.PowerShellConsoleWindow { DataContext = viewModel }; viewModel.CloseAction = () => window.Close(); // Wire up the debugger events to the console control var console = window.ConsoleControl; scriptDebugger.OnDebugStart = context => { console.OnDebugStart(context); return Task.CompletedTask; }; scriptDebugger.OnOutput = (level, message) => { console.OnOutput(level, message); return Task.CompletedTask; }; scriptDebugger.OnDebugBreak = async context => { await console.OnDebugBreakAsync(context); }; scriptDebugger.OnDebugEnd = context => { console.OnDebugEnd(context); return Task.CompletedTask; }; // Show the window window.Show(); // Run the appropriate script type // The script client already handles debug mode when EnableScriptDebugging is true scriptClient.Debug = true; // Force debug mode for this execution StatusMessage = $"Running {scriptTypeName} scripts..."; var gameClient = scope.ServiceProvider.GetRequiredService(); var manifests = await gameClient.GetManifestsAsync(InstallDirectory, GameId); foreach (var manifest in manifests) { switch (scriptType) { case ScriptType.Install: await scriptClient.Game_RunInstallScriptAsync(InstallDirectory, GameId); break; case ScriptType.Uninstall: await scriptClient.Game_RunUninstallScriptAsync(InstallDirectory, GameId); break; case ScriptType.NameChange: var userService = scope.ServiceProvider.GetRequiredService(); var user = await userService.GetCurrentUser(); await scriptClient.Game_RunNameChangeScriptAsync(InstallDirectory, GameId, user.GetUserNameSafe ?? SDK.Models.Settings.DEFAULT_GAME_USERNAME); break; case ScriptType.KeyChange: var key = await gameClient.GetAllocatedKeyAsync(manifest.Id); await scriptClient.Game_RunKeyChangeScriptAsync(InstallDirectory, GameId, key); break; } } StatusMessage = $"{scriptTypeName} scripts completed"; } catch (Exception ex) { _logger.LogError(ex, "Failed to run {ScriptType} scripts for {Title}", scriptTypeName, Title); StatusMessage = $"Script error: {ex.Message}"; } } /// /// Runs install scripts in a debug console /// [RelayCommand] private Task RunInstallScriptsAsync() { return OpenScriptTerminalAsync(ScriptType.Install, "Install"); } /// /// Runs uninstall scripts in a debug console /// [RelayCommand] private Task RunUninstallScriptsAsync() { return OpenScriptTerminalAsync(ScriptType.Uninstall, "Uninstall"); } /// /// Runs name change scripts /// [RelayCommand] private Task RunNameChangeScriptsAsync() { return OpenScriptTerminalAsync(ScriptType.NameChange, "Name Change"); } /// /// Runs key change scripts /// [RelayCommand] private Task RunKeyChangeScriptsAsync() { return OpenScriptTerminalAsync(ScriptType.KeyChange, "Key Change"); } } /// /// ViewModel for a game action (used in the Play dropdown) /// public partial class GameActionViewModel : ViewModelBase { public SDK.Models.Manifest.Action Action { get; } public string Name => Action.Name; private readonly Func _runAction; public GameActionViewModel(SDK.Models.Manifest.Action action, Func runAction) { Action = action; _runAction = runAction; } [RelayCommand] private async Task RunAsync() { await _runAction(Action); } } /// /// ViewModel for a game manual (used in the Manuals menu) /// public partial class ManualViewModel : ViewModelBase { public string Title { get; } public string FilePath { get; } private readonly Action _openManual; public ManualViewModel(string title, string filePath, Action openManual) { Title = title; FilePath = filePath; _openManual = openManual; } [RelayCommand] private void Open() { _openManual(this); } } /// /// ViewModel for the "choose a primary action" overlay shown when a game /// has more than one primary action configured. /// public class GameActionsOverlayViewModel { public string GameTitle { get; } public IReadOnlyList Actions { get; } public GameActionsOverlayViewModel(string gameTitle, IEnumerable actions) { GameTitle = gameTitle; Actions = actions.ToList(); } }