using Force.Crc32; using LANCommander.SDK.Enums; using LANCommander.SDK.Exceptions; using LANCommander.SDK.Extensions; using LANCommander.SDK.Helpers; using LANCommander.SDK.Models; using Microsoft.Extensions.Logging; using SharpCompress.Common; using SharpCompress.Readers; using System; using System.Collections.Generic; using System.IO; using System.Linq; using System.Net; using System.Net.Http; using System.Text; using System.Threading; using System.Threading.Tasks; using LANCommander.SDK.Abstractions; using LANCommander.SDK.Factories; using LANCommander.SDK.Plugins; using LANCommander.SDK.Plugins.Events; using Action = System.Action; namespace LANCommander.SDK.Services { public class InstallProgress { public Game Game { get; set; } public string Title { get; set; } public Guid IconId { get; set; } public InstallStatus Status { get; set; } public bool Indeterminate { get; set; } public float Progress { get { return BytesTransferred / (float)TotalBytes; } set { } } public long TransferSpeed { get; set; } public long BytesTransferred { get; set; } public long TotalBytes { get; set; } public TimeSpan TimeRemaining { get; set; } } public class InstallResult { public InstallResult() { } public InstallResult(string installDirectory, Guid gameId) { FileList = new GameInstallationFileList(installDirectory, gameId); } public string InstallDirectory { get => FileList.InstallDirectory; internal set => FileList.InstallDirectory = value; } public GameInstallationFileList FileList { get; set; } = GameInstallationFileList.Empty; } public class GameClient( ILogger logger, ApiRequestFactory apiRequestFactory, ProcessExecutionContextFactory processExecutionContextFactory, INetworkInformationProvider networkInformationProvider, ISettingsProvider settingsProvider, IConnectionClient connectionClient, RedistributableClient redistributableClient, SaveClient saveClient, ScriptClient scriptClient, ProfileClient profileClient, LobbyClient lobbyClient, ToolClient toolClient, IPluginEventBus pluginEventBus) { public delegate void OnArchiveEntryExtractionProgressHandler(object sender, ArchiveEntryExtractionProgressArgs e); public event OnArchiveEntryExtractionProgressHandler OnArchiveEntryExtractionProgress; public delegate void OnArchiveExtractionProgressHandler(long position, long length, Game game); public event OnArchiveExtractionProgressHandler OnArchiveExtractionProgress; public delegate void OnInstallProgressUpdateHandler(InstallProgress e); public event OnInstallProgressUpdateHandler OnInstallProgressUpdate; public delegate void OnTaskProgressHandler(InstallTaskProgress progress); public event OnTaskProgressHandler OnTaskProgress; private const string PlayerAliasFilename = "PlayerAlias"; private const string KeyFilename = "Key"; private static readonly TimeSpan ServerNotificationTimeout = TimeSpan.FromSeconds(15); private TrackableStream _transferStream; private IAsyncReader _reader; private readonly InstallProgress _installProgress = new(); private readonly Dictionary _running = new(); public async Task> GetAsync() { return await apiRequestFactory .Create() .UseAuthenticationToken() .UseVersioning() .UseRoute("/api/Games") .GetAsync>(); } public async Task GetAsync(Guid id) { return await apiRequestFactory .Create() .UseAuthenticationToken() .UseVersioning() .UseRoute($"/api/Games/{id}") .GetAsync(); } public async Task GetManifestAsync(Guid id) { return await apiRequestFactory .Create() .UseAuthenticationToken() .UseVersioning() .UseRoute($"/api/Games/{id}/Manifest") .GetAsync(); } public async Task GetManifestAsync(Guid id, Guid versionId) { return await apiRequestFactory .Create() .UseAuthenticationToken() .UseVersioning() .UseRoute($"/api/Games/{id}/Versions/{versionId}/Manifest") .GetAsync(); } public async Task> GetVersionsAsync(Guid id) { return await apiRequestFactory .Create() .UseAuthenticationToken() .UseVersioning() .UseRoute($"/api/Games/{id}/Versions") .GetAsync>(); } public async Task> GetManifestsAsync(string installDirectory, Guid id) { var manifests = new List(); var mainManifest = await ManifestHelper.ReadAsync(installDirectory, id); if (mainManifest == null) return manifests; manifests.Add(mainManifest); if (mainManifest.Addons != null) { foreach (var addon in mainManifest.Addons) { try { if (ManifestHelper.Exists(installDirectory, addon.Id)) { var addonManifest = await ManifestHelper.ReadAsync(installDirectory, addon.Id); if (addonManifest?.Type == GameType.Expansion || addonManifest?.Type == GameType.Mod) manifests.Add(addon); } } catch (Exception ex) { logger?.LogError(ex, $"Could not load manifest from dependent game {addon.Id}"); } } } return manifests; } public async Task> GetActionsAsync(string installDirectory, Guid id) { var actions = new List(); var manifests = await GetManifestsAsync(installDirectory, id); var installedIds = manifests.Select(m => m.Id).ToHashSet(); try { if (connectionClient.IsConnected() && !connectionClient.IsOfflineMode()) { using var cts = new CancellationTokenSource(TimeSpan.FromSeconds(5)); var serverActions = await apiRequestFactory .Create() .UseRoute($"/api/Games/{id}/Actions") .UseAuthenticationToken() .UseVersioning() .UseCancellationToken(cts.Token) .GetAsync>(); actions.AddRange(serverActions .Where(a => installedIds.Contains(a.GameId)) .Select(a => new Models.Manifest.Action { Name = a.Name, Arguments = a.Arguments, Path = a.Path, WorkingDirectory = a.WorkingDirectory, IsPrimaryAction = a.IsPrimaryAction, SortOrder = a.SortOrder, Variables = a.Variables, Platforms = a.Platforms })); } } catch (Exception ex) { logger?.LogError(ex, "Could not get actions from server"); } if (!actions.Any()) { actions = manifests .Where(m => m != null && m.Actions != null) .SelectMany(m => m.Actions) .OrderByDescending(a => a.IsPrimaryAction) .ThenBy(a => a.SortOrder) .ToList(); } // Merge in actions from tools that are actually installed. Tool actions are persisted to // the game's install directory (its manifest) only when the tool is installed, so the // presence of the tool manifest on disk gates whether its actions appear. var mainManifest = manifests.FirstOrDefault(m => m.Id == id); if (mainManifest?.Tools != null) { foreach (var tool in mainManifest.Tools) { if (!ManifestHelper.Exists(installDirectory, tool.Id)) continue; try { var toolManifest = await ManifestHelper.ReadAsync(installDirectory, tool.Id); if (toolManifest?.Actions != null) actions.AddRange(toolManifest.Actions); } catch (Exception ex) { logger?.LogError(ex, "Could not load actions from installed tool {ToolId}", tool.Id); } } } if (manifests.Any(m => m.MultiplayerModes?.Any(m => m.NetworkProtocol == NetworkProtocol.Lobby) ?? false)) { var primaryAction = actions.First(a => a.IsPrimaryAction); try { var lobbies = lobbyClient.GetSteamLobbies(installDirectory, id); foreach (var lobby in lobbies) { var lobbyAction = new Models.Manifest.Action { Arguments = $"{primaryAction.Arguments} +connect_lobby {lobby.Id}", IsPrimaryAction = true, Name = $"Join {lobby.ExternalUsername}'s lobby", SortOrder = actions.Count, Path = primaryAction.Path, WorkingDirectory = primaryAction.WorkingDirectory }; actions.Add(lobbyAction); } } catch (Exception ex) { logger?.LogError(ex, "Could not get lobbies"); } } // Only surface actions that support the runtime the launcher is currently running on. actions = actions .Where(a => EnvironmentHelper.SupportsCurrentRuntime(a.Platforms)) .ToList(); return actions; } public async Task> GetAddonsAsync(Guid id) { return await apiRequestFactory .Create() .UseAuthenticationToken() .UseVersioning() .UseRoute($"/api/Games/{id}/Addons") .GetAsync>(); } public async Task> GetToolsAsync(Guid id) { return await apiRequestFactory .Create() .UseAuthenticationToken() .UseVersioning() .UseRoute($"/api/Games/{id}/Tools") .GetAsync>(); } public async Task> GetScriptsAsync(Guid id) { return await apiRequestFactory .Create() .UseAuthenticationToken() .UseVersioning() .UseRoute($"/api/Games/{id}/Scripts") .GetAsync>(); } public async Task> GetScriptsAsync(Guid id, Guid versionId) { return await apiRequestFactory .Create() .UseAuthenticationToken() .UseVersioning() .UseRoute($"/api/Games/{id}/Versions/{versionId}/Scripts") .GetAsync>(); } public async Task CheckForUpdateAsync(Guid id, string currentVersion) { return await apiRequestFactory .Create() .UseAuthenticationToken() .UseVersioning() .UseRoute($"/api/Games/{id}/CheckForUpdate?version={currentVersion}") .GetAsync(); } public async Task> GetUpdatesAsync(Guid gameId, string version) { return await apiRequestFactory .Create() .UseAuthenticationToken() .UseVersioning() .UseRoute($"/api/Games/{gameId}/Updates?version={version}") .GetAsync>(); } private async Task StreamArchiveAsync(Guid archiveId) { return await apiRequestFactory .Create() .UseAuthenticationToken() .UseVersioning() .UseRoute($"/Download/Archive/{archiveId}") .StreamAsync(); } /// /// Downloads and extracts a specific archive for a game update. /// /// True if successful, false if canceled. public async Task ApplyUpdateArchiveAsync(Guid archiveId, Guid gameId, string destination, CancellationToken cancellationToken = default) { var game = await GetAsync(gameId); if (game == null) throw new InstallException($"Could not fetch game info for game {gameId}"); _installProgress.Game = game; _installProgress.Title = game.Title; var result = await DownloadAndExtractArchiveAsync(archiveId, game, destination, cancellationToken); if (result.Canceled) return false; if (!result.Success) throw new InstallException("Could not extract the update archive. Retry the update or check your connection"); return true; } internal async Task DownloadAndExtractArchiveAsync(Guid archiveId, Game game, string destination, CancellationToken cancellationToken = default) { if (game == null) throw new ArgumentNullException(nameof(game), "No game was specified"); logger?.LogTrace("Downloading archive {ArchiveId} and extracting {Game} to path {Destination}", archiveId, game.Title, destination); var extractionResult = new ExtractionResult { Canceled = false, }; var fileManifest = new StringBuilder(); var files = new List(); try { Directory.CreateDirectory(destination); var stream = await StreamArchiveAsync(archiveId); var monitor = new FileTransferMonitor(stream.Length); var progress = new Progress(report => { if (cancellationToken.IsCancellationRequested) { _reader?.Cancel(); _installProgress.Status = InstallStatus.Canceled; OnInstallProgressUpdate?.Invoke(_installProgress); return; } if (monitor.CanUpdate()) { monitor.Update(stream.Position); _installProgress.BytesTransferred = monitor.GetBytesTransferred(); _installProgress.TotalBytes = stream.Length; _installProgress.TransferSpeed = monitor.GetSpeed(); _installProgress.TimeRemaining = monitor.GetTimeRemaining(); OnInstallProgressUpdate?.Invoke(_installProgress); } OnArchiveEntryExtractionProgress?.Invoke(this, new ArchiveEntryExtractionProgressArgs { Progress = report, Game = game, }); }); _reader = await ReaderFactory.OpenAsyncReader(stream, new ReaderOptions { Progress = progress }, cancellationToken); _installProgress.Status = InstallStatus.Downloading; OnInstallProgressUpdate?.Invoke(_installProgress); while (await _reader.MoveToNextEntryAsync(cancellationToken)) { if (_reader.Cancelled) break; try { var entryKey = _reader.Entry.Key; var localFile = Path.Combine(destination, entryKey); fileManifest.AppendLine($"{entryKey} | {_reader.Entry.Crc.ToString("X")}"); files.Add(new ExtractionResult.FileEntry { EntryPath = entryKey, LocalPath = localFile, }); await _reader.WriteEntryToDirectoryAsync(destination, new ExtractionOptions() { ExtractFullPath = true, Overwrite = true, PreserveFileTime = true }, cancellationToken); } catch (IOException ex) { var errorCode = ex.HResult & 0xFFFF; if (errorCode == 87) throw; else logger?.LogTrace("Not replacing existing file/folder on disk: {EntryKey} - {Message}", _reader.Entry.Key, ex.Message); await using var es = await _reader.OpenEntryStreamAsync(cancellationToken); } } await _reader.DisposeAsync(); await stream.DisposeAsync(); } catch (ReaderCancelledException ex) { logger?.LogTrace(ex, "User cancelled the download"); extractionResult.Canceled = true; } catch (Exception ex) { logger?.LogError(ex, "Could not extract archive {ArchiveId} to path {Destination}", archiveId, destination); throw new Exception("The game archive could not be extracted, is it corrupted? Please try again"); } if (!extractionResult.Canceled) { extractionResult.Success = true; extractionResult.Directory = destination; extractionResult.Files = files; var fileListDestination = Path.Combine(destination, ".lancommander", game.Id.ToString(), "FileList.txt"); if (!Directory.Exists(Path.GetDirectoryName(fileListDestination))) Directory.CreateDirectory(Path.GetDirectoryName(fileListDestination)); File.WriteAllText(fileListDestination, fileManifest.ToString()); } return extractionResult; } private async Task CanStreamLatestArchiveAsync(Guid id) { try { await apiRequestFactory .Create() .UseAuthenticationToken() .UseVersioning() .UseRoute($"/api/Games/{id}/Download") .HeadAsync(); return true; } catch { return false; } } private async Task StreamLatestArchiveAsync(Guid id) { return await apiRequestFactory .Create() .UseAuthenticationToken() .UseVersioning() .UseRoute($"/api/Games/{id}/Download") .StreamAsync(); } public async Task StartedAsync(Guid id) { if (!connectionClient.IsConnected()) return; logger?.LogTrace("Signaling to the server that we started the game..."); using var timeout = new CancellationTokenSource(ServerNotificationTimeout); try { await apiRequestFactory .Create() .UseAuthenticationToken() .UseVersioning() .UseRoute($"/api/Games/{id}/Started") .UseCancellationToken(timeout.Token) .GetAsync(); } catch (Exception ex) { logger?.LogError(ex, "Failed sending start request to server"); } } public async Task StoppedAsync(Guid id) { if (!connectionClient.IsConnected()) return; logger?.LogTrace("Signaling to the server that we stopped the game..."); using var timeout = new CancellationTokenSource(ServerNotificationTimeout); try { await apiRequestFactory .Create() .UseAuthenticationToken() .UseVersioning() .UseRoute($"/api/Games/{id}/Stopped") .UseCancellationToken(timeout.Token) .GetAsync(); } catch (Exception ex) { logger?.LogError(ex, "Failed sending stop request to server"); } } public async Task GetAllocatedKeyAsync(Guid id) { logger?.LogTrace("Requesting allocated key..."); var request = new KeyRequest() { GameId = id, MacAddress = networkInformationProvider.GetMacAddress(), ComputerName = Environment.MachineName, IpAddress = networkInformationProvider.GetIpAddress(), }; var response = await apiRequestFactory .Create() .UseAuthenticationToken() .UseVersioning() .UseRoute($"/api/Keys/GetAllocated/{id}") .AddBody(request) .PostAsync(); if (response == null) return string.Empty; return response.Value; } public async Task GetNewKey(Guid id) { logger?.LogTrace("Requesting new key allocation..."); var request = new KeyRequest() { GameId = id, MacAddress = networkInformationProvider.GetMacAddress(), ComputerName = Environment.MachineName, IpAddress = networkInformationProvider.GetIpAddress(), }; var response = await apiRequestFactory .Create() .UseAuthenticationToken() .UseVersioning() .UseRoute($"/api/Keys/Allocate/{id}") .AddBody(request) .PostAsync(); if (response == null) return string.Empty; return response.Value; } /// /// Downloads, extracts, and runs post-install scripts for the specified game /// /// Unique identifier of the game to install. /// Optional custom installation directory. /// Optional list of add-on identifiers to install alongside the game. /// Maximum attempts in case of transmission error /// /// An containing details about the installation outcome such as the final install path. /// /// /// Thrown if installation fails after the maximum retry attempts. /// public async Task InstallAsync(Guid gameId, string installDirectory = "", Guid[] addonIds = null, int maxAttempts = 10, CancellationToken cancellationToken = default) { var installResult = new InstallResult(installDirectory, gameId); var gameFileList = installResult.FileList; SDK.Models.Manifest.Game manifest = null; if (string.IsNullOrWhiteSpace(installDirectory)) installDirectory = settingsProvider.CurrentValue.Games.InstallDirectories.First(); var game = await GetAsync(gameId); var destination = await GetInstallDirectory(game, installDirectory); _installProgress.Game = game; _installProgress.Title = game.Title; _installProgress.Status = InstallStatus.Downloading; _installProgress.Progress = 0; _installProgress.TransferSpeed = 0; _installProgress.TotalBytes = 0; _installProgress.BytesTransferred = 0; OnInstallProgressUpdate?.Invoke(_installProgress); // Handle Standalone Mods if (game.Type == GameType.StandaloneMod && game.BaseGameId != Guid.Empty) { var baseGame = await GetAsync(game.BaseGameId); destination = await GetInstallDirectory(baseGame, installDirectory); if (!Directory.Exists(destination)) { var baseGameFileList = await InstallAsync(game.BaseGameId, installDirectory, null, maxAttempts, cancellationToken); destination = installResult.InstallDirectory; } } try { if (ManifestHelper.Exists(destination, game.Id)) manifest = await ManifestHelper.ReadAsync(destination, game.Id); } catch (Exception ex) { logger?.LogTrace(ex, "Error reading manifest before install"); } logger?.LogTrace("Installing game {GameTitle} ({GameId})", game.Title, game.Id); // Download and extract var result = await RetryHelper.RetryOnExceptionAsync(maxAttempts, TimeSpan.FromMilliseconds(500), new ExtractionResult(), async () => { logger?.LogTrace("Attempting to download and extract game"); return await Task.Run(async () => await DownloadAndExtractAsync(game, destination, cancellationToken)); }); if (!result.Success && !result.Canceled) throw new InstallException("Could not extract the installer. Retry the install or check your connection"); else if (result.Canceled) throw new InstallCanceledException("Game install was canceled"); game.InstallDirectory = result.Directory; installResult.InstallDirectory = result.Directory; // Game is extracted, get metadata var writeManifestSuccess = await RetryHelper.RetryOnExceptionAsync(maxAttempts, TimeSpan.FromSeconds(1), false, async () => { logger?.LogTrace("Attempting to get game manifest"); manifest = await WriteManifestAsync(game.InstallDirectory, game); return true; }); if (!writeManifestSuccess) throw new InstallException("Could not grab the manifest file. Retry the install or check your connection"); // store scripts locally await WriteScriptsAsync(game.InstallDirectory, game); // store manifest and files for current game (could be base game, or any dependent game as this point due to recursive call) gameFileList.BaseGame.Manifest = manifest; var gameFiles = result?.Files?.Where(x => !x.EntryPath.EndsWith("/")).Select(x => new GameInstallationFileListEntry.FileEntry { EntryPath = x.EntryPath, LocalPath = x.LocalPath, }); gameFileList.BaseGame.AddFiles(gameFiles ?? []); _installProgress.Progress = 1; _installProgress.BytesTransferred = _installProgress.TotalBytes; _installProgress.Status = InstallStatus.InstallingRedistributables; OnInstallProgressUpdate?.Invoke(_installProgress); #region Install Redistributables if (game.Redistributables != null && game.Redistributables.Any()) { logger?.LogTrace("Installing redistributables"); await redistributableClient.InstallAsync(game); } #endregion #region Download Latest Save logger?.LogInformation("Downloading latest save for game {GameTitle} ({GameId}) during install", game.Title, game.Id); _installProgress.Status = InstallStatus.DownloadingSaves; OnInstallProgressUpdate?.Invoke(_installProgress); await saveClient.DownloadAsync(game.InstallDirectory, game.Id); #endregion await RunPostInstallScripts(game); if (addonIds != null) { var addonsResult = await InstallAddonsAsync(installDirectory, game, addonIds); gameFileList.MergeDependentGames(addonsResult.FileList); } _installProgress.Status = InstallStatus.Complete; _installProgress.Progress = 1; _installProgress.BytesTransferred = _installProgress.TotalBytes; OnInstallProgressUpdate?.Invoke(_installProgress); return installResult; } public async Task InstallAddonsAsync(string installDirectory, Guid baseGameId, IEnumerable addonIds) { var game = await GetAsync(baseGameId); return await InstallAddonsAsync(installDirectory, game, addonIds); } public async Task InstallAddonsAsync(string installDirectory, Game game, IEnumerable addonIds) { var installResult = new InstallResult(installDirectory, game.Id); var gameFileList = installResult.FileList; if (addonIds != null) { var addons = new List(); foreach (var addonId in addonIds) { try { addons.Add(await GetAsync(addonId)); } catch (Exception ex) { logger?.LogError(ex, "Could not get information for addon with ID {AddonId}, skipping install", addonId); } } var expansions = addons.Where(a => a?.Type == GameType.Expansion).ToList(); foreach (var expansion in expansions) { try { _installProgress.Status = InstallStatus.Downloading; _installProgress.Game = expansion; _installProgress.Progress = 0; _installProgress.BytesTransferred = 0; _installProgress.TotalBytes = 1; _installProgress.BytesTransferred = 0; OnInstallProgressUpdate?.Invoke(_installProgress); var expansionResult = await InstallAddonAsync(installDirectory, expansion); gameFileList.MergeBaseAsDependentGame(expansion.Id, expansionResult.FileList); } catch (Exception ex) { logger?.LogError(ex, "Could not install expansion with ID {AddonId}", expansion.Id); } } var mods = addons.Where(a => a?.Type == GameType.Mod).ToList(); foreach (var mod in mods) { try { _installProgress.Status = InstallStatus.Downloading; _installProgress.Game = mod; _installProgress.Progress = 0; _installProgress.BytesTransferred = 0; _installProgress.TotalBytes = 1; _installProgress.BytesTransferred = 0; OnInstallProgressUpdate?.Invoke(_installProgress); var modResult = await InstallAddonAsync(installDirectory, mod); gameFileList.MergeBaseAsDependentGame(mod.Id, modResult.FileList); } catch (Exception ex) { logger?.LogError(ex, "Could not install mod with ID {AddonId}", mod.Id); } } } return installResult; } public async Task InstallAddonAsync(string installDirectory, Game addon) { var installResult = new InstallResult(installDirectory, addon.Id); var gameFileList = installResult.FileList; if (!addon.IsAddon) return installResult; OnInstallProgressUpdate?.Invoke(_installProgress); try { var addonResult = await InstallAsync(addon.Id, installDirectory); gameFileList.Merge(addonResult.FileList); } catch (InstallCanceledException ex) { logger?.LogDebug("Install canceled"); _installProgress.Status = InstallStatus.Canceled; OnInstallProgressUpdate?.Invoke(_installProgress); throw; } catch (Exception ex) { logger?.LogError(ex, "Failed to install addon {AddonTitle} ({AddonId})", addon.Title, addon.Id); _installProgress.Status = InstallStatus.Failed; OnInstallProgressUpdate?.Invoke(_installProgress); throw; } await RunPostInstallScripts(addon); return installResult; } /// /// Generates an install plan for a game, producing a list of queue items and their tasks /// without executing anything. /// public async Task GenerateInstallPlanAsync(Guid gameId, string installDirectory, Guid[] addonIds = null, Guid[] toolIds = null) { logger?.LogInformation("[InstallQueue] GenerateInstallPlan: gameId={GameId}, installDir={InstallDir}, addonIds={AddonIds}", gameId, installDirectory, addonIds != null ? string.Join(",", addonIds) : "none"); var plan = new InstallPlan(); var game = await GetAsync(gameId); logger?.LogInformation("[InstallQueue] GenerateInstallPlan: Fetched game {Title} ({Id}), type={Type}, baseGameId={BaseGameId}, redistCount={RedistCount}, scriptCount={ScriptCount}", game?.Title, game?.Id, game?.Type, game?.BaseGameId, game?.Redistributables?.Count() ?? 0, game?.Scripts?.Count() ?? 0); if (string.IsNullOrWhiteSpace(installDirectory)) installDirectory = settingsProvider.CurrentValue.Games.InstallDirectories.First(); var destination = await GetInstallDirectory(game, installDirectory); logger?.LogInformation("[InstallQueue] GenerateInstallPlan: Resolved install directory to {Destination}", destination); // Handle standalone mods — need base game first if (game.Type == GameType.StandaloneMod && game.BaseGameId != Guid.Empty) { var baseGame = await GetAsync(game.BaseGameId); var baseDestination = await GetInstallDirectory(baseGame, installDirectory); if (!Directory.Exists(baseDestination)) { var basePlan = await GenerateInstallPlanAsync(game.BaseGameId, installDirectory); plan.Items.AddRange(basePlan.Items); } destination = baseDestination; } // Base game item var gameItem = new InstallPlanItem { EntityId = game.Id, Title = game.Title, Type = InstallPlanItemType.Game, InstallDirectory = destination, Order = plan.Items.Count, }; int taskOrder = 0; gameItem.Tasks.Add(new InstallTaskDefinition { Type = InstallTaskType.VerifyFiles, Title = "Verify local files", Order = taskOrder++, TargetId = game.Id, TargetName = game.Title, IsCritical = false, }); gameItem.Tasks.Add(new InstallTaskDefinition { Type = InstallTaskType.DownloadAndExtract, Title = $"Download {game.Title}", Order = taskOrder++, TargetId = game.Id, TargetName = game.Title, IsCritical = true, ReportsProgress = true, }); gameItem.Tasks.Add(new InstallTaskDefinition { Type = InstallTaskType.WriteManifest, Title = "Write manifest", Order = taskOrder++, TargetId = game.Id, TargetName = game.Title, IsCritical = true, }); gameItem.Tasks.Add(new InstallTaskDefinition { Type = InstallTaskType.WriteScripts, Title = "Save scripts", Order = taskOrder++, TargetId = game.Id, TargetName = game.Title, IsCritical = false, }); gameItem.Tasks.Add(new InstallTaskDefinition { Type = InstallTaskType.DownloadSaves, Title = "Download saves", Order = taskOrder++, TargetId = game.Id, TargetName = game.Title, IsCritical = false, ReportsProgress = true, }); if (game.Scripts != null && game.Scripts.Any()) { gameItem.Tasks.Add(new InstallTaskDefinition { Type = InstallTaskType.RunInstallScript, Title = "Run install script", Order = taskOrder++, TargetId = game.Id, TargetName = game.Title, IsCritical = false, }); gameItem.Tasks.Add(new InstallTaskDefinition { Type = InstallTaskType.RunKeyChangeScript, Title = "Apply key", Order = taskOrder++, TargetId = game.Id, TargetName = game.Title, IsCritical = false, }); gameItem.Tasks.Add(new InstallTaskDefinition { Type = InstallTaskType.RunNameChangeScript, Title = "Apply player name", Order = taskOrder++, TargetId = game.Id, TargetName = game.Title, IsCritical = false, }); } if (game.Media != null && game.Media.Any(m => m.Type == MediaType.Manual)) { var manualIds = game.Media .Where(m => m.Type == MediaType.Manual) .Select(m => m.Id.ToString()); gameItem.Tasks.Add(new InstallTaskDefinition { Type = InstallTaskType.DownloadManual, Title = "Download manuals", Order = taskOrder++, TargetId = game.Id, TargetName = game.Title, IsCritical = false, Parameters = new Dictionary { ["ManualIds"] = string.Join(",", manualIds), }, }); } plan.Items.Add(gameItem); // Addon items if (addonIds != null) { foreach (var addonId in addonIds) { var addon = await GetAsync(addonId); var addonItem = new InstallPlanItem { EntityId = addon.Id, Title = addon.Title, Type = InstallPlanItemType.Addon, InstallDirectory = destination, Order = plan.Items.Count, DependsOnId = game.Id, }; int addonTaskOrder = 0; addonItem.Tasks.Add(new InstallTaskDefinition { Type = InstallTaskType.DownloadAndExtract, Title = $"Download {addon.Title}", Order = addonTaskOrder++, TargetId = addon.Id, TargetName = addon.Title, IsCritical = true, ReportsProgress = true, }); addonItem.Tasks.Add(new InstallTaskDefinition { Type = InstallTaskType.WriteManifest, Title = "Write manifest", Order = addonTaskOrder++, TargetId = addon.Id, TargetName = addon.Title, IsCritical = true, }); addonItem.Tasks.Add(new InstallTaskDefinition { Type = InstallTaskType.WriteScripts, Title = "Save scripts", Order = addonTaskOrder++, TargetId = addon.Id, TargetName = addon.Title, IsCritical = false, }); if (addon.Scripts != null && addon.Scripts.Any()) { addonItem.Tasks.Add(new InstallTaskDefinition { Type = InstallTaskType.RunInstallScript, Title = "Run install script", Order = addonTaskOrder++, TargetId = addon.Id, TargetName = addon.Title, IsCritical = false, }); addonItem.Tasks.Add(new InstallTaskDefinition { Type = InstallTaskType.RunKeyChangeScript, Title = "Apply key", Order = addonTaskOrder++, TargetId = addon.Id, TargetName = addon.Title, IsCritical = false, }); addonItem.Tasks.Add(new InstallTaskDefinition { Type = InstallTaskType.RunNameChangeScript, Title = "Apply player name", Order = addonTaskOrder++, TargetId = addon.Id, TargetName = addon.Title, IsCritical = false, }); } plan.Items.Add(addonItem); } } // Tool items var toolIdSet = new HashSet(toolIds ?? Array.Empty()); // Always-install tools are installed alongside the game regardless of user selection try { var gameTools = await GetToolsAsync(game.Id); if (gameTools != null) { foreach (var alwaysInstallTool in gameTools.Where(t => t.AlwaysInstall)) toolIdSet.Add(alwaysInstallTool.Id); } } catch (Exception ex) { logger?.LogWarning(ex, "[InstallQueue] GenerateInstallPlan: Could not resolve always-install tools for game {GameId}", game.Id); } foreach (var toolId in toolIdSet) { var tool = await toolClient.GetAsync(toolId); var toolPlan = await toolClient.GenerateInstallPlanAsync(tool, destination); foreach (var toolPlanItem in toolPlan.Items) { toolPlanItem.Order = plan.Items.Count; toolPlanItem.DependsOnId = game.Id; plan.Items.Add(toolPlanItem); } } // Redistributable items if (game.Redistributables != null) { foreach (var redist in game.Redistributables) { var redistItem = new InstallPlanItem { EntityId = redist.Id, Title = redist.Name, Type = InstallPlanItemType.Redistributable, InstallDirectory = destination, Order = plan.Items.Count, DependsOnId = game.Id, }; redistItem.Tasks.Add(new InstallTaskDefinition { Type = InstallTaskType.DownloadAndExtract, Title = $"Download {redist.Name}", Order = 0, TargetId = redist.Id, TargetName = redist.Name, IsCritical = true, ReportsProgress = true, Parameters = new Dictionary { ["ParentGameId"] = game.Id.ToString(), }, }); redistItem.Tasks.Add(new InstallTaskDefinition { Type = InstallTaskType.RunRedistributableInstallScript, Title = $"Install {redist.Name}", Order = 1, TargetId = redist.Id, TargetName = redist.Name, IsCritical = false, Parameters = new Dictionary { ["ParentGameId"] = game.Id.ToString(), }, }); plan.Items.Add(redistItem); } } return plan; } /// /// Executes a single install plan item's tasks in order, firing OnTaskProgress events for each. /// public async Task ExecuteInstallPlanItemAsync(InstallPlanItem planItem, CancellationToken cancellationToken = default) { var installResult = new InstallResult(planItem.InstallDirectory, planItem.EntityId); switch (planItem.Type) { case InstallPlanItemType.Game: case InstallPlanItemType.Addon: await ExecuteGamePlanItemAsync(planItem, installResult, cancellationToken); break; case InstallPlanItemType.Redistributable: await ExecuteRedistributablePlanItemAsync(planItem, installResult, cancellationToken); break; case InstallPlanItemType.Tool: var toolResult = await toolClient.ExecuteInstallPlanItemAsync(planItem, cancellationToken); installResult.InstallDirectory = toolResult.InstallDirectory; break; } return installResult; } private async Task ExecuteGamePlanItemAsync(InstallPlanItem planItem, InstallResult installResult, CancellationToken cancellationToken) { logger?.LogInformation("[InstallQueue] ExecuteGamePlanItem: Starting for {Title} ({EntityId}), type={Type}, installDir={InstallDir}, taskCount={TaskCount}", planItem.Title, planItem.EntityId, planItem.Type, planItem.InstallDirectory, planItem.Tasks?.Count ?? 0); var game = await GetAsync(planItem.EntityId); if (game == null) { logger?.LogInformation("[InstallQueue] ExecuteGamePlanItem: ERROR - Could not fetch game {EntityId} from server", planItem.EntityId); throw new InstallException($"Could not fetch game info for {planItem.Title}"); } // Set the progress context so OnInstallProgressUpdate events carry the game reference _installProgress.Game = game; _installProgress.Title = game.Title; var gameFileList = installResult.FileList; SDK.Models.Manifest.Game manifest = null; // Files confirmed to exist locally and match FileList.txt — skip during extraction HashSet verifiedFiles = null; foreach (var taskDef in planItem.Tasks.OrderBy(t => t.Order)) { cancellationToken.ThrowIfCancellationRequested(); logger?.LogInformation("[InstallQueue] ExecuteGamePlanItem: Running task [{Order}] {Type}: {Title} (critical={IsCritical})", taskDef.Order, taskDef.Type, taskDef.Title, taskDef.IsCritical); var taskProgress = new InstallTaskProgress { QueueItemId = planItem.EntityId, TaskId = taskDef.Id, TaskType = taskDef.Type, TaskTitle = taskDef.Title, TaskStatus = InstallTaskStatus.Running, }; OnTaskProgress?.Invoke(taskProgress); try { switch (taskDef.Type) { case InstallTaskType.VerifyFiles: verifiedFiles = await VerifyLocalFilesAsync(planItem.InstallDirectory, game.Id, cancellationToken); logger?.LogInformation("[InstallQueue] VerifyFiles: {Count} files verified as present", verifiedFiles?.Count ?? 0); break; case InstallTaskType.DownloadAndExtract: var skipFiles = verifiedFiles; var maxAttempts = Math.Max(1, settingsProvider.CurrentValue.Games.MaxInstallAttempts); var result = await RetryHelper.RetryOnExceptionAsync(maxAttempts, TimeSpan.FromMilliseconds(500), new ExtractionResult(), async () => { return await Task.Run(async () => await DownloadAndExtractAsync(game, planItem.InstallDirectory, cancellationToken, skipFiles)); }); if (!result.Success && !result.Canceled) throw new InstallException("Could not extract the installer. Retry the install or check your connection"); if (result.Canceled) throw new InstallCanceledException("Game install was canceled"); game.InstallDirectory = result.Directory; installResult.InstallDirectory = result.Directory; planItem.InstallDirectory = result.Directory; gameFileList.BaseGame.AddFiles(result.Files? .Where(x => !x.EntryPath.EndsWith("/")) .Select(x => new GameInstallationFileListEntry.FileEntry { EntryPath = x.EntryPath, LocalPath = x.LocalPath, }) ?? []); break; case InstallTaskType.WriteManifest: manifest = await RetryHelper.RetryOnExceptionAsync(10, TimeSpan.FromSeconds(1), (SDK.Models.Manifest.Game)null, async () => { return await WriteManifestAsync(planItem.InstallDirectory, game); }); if (manifest == null) throw new InstallException("Could not grab the manifest file. Retry the install or check your connection"); gameFileList.BaseGame.Manifest = manifest; break; case InstallTaskType.WriteScripts: await WriteScriptsAsync(planItem.InstallDirectory, game); break; case InstallTaskType.DownloadSaves: await saveClient.DownloadAsync(planItem.InstallDirectory, game.Id); break; case InstallTaskType.RunInstallScript: await scriptClient.Game_RunInstallScriptAsync(planItem.InstallDirectory, game.Id); break; case InstallTaskType.RunKeyChangeScript: var allocatedKey = await GetAllocatedKeyAsync(game.Id); await scriptClient.Game_RunKeyChangeScriptAsync(planItem.InstallDirectory, game.Id, allocatedKey); break; case InstallTaskType.RunNameChangeScript: var alias = await profileClient.GetAliasAsync(); await scriptClient.Game_RunNameChangeScriptAsync(planItem.InstallDirectory, game.Id, alias); break; case InstallTaskType.DownloadManual: // Manual download handled by caller (InstallService) since it needs MediaClient break; } taskProgress.TaskStatus = InstallTaskStatus.Completed; taskProgress.Progress = 1.0f; OnTaskProgress?.Invoke(taskProgress); } catch (InstallCanceledException) { taskProgress.TaskStatus = InstallTaskStatus.Canceled; OnTaskProgress?.Invoke(taskProgress); throw; } catch (Exception ex) when (!taskDef.IsCritical) { logger?.LogError(ex, "Non-critical task {TaskTitle} failed for {GameTitle} ({GameId})", taskDef.Title, game.Title, game.Id); taskProgress.TaskStatus = InstallTaskStatus.Failed; taskProgress.ErrorMessage = ex.Message; OnTaskProgress?.Invoke(taskProgress); } } } private async Task ExecuteRedistributablePlanItemAsync(InstallPlanItem planItem, InstallResult installResult, CancellationToken cancellationToken) { // RedistributableClient.InstallAsync bundles download + install into one operation. // We fire task progress for both tasks but execute them as one call. var firstTask = planItem.Tasks.OrderBy(t => t.Order).FirstOrDefault(); if (firstTask == null) return; // Get parent game context from task parameters var parentGameId = Guid.Empty; if (firstTask.Parameters.TryGetValue("ParentGameId", out var parentGameIdStr)) Guid.TryParse(parentGameIdStr, out parentGameId); var taskProgress = new InstallTaskProgress { QueueItemId = planItem.EntityId, TaskId = firstTask.Id, TaskType = firstTask.Type, TaskTitle = firstTask.Title, TaskStatus = InstallTaskStatus.Running, }; OnTaskProgress?.Invoke(taskProgress); try { cancellationToken.ThrowIfCancellationRequested(); var game = parentGameId != Guid.Empty ? await GetAsync(parentGameId) : null; if (game != null) { game.InstallDirectory = planItem.InstallDirectory; var redist = game.Redistributables?.FirstOrDefault(r => r.Id == planItem.EntityId); if (redist != null) await redistributableClient.InstallAsync(redist, game); } // Mark all tasks as completed foreach (var taskDef in planItem.Tasks.OrderBy(t => t.Order)) { OnTaskProgress?.Invoke(new InstallTaskProgress { QueueItemId = planItem.EntityId, TaskId = taskDef.Id, TaskType = taskDef.Type, TaskTitle = taskDef.Title, TaskStatus = InstallTaskStatus.Completed, Progress = 1.0f, }); } } catch (InstallCanceledException) { taskProgress.TaskStatus = InstallTaskStatus.Canceled; OnTaskProgress?.Invoke(taskProgress); throw; } catch (Exception ex) { logger?.LogError(ex, "Redistributable {RedistName} failed to install", planItem.Title); taskProgress.TaskStatus = InstallTaskStatus.Failed; taskProgress.ErrorMessage = ex.Message; OnTaskProgress?.Invoke(taskProgress); } } public async Task UninstallAsync(string installDirectory, Guid gameId) { var installResult = new InstallResult(installDirectory, gameId); var gameFileList = installResult.FileList; var manifest = await ManifestHelper.ReadAsync(installDirectory, gameId); if (manifest == null) { logger?.LogInformation("Unable to read or find manifest for game with ID {GameId}. Skip uninstallation!", gameId); return installResult; } // store manifest for current game (could be base game, or any dependent game as this point due to recursive call) gameFileList.BaseGame.Manifest = manifest; var baseFileList = gameFileList.BaseGame; #region Uninstall Addons if (manifest.Addons != null) { foreach (var addon in manifest.Addons) { try { if (ManifestHelper.Exists(installDirectory, addon.Id)) { var dependentResult = await UninstallAsync(installDirectory, addon.Id); gameFileList.MergeDependentGames(dependentResult.FileList); } } catch (Exception ex) { logger?.LogWarning("Could not uninstall dependent game with ID {GameId}. Assuming it's already uninstalled or never installed...", gameId); } } } #endregion #region Delete Redistributable Files if (manifest.Redistributables != null) { foreach (var redistributable in manifest.Redistributables) { try { await scriptClient.Redistributable_RunUninstallScriptAsync(installDirectory, gameId, redistributable.Id); var redistFileListPath = GetMetadataFilePath(installDirectory, redistributable.Id, "FileList.txt"); if (File.Exists(redistFileListPath)) { var redistFiles = await File.ReadAllLinesAsync(redistFileListPath); foreach (var file in redistFiles.Where(f => !string.IsNullOrWhiteSpace(f))) { var localPath = Path.Combine(installDirectory, file); try { if (File.Exists(localPath)) File.Delete(localPath); logger?.LogTrace("Deleted redistributable file {LocalPath}", localPath); } catch (Exception ex) { logger?.LogWarning(ex, "Could not remove redistributable file {LocalPath}", localPath); } } } var redistMetadataPath = GetMetadataDirectoryPath(installDirectory, redistributable.Id); if (Directory.Exists(redistMetadataPath)) Directory.Delete(redistMetadataPath, true); } catch (Exception ex) { logger?.LogWarning(ex, "Could not clean up redistributable {RedistributableId}", redistributable.Id); } } } #endregion #region Delete Tool Files if (manifest.Tools != null) { foreach (var tool in manifest.Tools) { try { if (ManifestHelper.Exists(installDirectory, tool.Id)) await toolClient.UninstallAsync(installDirectory, tool.Id); } catch (Exception ex) { logger?.LogWarning(ex, "Could not clean up tool {ToolId}", tool.Id); } } } #endregion #region Delete Files var fileListPath = GetMetadataFilePath(installDirectory, gameId, "FileList.txt"); if (File.Exists(fileListPath)) { var fileList = await File.ReadAllLinesAsync(fileListPath); var files = fileList.Select(l => l.Split('|').FirstOrDefault()?.Trim()); logger?.LogDebug("Attempting to delete the install files"); foreach (var file in files.Where(f => f != null && !f.EndsWith("/"))) { var localPath = Path.Combine(installDirectory, file); baseFileList.AddFile(new GameInstallationFileListEntry.FileEntry { EntryPath = file, LocalPath = localPath, }); try { if (File.Exists(localPath)) File.Delete(localPath); logger?.LogTrace("Deleted file {LocalPath}", localPath); } catch (Exception ex) { logger?.LogWarning(ex, "Could not remove file {LocalPath}", localPath); } } logger?.LogDebug("Attempting to delete any empty directories"); DirectoryHelper.DeleteEmptyDirectories(installDirectory); if (!Directory.Exists(installDirectory)) logger?.LogDebug("Deleted install directory {InstallDirectory}", installDirectory); else logger?.LogTrace("Removed game files for {GameTitle} ({GameId})", manifest.Title, gameId); } else { Directory.Delete(installDirectory, true); } #endregion await scriptClient.Game_RunUninstallScriptAsync(installDirectory, gameId); #region Cleanup Install Directory var metadataPath = GetMetadataDirectoryPath(installDirectory, gameId); if (Directory.Exists(metadataPath)) Directory.Delete(metadataPath, true); DirectoryHelper.DeleteEmptyDirectories(installDirectory); #endregion return installResult; } public async Task UninstallAddonsAsync(string installDirectory, Guid baseGameId, IEnumerable addonIds) { var installResult = new InstallResult(installDirectory, baseGameId); var gameFileList = installResult.FileList; var baseManifest = await ManifestHelper.ReadAsync(installDirectory, baseGameId); if (baseManifest == null) { logger?.LogInformation("Unable to read or find manifest for addon game with ID {GameId}. Skip uninstallation!", baseGameId); return installResult; } // store manifest for current addon game, skip any files gameFileList.BaseGame.Manifest = baseManifest; gameFileList.InstallDirectory = installDirectory; addonIds ??= []; foreach (var addon in baseManifest.Addons) { if (!addonIds.Contains(addon.Id)) continue; try { var dependentResult = await UninstallAddonAsync(installDirectory, addon.Id); gameFileList.MergeBaseAsDependentGame(addon.Id, dependentResult.FileList); } catch (Exception ex) { logger?.LogWarning(ex, $"Could not uninstall dependent game {addon} of base game {baseGameId}. Assuming it's already uninstalled or never installed..."); } } return installResult; } public async Task UninstallAddonAsync(string installDirectory, Guid addonGameId) { var installResult = new InstallResult(installDirectory, addonGameId); var gameFileList = installResult.FileList; var manifest = await ManifestHelper.ReadAsync(installDirectory, addonGameId); if (manifest != null) { var dependentResult = await UninstallAsync(installDirectory, manifest.Id); gameFileList.BaseGame.Manifest = manifest; gameFileList.Merge(dependentResult.FileList); } return installResult; } public async Task MoveAsync(Guid gameId, string oldInstallDirectory, string newInstallDirectory) { var game = await GetAsync(gameId); return await MoveAsync(game, oldInstallDirectory, newInstallDirectory); } public async Task MoveAsync(Game game, string oldInstallDirectory, string newInstallDirectory) { var gameAndAddons = new List(); _installProgress.Game = game; _installProgress.Status = InstallStatus.EnumeratingFiles; _installProgress.Indeterminate = true; _installProgress.Progress = 0; OnInstallProgressUpdate?.Invoke(_installProgress); gameAndAddons.Add(game); foreach (var dependentGameId in game.DependentGames) { var dependentGame = await GetAsync(dependentGameId); if (dependentGame.IsAddon) gameAndAddons.Add(dependentGame); } foreach (var entry in gameAndAddons) { if (await IsInstalled(oldInstallDirectory, game, entry.Id)) await saveClient.UploadAsync(oldInstallDirectory, entry.Id); } if (Directory.Exists(newInstallDirectory)) { // Trigger notification eventually _installProgress.Status = InstallStatus.Failed; OnInstallProgressUpdate?.Invoke(_installProgress); return newInstallDirectory; } var directories = Directory.GetDirectories(oldInstallDirectory, "*", SearchOption.AllDirectories); var files = Directory.GetFiles(oldInstallDirectory, "*.*", SearchOption.AllDirectories); var fileInfos = files.Select(f => new FileInfo(f)); var totalSize = fileInfos.Sum(fi => fi.Length); long totalPos = 0; _installProgress.Status = InstallStatus.Moving; _installProgress.Indeterminate = false; _installProgress.BytesTransferred = totalPos; _installProgress.TotalBytes = totalSize; foreach (var directory in directories) { Directory.CreateDirectory(directory.Replace(oldInstallDirectory, newInstallDirectory)); } using (var fileTransferMonitor = new FileTransferMonitor(totalSize)) { foreach (var fileInfo in fileInfos) { using (FileStream sourceStream = File.Open(fileInfo.FullName, FileMode.Open)) using (FileStream destinationStream = File.Create(fileInfo.FullName.Replace(oldInstallDirectory, newInstallDirectory))) { _installProgress.TotalBytes = totalSize; var buffer = new byte[81920]; int bytesRead; while ((bytesRead = await sourceStream.ReadAsync(buffer, 0, buffer.Length)) > 0) { await destinationStream.WriteAsync(buffer, 0, bytesRead); totalPos += bytesRead; if (fileTransferMonitor.CanUpdate()) { fileTransferMonitor.Update(totalPos); _installProgress.TimeRemaining = fileTransferMonitor.GetTimeRemaining(); _installProgress.BytesTransferred = fileTransferMonitor.GetBytesTransferred(); _installProgress.TransferSpeed = fileTransferMonitor.GetSpeed(); OnInstallProgressUpdate?.Invoke(_installProgress); } } } } } _installProgress.BytesTransferred = totalSize; _installProgress.Progress = 1; _installProgress.Status = InstallStatus.RunningScripts; OnInstallProgressUpdate?.Invoke(_installProgress); Directory.Delete(oldInstallDirectory, true); foreach (var entry in gameAndAddons) { if (await IsInstalled(newInstallDirectory, game, entry.Id)) { await RunPostInstallScripts(entry); await saveClient.DownloadAsync(newInstallDirectory, entry.Id); } } _installProgress.Status = InstallStatus.Complete; OnInstallProgressUpdate?.Invoke(_installProgress); return newInstallDirectory; } public async Task IsInstalled(string installDirectory, Game game, Guid? addonId = null) { installDirectory = await GetInstallDirectory(game, installDirectory); var metadataPath = ManifestHelper.GetPath(installDirectory, addonId ?? game.Id); return File.Exists(metadataPath); } public async Task UpdateGameInstallationAsync(string installDirectory, Game game) { // update game and scripts locally await WriteManifestAsync(installDirectory, game); await WriteScriptsAsync(installDirectory, game); } /// /// Refreshes the on-disk manifest and scripts for an installed game by fetching the latest /// versions from the server and writing them to the game's install directory. /// public async Task RefreshManifestAndScriptsAsync(string installDirectory, Guid gameId) { logger?.LogTrace("Refreshing manifest and scripts for game {GameId} in {InstallDirectory}", gameId, installDirectory); var manifest = await GetManifestAsync(gameId); await ManifestHelper.WriteAsync(manifest, installDirectory); var scripts = await GetScriptsAsync(gameId); if (scripts != null && scripts.Any()) { var game = new Game { Id = gameId }; foreach (var script in scripts) await ScriptHelper.SaveScriptAsync(game, script, installDirectory); } } /// /// Refreshes the on-disk manifest and scripts for an installed game using a specific version, /// writing the version-scoped manifest and its scripts to the game's install directory. Used /// when installing or rolling back to a particular version so the local config matches exactly. /// public async Task RefreshManifestAndScriptsAsync(string installDirectory, Guid gameId, Guid versionId) { logger?.LogTrace("Refreshing version {VersionId} manifest and scripts for game {GameId} in {InstallDirectory}", versionId, gameId, installDirectory); var manifest = await GetManifestAsync(gameId, versionId); await ManifestHelper.WriteAsync(manifest, installDirectory); var scripts = await GetScriptsAsync(gameId, versionId); if (scripts != null && scripts.Any()) { var game = new Game { Id = gameId }; foreach (var script in scripts) await ScriptHelper.SaveScriptAsync(game, script, installDirectory); } } private async Task WriteManifestAsync(string installDirectory, Game game) { logger?.LogTrace($"Retrieving game manifest for game {game.Title} with id {game.Id}"); var manifest = await GetManifestAsync(game.Id); logger?.LogTrace($"Saving Manifest for game {game.Id} into {installDirectory}"); await ManifestHelper.WriteAsync(manifest, installDirectory); return manifest; } private async Task WriteScriptsAsync(string installDirectory, Game game) { var scripts = await GetScriptsAsync(game.Id); if (scripts != null && scripts.Any()) { logger?.LogTrace($"Saving scripts for game {game.Title} with id {game.Id} into {installDirectory}"); foreach (var script in scripts) await ScriptHelper.SaveScriptAsync(game, script, installDirectory); } } private async Task RunPostInstallScripts(Game game) { if (game.Scripts != null && game.Scripts.Any()) { _installProgress.Status = InstallStatus.RunningScripts; OnInstallProgressUpdate?.Invoke(_installProgress); try { var allocatedKey = await GetAllocatedKeyAsync(game.Id); await scriptClient.Game_RunInstallScriptAsync(game.InstallDirectory, game.Id); await scriptClient.Game_RunKeyChangeScriptAsync(game.InstallDirectory, game.Id, allocatedKey); await scriptClient.Game_RunNameChangeScriptAsync(game.InstallDirectory, game.Id, await profileClient.GetAliasAsync()); } catch (Exception ex) { logger?.LogError(ex, "Scripts failed to execute for game/addon {GameTitle} ({GameId})", game.Title, game.Id); } } } /// /// Reads the existing FileList.txt and checks which files are present on disk. /// Returns a set of entry paths (relative) that exist locally and can be skipped during extraction. /// private async Task> VerifyLocalFilesAsync(string installDirectory, Guid gameId, CancellationToken cancellationToken) { var verified = new HashSet(StringComparer.OrdinalIgnoreCase); var fileListPath = GetMetadataFilePath(installDirectory, gameId, "FileList.txt"); if (!File.Exists(fileListPath)) return verified; var lines = await File.ReadAllLinesAsync(fileListPath, cancellationToken); foreach (var line in lines) { if (string.IsNullOrWhiteSpace(line)) continue; // Format: "path/to/file | CRC32HEX" var separatorIndex = line.IndexOf('|'); var entryPath = separatorIndex >= 0 ? line.Substring(0, separatorIndex).Trim() : line.Trim(); if (string.IsNullOrEmpty(entryPath) || entryPath.EndsWith("/")) continue; var localPath = Path.Combine(installDirectory, entryPath); if (File.Exists(localPath)) verified.Add(entryPath); } return verified; } private async Task DownloadAndExtractAsync(Game game, string destination, CancellationToken cancellationToken = default, HashSet skipFiles = null) { if (game == null) { logger?.LogTrace("Game failed to download, no game was specified"); throw new ArgumentNullException("No game was specified"); } logger?.LogTrace("Downloading and extracting {Game} to path {Destination}", game.Title, destination); var extractionResult = new ExtractionResult { Canceled = false, }; if (!await CanStreamLatestArchiveAsync(game.Id)) { extractionResult.Success = false; extractionResult.Canceled = true; return extractionResult; } var fileManifest = new StringBuilder(); var files = new List(); // Tracked outside the try so the catch blocks can report exactly where extraction failed TrackableStream stream = null; string currentEntryKey = null; var entriesProcessed = 0; try { Directory.CreateDirectory(destination); stream = await StreamLatestArchiveAsync(game.Id); var monitor = new FileTransferMonitor(stream.Length); var progress = new Progress(report => { if (cancellationToken.IsCancellationRequested) { _reader?.Cancel(); _installProgress.Status = InstallStatus.Canceled; OnInstallProgressUpdate?.Invoke(_installProgress); return; } if (monitor.CanUpdate()) { monitor.Update(stream.Position); _installProgress.BytesTransferred = monitor.GetBytesTransferred(); _installProgress.TotalBytes = stream.Length; _installProgress.TransferSpeed = monitor.GetSpeed(); _installProgress.TimeRemaining = monitor.GetTimeRemaining(); OnInstallProgressUpdate?.Invoke(_installProgress); } OnArchiveEntryExtractionProgress?.Invoke(this, new ArchiveEntryExtractionProgressArgs { Progress = report, Game = game, }); }); _reader = await ReaderFactory.OpenAsyncReader(stream, new ReaderOptions { Progress = progress }, cancellationToken); _installProgress.Status = InstallStatus.Downloading; OnInstallProgressUpdate?.Invoke(_installProgress); while (await _reader.MoveToNextEntryAsync(cancellationToken)) { if (_reader.Cancelled) break; try { var entryKey = _reader.Entry.Key; currentEntryKey = entryKey; var localFile = Path.Combine(destination, entryKey); fileManifest.AppendLine($"{entryKey} | {_reader.Entry.Crc.ToString("X")}"); files.Add(new ExtractionResult.FileEntry { EntryPath = entryKey, LocalPath = localFile, }); // If pre-flight verification confirmed this file exists locally, skip it bool shouldSkip = skipFiles != null && skipFiles.Contains(entryKey); if (!shouldSkip) await _reader.WriteEntryToDirectoryAsync(destination, new ExtractionOptions() { ExtractFullPath = true, Overwrite = true, PreserveFileTime = true }, cancellationToken); else // Skip to next entry try { await using var es = await _reader.OpenEntryStreamAsync(cancellationToken); } catch { logger?.LogError("Could not skip to next entry in archive: {EntryKey}", entryKey); } entriesProcessed++; } catch (IOException ex) { var errorCode = ex.HResult & 0xFFFF; if (errorCode == 87) { logger?.LogError(ex, "Fatal IO error (HResult 0x{HResult:X8}, Win32 {ErrorCode}) writing entry {EntryKey} for game {GameTitle} ({GameId}) after {EntriesProcessed} entries at {Position}/{Length} bytes", ex.HResult, errorCode, currentEntryKey, game.Title, game.Id, entriesProcessed, stream?.Position, stream?.Length); throw ex; } logger?.LogTrace("Not replacing existing file/folder on disk: {EntryKey} (HResult 0x{HResult:X8}) - {Message}", currentEntryKey, ex.HResult, ex.Message); // Skip to next entry await using var es = await _reader.OpenEntryStreamAsync(cancellationToken); } } await _reader.DisposeAsync(); await stream.DisposeAsync(); // _transferStream.Dispose(); } catch (ReaderCancelledException ex) { logger?.LogTrace(ex, "User cancelled the download"); extractionResult.Canceled = true; if (Directory.Exists(destination)) { logger?.LogTrace("Cleaning up orphaned files after cancelled install"); Directory.Delete(destination, true); } } catch (Exception ex) { logger?.LogError(ex, "Could not extract game {GameTitle} ({GameId}) to {Destination}. Failed on entry {EntryKey} (entry #{EntriesProcessed}) at {Position}/{Length} bytes with {ExceptionType} (HResult 0x{HResult:X8})", game.Title, game.Id, destination, currentEntryKey, entriesProcessed, stream?.Position, stream?.Length, ex.GetType().Name, ex.HResult); if (Directory.Exists(destination)) { logger?.LogTrace("Cleaning up orphaned install files after bad install"); Directory.Delete(destination, true); } throw new Exception("The game archive could not be extracted, is it corrupted? Please try again"); } if (!extractionResult.Canceled) { extractionResult.Success = true; extractionResult.Directory = destination; extractionResult.Files = files; var fileListDestination = Path.Combine(destination, ".lancommander", game.Id.ToString(), "FileList.txt"); if (!Directory.Exists(Path.GetDirectoryName(fileListDestination))) Directory.CreateDirectory(Path.GetDirectoryName(fileListDestination)); File.WriteAllText(fileListDestination, fileManifest.ToString()); logger?.LogTrace("Game {Game} successfully downloaded and extracted to {Destination}", game.Title, destination); } return extractionResult; } public async Task GetInstallDirectory(Game game, string installDirectory) { if (string.IsNullOrWhiteSpace(installDirectory)) installDirectory = settingsProvider.CurrentValue.Games.InstallDirectories.First(); if ((game.Type == GameType.Expansion || game.Type == GameType.Mod || game.Type == GameType.StandaloneMod) && game.BaseGameId != Guid.Empty) { // modify installation passes the original installation of the game including the game folder, use the existing folder, // otherwise a name change could lead to installing files into differnt folder if (Path.Exists(installDirectory) && Path.Exists(Path.Combine(installDirectory, ".lancommander"))) { return installDirectory; } else { var baseGame = await GetAsync(game.BaseGameId); return await GetInstallDirectory(baseGame, installDirectory); } } else return Path.Combine(installDirectory, game.Title.SanitizeFilename()); } public void CancelInstall() { _reader?.Cancel(); } public async Task> ReadManifestsAsync(string installDirectory, Guid gameId) { var manifests = new List(); var mainManifest = await ManifestHelper.ReadAsync(installDirectory, gameId); if (mainManifest == null) return manifests; manifests.Add(mainManifest); if (mainManifest.Addons != null) { foreach (var addon in mainManifest.Addons) { try { var dependentGameManifest = await ManifestHelper.ReadAsync(installDirectory, addon.Id); if (dependentGameManifest.Type == GameType.Expansion || dependentGameManifest.Type == GameType.Mod) manifests.Add(dependentGameManifest); } catch (Exception ex) { logger?.LogError(ex, "Could not load manifest from dependent game {AddonId}", addon.Id); } } } return manifests; } /// /// Retrieves the archive entries of the current game installation from the server for the specified game /// /// The unique identifier of the game. /// The manifest containing metadata of the game's installation. /// /// A collection of representing the archive entries. /// Returns an empty list if no entries are found. /// /// /// Thrown if the request to retrieve archive entries encounters an error. /// protected async Task> GetGameInstallationArchiveEntries(Guid gameId, Models.Manifest.Game manifest) { var entries = await apiRequestFactory .Create() .UseAuthenticationToken() .UseVersioning() .UseRoute($"/api/Archives/Contents/{manifest.Id}/{manifest.Version}") .GetAsync>(); return entries ?? []; } /// /// Retrieves the archive entries for a game installation, including its base game and dependencies. /// /// The directory where the game is installed. /// The unique identifier of the game. /// /// An instance of containing archive entries /// for the base game and any dependent games. /// protected async Task GetGameInstallationArchivesEntries(string installDirectory, Guid gameId) { var gameArchives = new GameInstallationArchiveEntries(); var manifests = await GetManifestsAsync(installDirectory, gameId); if (manifests == null || !manifests.Any()) return gameArchives; // Retrieves and processes the base game manifest and its archive entries. var baseManifest = gameArchives.BaseGame.Manifest = manifests.FirstOrDefault(mf => mf.Type.ValueIsIn(GameType.MainGame, GameType.StandaloneExpansion, GameType.StandaloneMod)); if (baseManifest != null) { var entries = await GetGameInstallationArchiveEntries(gameId, baseManifest); gameArchives.BaseGame.Entries.AddRange(entries); manifests = manifests.Except([baseManifest]).ToList(); var savePathEntries = baseManifest.SavePaths?.SelectMany(p => saveClient.GetFileSavePathEntries(p, installDirectory)).ToList() ?? []; gameArchives.BaseGame.SavePaths = savePathEntries; } // Processes dependent game manifests and their corresponding archive entries. foreach (var depManifest in manifests ?? []) { var depEntries = await GetGameInstallationArchiveEntries(gameId, depManifest); if (!gameArchives.Addons.TryGetValue(depManifest.Id, out var depArchiveInfo)) { depArchiveInfo = new(); gameArchives.Addons.Add(depManifest.Id, depArchiveInfo); } depArchiveInfo.Manifest = depManifest; depArchiveInfo.Entries.AddRange(depEntries); var savePathEntries = depManifest.SavePaths?.SelectMany(p => saveClient.GetFileSavePathEntries(p, installDirectory)).ToList() ?? []; depArchiveInfo.SavePaths = savePathEntries; } return gameArchives; } public async Task RunAsync(string installDirectory, Guid gameId, Models.Manifest.Action action, DateTime? lastRun, string args = "") { var screen = DisplayHelper.GetScreen(); using (var context = processExecutionContextFactory.Create()) { context.AddVariable("ServerAddress", connectionClient.GetServerAddress().ToString()); try { context.AddVariable("DisplayWidth", screen.Width.ToString()); context.AddVariable("DisplayHeight", screen.Height.ToString()); context.AddVariable("DisplayRefreshRate", screen.RefreshRate.ToString()); context.AddVariable("DisplayBitDepth", screen.BitsPerPixel.ToString()); } catch (Exception ex) { logger?.LogError(ex, "Could not get display information for execution context variables"); } try { if (connectionClient.IsConnected() && !String.IsNullOrWhiteSpace(settingsProvider.CurrentValue.IPXRelay.Host)) { context.AddVariable("IPXRelayHost", settingsProvider.CurrentValue.IPXRelay.Host); context.AddVariable("IPXRelayPort", settingsProvider.CurrentValue.IPXRelay.Port.ToString()); } } catch (Exception ex) { logger?.LogError(ex, "Could not connect to IPXRelay host"); } if (action.Variables != null) { foreach (var variable in action.Variables) context.AddVariable(variable.Key, variable.Value); } // When an action references {ServerHost} but the game server didn't specify a host, // fall back to the host of the LANCommander server the launcher is connected to. if (action.Variables == null || !action.Variables.TryGetValue("ServerHost", out var serverHost) || String.IsNullOrWhiteSpace(serverHost)) { var serverAddress = connectionClient.GetServerAddress(); if (serverAddress != null) context.AddVariable("ServerHost", serverAddress.Host); } #region Run Scripts var manifests = await ReadManifestsAsync(installDirectory, gameId); foreach (var manifest in manifests) { //manifest.Actions var currentGamePlayerAlias = await GetPlayerAliasAsync(installDirectory, manifest.Id); var currentGameKey = await GetCurrentKeyAsync(installDirectory, manifest.Id); #region Check Game's Player Name if (connectionClient.IsConnected()) { var alias = await profileClient.GetAliasAsync(); if (currentGamePlayerAlias != alias) { await scriptClient.Game_RunNameChangeScriptAsync(installDirectory, gameId, alias); if (manifest.Redistributables != null) { foreach (var redistributable in manifest.Redistributables.Where(r => r.Scripts != null)) { await scriptClient.Redistributable_RunNameChangeScriptAsync(installDirectory, gameId, redistributable.Id, alias); } } } } #endregion #region Check Key Allocation if (connectionClient.IsConnected()) { var newKey = await GetAllocatedKeyAsync(manifest.Id); if (currentGameKey != newKey) await scriptClient.Game_RunKeyChangeScriptAsync(installDirectory, manifest.Id, newKey); } #endregion #region Download Latest Saves if (connectionClient.IsConnected()) { await RetryHelper.RetryOnExceptionAsync(10, TimeSpan.FromSeconds(1), false, async () => { logger?.LogTrace("Checking for latest save for game {GameId}", manifest.Id); try { var latestSave = await saveClient.GetLatestAsync(manifest.Id); if (latestSave == null) { logger?.LogDebug("No saves found on server for game {GameId}", manifest.Id); } else if (lastRun == null) { logger?.LogInformation("Downloading save for game {GameId} (first run, save date: {SaveDate})", manifest.Id, latestSave.CreatedOn); await saveClient.DownloadAsync(installDirectory, manifest.Id); } else if (latestSave.CreatedOn > lastRun) { logger?.LogInformation("Downloading newer save for game {GameId} (save date: {SaveDate}, last run: {LastRun})", manifest.Id, latestSave.CreatedOn, lastRun); await saveClient.DownloadAsync(installDirectory, manifest.Id); } else { logger?.LogDebug("Save for game {GameId} is up to date (save date: {SaveDate}, last run: {LastRun})", manifest.Id, latestSave.CreatedOn, lastRun); } } catch (HttpRequestException ex) { if (ex.StatusCode == HttpStatusCode.NotFound) { logger?.LogDebug("No saves found on server for game {GameId} (404)", manifest.Id); return true; } throw; } return true; }); } else { logger?.LogDebug("Skipping save download for game {GameId}, not connected to server", manifest.Id); } #endregion #region Run Before Start Script await scriptClient.Game_RunBeforeStartScriptAsync(installDirectory, manifest.Id); if (manifest.Redistributables != null) { foreach (var redistributable in manifest.Redistributables.Where(r => r.Scripts != null)) { await scriptClient.Redistributable_RunBeforeStartScriptAsync(installDirectory, gameId, redistributable.Id); } } #endregion } #endregion await pluginEventBus.PublishAsync(new GameBeforeLaunchEvent(gameId, installDirectory, action?.Name)); Task heartbeatTask = null; try { var cancellationTokenSource = new CancellationTokenSource(); _running[gameId] = cancellationTokenSource; heartbeatTask = SendKeepAlivesAsync(gameId, cancellationTokenSource.Token); #region Run Wrapper Scripts bool runWrapperHandled = false; var gameManifest = await ManifestHelper.ReadAsync(installDirectory, gameId); var resolvedAction = action ?? gameManifest.Actions.FirstOrDefault(a => a.IsPrimaryAction); if (resolvedAction != null && gameManifest.Redistributables != null) { var wrapperRedistributables = gameManifest.Redistributables .Where(r => r.Scripts != null && r.Scripts.Any(s => s.Type == Enums.ScriptType.RunWrapper)) .ToList(); if (wrapperRedistributables.Any()) { if (gameManifest.CustomFields != null && gameManifest.CustomFields.Any()) { foreach (var customField in gameManifest.CustomFields) { context.AddVariable(customField.Name, customField.Value); } } var executablePath = context.ExpandVariables(resolvedAction.Path, installDirectory); var arguments = context.ExpandVariables(resolvedAction.Arguments, installDirectory, skipSlashes: true); var workingDirectory = context.ExpandVariables(resolvedAction.WorkingDirectory, installDirectory); if (string.IsNullOrWhiteSpace(workingDirectory)) workingDirectory = installDirectory; if (!string.IsNullOrWhiteSpace(args)) arguments = string.IsNullOrWhiteSpace(arguments) ? args : arguments + " " + args; foreach (var redistributable in wrapperRedistributables) { runWrapperHandled = await scriptClient.Redistributable_RunRunWrapperScriptAsync(installDirectory, gameId, redistributable.Id, executablePath, arguments, workingDirectory, cancellationTokenSource.Token); if (runWrapperHandled) break; } } } #endregion if (!runWrapperHandled) await context.ExecuteGameActionAsync(installDirectory, gameId, action, args, cancellationTokenSource.Token); _running.Remove(gameId); await StopHeartbeatAsync(cancellationTokenSource, heartbeatTask); cancellationTokenSource.Dispose(); await UploadSavesAsync(manifests, installDirectory); } catch (Exception ex) { if (_running.TryGetValue(gameId, out var cts)) { _running.Remove(gameId); await StopHeartbeatAsync(cts, heartbeatTask); cts.Dispose(); } logger?.LogError(ex, "Game failed to run"); throw; } foreach (var manifest in manifests) { #region Run After Stop Script await scriptClient.Game_RunAfterStopScriptAsync(installDirectory, gameId); if (manifest.Redistributables != null) { foreach (var redistributable in manifest.Redistributables.Where(r => r.Scripts != null)) { await scriptClient.Redistributable_RunAfterStopScriptAsync(installDirectory, gameId, redistributable.Id); } } #endregion } await pluginEventBus.PublishAsync(new GameAfterExitEvent(gameId, installDirectory)); } } private async Task UploadSavesAsync(ICollection manifests, string installDirectory) { if (connectionClient.IsConnected()) { foreach (var manifest in manifests) { await RetryHelper.RetryOnExceptionAsync(10, TimeSpan.FromSeconds(1), false, async () => { logger?.LogDebug("Uploading save for game {GameId}", manifest.Id); try { await saveClient.UploadAsync(installDirectory, manifest.Id); } catch (Exception ex) { logger?.LogError(ex, "Save upload attempt failed for game {GameId}", manifest.Id); throw; } logger?.LogInformation("Save uploaded successfully for game {GameId}", manifest.Id); return true; }); } } else { logger?.LogDebug("Skipping save upload, not connected to server"); } } // Heartbeat interval while a game is running. Must stay well below the server's // KeepAliveTimeout so a session isn't reaped between beats. private const int KeepAliveIntervalSeconds = 30; private async Task SendKeepAlivesAsync(Guid gameId, CancellationToken token) { try { while (!token.IsCancellationRequested) { await Task.Delay(TimeSpan.FromSeconds(KeepAliveIntervalSeconds), token); if (token.IsCancellationRequested) break; if (!connectionClient.IsConnected() || RpcClient.Hub == null) continue; try { await RpcClient.Hub.GameKeepAliveAsync(gameId); } catch (Exception ex) { logger?.LogTrace(ex, "Keepalive send failed for {GameId}", gameId); } } } catch (OperationCanceledException) { // Expected when the game exits and the token is cancelled. } } private static async Task StopHeartbeatAsync(CancellationTokenSource cancellationTokenSource, Task heartbeatTask) { cancellationTokenSource.Cancel(); if (heartbeatTask != null) { try { await heartbeatTask; } catch (OperationCanceledException) { } } } public async Task Stop(Guid gameId) { if (_running.ContainsKey(gameId)) { await _running[gameId].CancelAsync(); _running.Remove(gameId); } } public bool IsRunning(Guid gameId) { if (!_running.ContainsKey(gameId)) return false; return !_running[gameId].IsCancellationRequested; } public async Task ImportAsync(string archivePath) { using (var fs = new FileStream(archivePath, FileMode.Open, FileAccess.Read)) { var objectKey = await apiRequestFactory .Create() .UseAuthenticationToken() .UseVersioning() .UploadInChunksAsync(settingsProvider.CurrentValue.Archives.UploadChunkSize, fs); if (objectKey != Guid.Empty) await apiRequestFactory .Create() .UseAuthenticationToken() .UseVersioning() .UseRoute($"/api/Games/Import/{objectKey}") .PostAsync(); } } [Obsolete("Servers no longer do \"Full\" exports")] public async Task ExportAsync(string destinationPath, Guid gameId) { await apiRequestFactory .Create() .UseAuthenticationToken() .UseVersioning() .UseRoute($"/api/Games/Export/Full") .DownloadAsync(destinationPath); } public async Task UploadArchiveAsync(string archivePath, Guid gameId, string version, string changelog = "") { using (var fs = new FileStream(archivePath, FileMode.Open, FileAccess.Read)) { var objectKey = await apiRequestFactory .Create() .UseAuthenticationToken() .UseVersioning() .UploadInChunksAsync(settingsProvider.CurrentValue.Archives.UploadChunkSize, fs); if (objectKey != Guid.Empty) await apiRequestFactory .Create() .UseAuthenticationToken() .UseVersioning() .UseRoute("/api/Games/UploadArchive") .AddBody(new UploadArchiveRequest { Id = gameId, ObjectKey = objectKey, Version = version, Changelog = changelog }) .PostAsync(); } } /// /// Get the archive associated with the installed version of the game and return any non-matching files in the current install. /// /// The game's install directory /// The game's ID /// List of file conflicts public async Task> ValidateFilesAsync(string installDirectory, Guid gameId) { var archives = await GetGameInstallationArchivesEntries(installDirectory, gameId); var entries = archives?.BaseGame?.Entries?.ToList() ?? []; foreach ((var dependentGameId, var dependentGameInfo) in archives?.Addons ?? []) { foreach (var depArchive in dependentGameInfo.Entries ?? []) { if (depArchive.FullName.EndsWith('/')) continue; var archiveIndex = entries.FindLastIndex(archive => string.Equals(archive.FullName, depArchive.FullName)); if (archiveIndex < 0) { entries.Add(depArchive); continue; } entries[archiveIndex] = depArchive; } } // lookup for dependent games var lookupEntry = archives?.Addons? .SelectMany(dep => dep.Value?.Entries?.Select(entry => new { GameId = (Guid?)dep.Key, ArchiveEntry = entry }) ?? []) .ToLookup(tentry => tentry.ArchiveEntry, tentry => tentry.GameId) ?? Enumerable.Empty().ToLookup(x => default(ArchiveEntry)); var conflictedEntries = new List(); var savePathEntries = archives?.BaseGame?.SavePaths.ToList() ?? []; var depSavePathEntries = archives?.Addons?.SelectMany(dep => dep.Value?.SavePaths ?? []).ToList() ?? []; savePathEntries.AddRange(depSavePathEntries); foreach (var entry in entries) { if (savePathEntries.Any(e => e.ArchivePath.Equals(entry.FullName, StringComparison.OrdinalIgnoreCase))) continue; if (entry.FullName.EndsWith('/')) continue; var localFile = Path.Combine(installDirectory, entry.FullName.Replace('/', Path.DirectorySeparatorChar)); if (!Path.Exists(localFile)) conflictedEntries.Add(new ArchiveValidationConflict { GameId = lookupEntry[entry]?.FirstOrDefault() ?? gameId, Name = entry.Name, FullName = entry.FullName, Crc32 = entry.Crc32, Length = entry.Length, }); else { uint crc = 0; if (File.Exists(localFile)) { using (FileStream fs = File.Open(localFile, FileMode.Open)) { var buffer = new byte[65536]; while (true) { var count = fs.Read(buffer, 0, buffer.Length); if (count == 0) break; crc = Crc32Algorithm.Append(crc, buffer, 0, count); } } } if (crc == 0 || crc != entry.Crc32) conflictedEntries.Add(new ArchiveValidationConflict { GameId = lookupEntry[entry]?.FirstOrDefault() ?? gameId, Name = entry.Name, FullName = entry.FullName, Crc32 = entry.Crc32, LocalFileInfo = new FileInfo(localFile) }); } } return conflictedEntries; } /// /// Downloads the specified files for multiple games (base game, mods, expansions). /// /// The directory where the games are installed. /// /// A collection of tuples containing the game ID and the corresponding file path. /// public async Task DownloadFilesAsync(string installDirectory, IEnumerable<(Guid GameId, string FilePath)> entries) { var groups = entries.GroupBy(x => x.GameId); foreach (var group in groups) { await DownloadFilesAsync(installDirectory, group.Key, group.Select(x => x.FilePath).ToList()); } } /// /// Downloads the specified files for a single game. /// /// The directory where the game is installed. /// The unique identifier of the game. /// A collection of file paths to download. public async Task DownloadFilesAsync(string installDirectory, Guid gameId, ICollection entries, CancellationToken cancellationToken = default) { var manifest = await ManifestHelper.ReadAsync(installDirectory, gameId); try { var stream = await StreamLatestArchiveAsync(gameId); _reader = await ReaderFactory.OpenAsyncReader(stream, new ReaderOptions(), cancellationToken); while (await _reader.MoveToNextEntryAsync(cancellationToken)) { if (_reader.Cancelled) break; try { if (entries.Contains(_reader.Entry.Key)) { await _reader.WriteEntryToDirectoryAsync(installDirectory, new ExtractionOptions { ExtractFullPath = true, Overwrite = true, PreserveFileTime = true, }, cancellationToken); } else // Skip to next entry try { await using var es = await _reader.OpenEntryStreamAsync(cancellationToken); } catch (Exception ex) { logger?.LogError(ex, "Could not skip to the next entry in the archive: {EntryKey}", _reader.Entry.Key); } } catch (IOException ex) { var errorCode = ex.HResult & 0xFFFF; if (errorCode == 87) throw; else logger?.LogTrace("Not replacing existing file/folder on disk: {EntryKey} - {Message}", _reader.Entry.Key, ex.Message); // Skip to next entry await using var es = await _reader.OpenEntryStreamAsync(cancellationToken); } } await _reader.DisposeAsync(); await stream.DisposeAsync(); } catch (Exception ex) { throw new Exception("The game archive could not be extracted, is it corrupted? Please try again"); } } public Task RestoreFilesAsync(string installDirectory, Guid gameId, GameInstallationFileList fileListRemoved, GameInstallationFileList fileListAdded) { var listRemoved = fileListRemoved?.ToFlatDistinctFileEntries() ?? []; var listAdded = fileListAdded?.ToFlatDistinctFileEntries() ?? []; var uniqueList = listRemoved.ExceptBy(listAdded.Select(x => x.EntryPath), x => x.EntryPath, StringComparer.OrdinalIgnoreCase); var possibleRestoreEntries = uniqueList.Select(x => x.EntryPath).ToArray(); return RestoreFilesAsync(installDirectory, gameId, possibleRestoreEntries); } /// /// Restores invalidated files matching the specified files. /// /// The directory where the game is installed. /// The unique identifier of the game. /// A collection of file paths to check and compare with invalidated files. public async Task RestoreFilesAsync(string installDirectory, Guid gameId, IEnumerable entries) { // early out if no files were removed which would require checking if (entries == null || !entries.Any()) return; // validate files, which takes addons into account var conflicts = await ValidateFilesAsync(installDirectory, gameId) ?? []; // build list of files to download by matching up removed files with conflicting files, split by game/addon var downloadEntries = conflicts .IntersectBy(entries, x => x.FullName, StringComparer.OrdinalIgnoreCase) .Select(x => (x.GameId ?? gameId, x.FullName)).ToArray(); await DownloadFilesAsync(installDirectory, downloadEntries); } public static string GetMetadataDirectoryPath(string installDirectory, Guid gameId) { if (string.IsNullOrWhiteSpace(installDirectory)) return ""; return Path.Combine(installDirectory, ".lancommander", gameId.ToString()); } public static string GetMetadataFilePath(string installDirectory, Guid gameId, string fileName) { return Path.Combine(GetMetadataDirectoryPath(installDirectory, gameId), fileName); } public static string GetPlayerAlias(string installDirectory, Guid gameId) { var aliasFilePath = GetMetadataFilePath(installDirectory, gameId, PlayerAliasFilename); if (File.Exists(aliasFilePath)) return File.ReadAllText(aliasFilePath); return string.Empty; } public static async Task GetPlayerAliasAsync(string installDirectory, Guid gameId) { var aliasFilePath = GetMetadataFilePath(installDirectory, gameId, PlayerAliasFilename); if (File.Exists(aliasFilePath)) return await File.ReadAllTextAsync(aliasFilePath); return string.Empty; } public static void UpdatePlayerAlias(string installDirectory, Guid gameId, string newName) { File.WriteAllText(GetMetadataFilePath(installDirectory, gameId, PlayerAliasFilename), newName); } public static async Task UpdatePlayerAliasAsync(string installDirectory, Guid gameId, string newName) { await File.WriteAllTextAsync(GetMetadataFilePath(installDirectory, gameId, PlayerAliasFilename), newName); } public static string GetCurrentKey(string installDirectory, Guid gameId) { var keyFilePath = GetMetadataFilePath(installDirectory, gameId, KeyFilename); if (File.Exists(keyFilePath)) return File.ReadAllText(keyFilePath); return string.Empty; } public static async Task GetCurrentKeyAsync(string installDirectory, Guid gameId) { var keyFilePath = GetMetadataFilePath(installDirectory, gameId, KeyFilename); if (File.Exists(keyFilePath)) return await File.ReadAllTextAsync(keyFilePath); return string.Empty; } public static void UpdateCurrentKey(string installDirectory, Guid gameId, string newKey) { File.WriteAllText(GetMetadataFilePath(installDirectory, gameId, KeyFilename), newKey); } public static async Task UpdateCurrentKeyAsync(string installDirectory, Guid gameId, string newKey) { await File.WriteAllTextAsync(GetMetadataFilePath(installDirectory, gameId, KeyFilename), newKey); } } }