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.Text; using System.Threading; using System.Threading.Tasks; using LANCommander.SDK.Abstractions; using LANCommander.SDK.Factories; 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) { 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; private const string PlayerAliasFilename = "PlayerAlias"; private const string KeyFilename = "Key"; private TrackableStream _transferStream; private IReader _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> 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(); try { if (connectionClient.IsConnected()) { actions.AddRange( await apiRequestFactory .Create() .UseRoute($"/api/Games/{id}/Actions") .UseAuthenticationToken() .UseVersioning() .GetAsync>() ); } } catch (Exception ex) { logger?.LogError(ex, "Could not get actions from server"); } var manifests = await GetManifestsAsync(installDirectory, id); 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(); } /* if (manifests.Any(m => m.OnlineMultiplayer != null && m.OnlineMultiplayer.NetworkProtocol == NetworkProtocol.Lobby || m.LanMultiplayer != null && m.LanMultiplayer.NetworkProtocol == NetworkProtocol.Lobby)) { var primaryAction = actions.Where(a => a.IsPrimaryAction).First(); try { var lobbies = lobbyClient.GetSteamLobbies(installDirectory, id); foreach (var lobby in lobbies) { var lobbyAction = new Models.Action { Arguments = $"{primaryAction.Arguments} +connect_lobby {lobby.Id}", IsPrimaryAction = true, Name = $"Join {lobby.ExternalUsername}'s lobby", SortOrder = actions.Count, Variables = primaryAction.Variables, Path = primaryAction.Path, WorkingDirectory = primaryAction.WorkingDirectory }; actions.Add(lobbyAction); } } catch (Exception ex) { logger?.LogError(ex, "Could not get lobbies"); } } */ return actions; } public async Task> GetAddonsAsync(Guid id) { return await apiRequestFactory .Create() .UseAuthenticationToken() .UseVersioning() .UseRoute($"/api/Games/{id}/Addons") .GetAsync>(); } public async Task CheckForUpdateAsync(Guid id, string currentVersion) { return await apiRequestFactory .Create() .UseAuthenticationToken() .UseVersioning() .UseRoute($"/api/Games/{id}/CheckForUpdate?version={currentVersion}") .GetAsync(); } 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..."); try { await apiRequestFactory .Create() .UseAuthenticationToken() .UseVersioning() .UseRoute($"/api/Games/{id}/Started") .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..."); try { await apiRequestFactory .Create() .UseAuthenticationToken() .UseVersioning() .UseRoute($"/api/Games/{id}/Stopped") .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?.LogTrace("Attempting to download the latest save"); _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; } 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 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.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); } 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) { if (game.Scripts != null) { logger?.LogTrace($"Saving scripts for game {game.Title} with id {game.Id} into {installDirectory}"); foreach (var script in game.Scripts) { await ScriptHelper.SaveScriptAsync(game, script.Type, 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.RunInstallScriptAsync(game.InstallDirectory, game.Id); await scriptClient.RunKeyChangeScriptAsync(game.InstallDirectory, game.Id, allocatedKey); await scriptClient.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); } } } private async Task DownloadAndExtractAsync(Game game, string destination, CancellationToken cancellationToken = default) { 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(); try { Directory.CreateDirectory(destination); var stream = await StreamLatestArchiveAsync(game.Id); _reader = ReaderFactory.Open(stream); using (var monitor = new FileTransferMonitor(stream.Length)) { _reader.EntryExtractionProgress += (sender, e) => { 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); } // Do we need this granular of control? If so, invocations should be rate limited OnArchiveEntryExtractionProgress?.Invoke(this, new ArchiveEntryExtractionProgressArgs { Entry = e.Item, Progress = e.ReaderProgress, Game = game, }); }; } while (_reader.MoveToNextEntry()) { if (_reader.Cancelled) break; try { var localFile = Path.Combine(destination, _reader.Entry.Key); 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); } } } fileManifest.AppendLine($"{_reader.Entry.Key} | {_reader.Entry.Crc.ToString("X")}"); files.Add(new ExtractionResult.FileEntry { EntryPath = _reader.Entry.Key, LocalPath = localFile, }); if (crc == 0 || crc != _reader.Entry.Crc) _reader.WriteEntryToDirectory(destination, new ExtractionOptions() { ExtractFullPath = true, Overwrite = true, PreserveFileTime = true }); else // Skip to next entry try { _reader.OpenEntryStream().Dispose(); } catch { logger?.LogError("Could not skip to next entry in archive"); } } catch (IOException ex) { var errorCode = ex.HResult & 0xFFFF; if (errorCode == 87) throw ex; else logger?.LogTrace("Not replacing existing file/folder on disk: {Message}", ex.Message); // Skip to next entry _reader.OpenEntryStream().Dispose(); } } _reader.Dispose(); 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 to path {Destination}", destination); 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"); } #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.RunNameChangeScriptAsync(installDirectory, gameId, alias); if (manifest.Redistributables != null) { foreach (var redistributable in manifest.Redistributables.Where(r => r.Scripts != null)) { await scriptClient.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.RunKeyChangeScriptAsync(installDirectory, manifest.Id, newKey); } #endregion #region Download Latest Saves if (connectionClient.IsConnected()) { await RetryHelper.RetryOnExceptionAsync(10, TimeSpan.FromSeconds(1), false, async () => { logger?.LogTrace("Attempting to download save"); var latestSave = await saveClient.GetLatestAsync(manifest.Id); if (latestSave != null && (latestSave.CreatedOn > lastRun || lastRun == null)) await saveClient.DownloadAsync(installDirectory, manifest.Id); return true; }); } #endregion #region Run Before Start Script await scriptClient.RunBeforeStartScriptAsync(installDirectory, manifest.Id); if (manifest.Redistributables != null) { foreach (var redistributable in manifest.Redistributables.Where(r => r.Scripts != null)) { await scriptClient.RunBeforeStartScriptAsync(installDirectory, gameId, redistributable.Id); } } #endregion } #endregion try { var cancellationTokenSource = new CancellationTokenSource(); var task = context.ExecuteGameActionAsync(installDirectory, gameId, action, "", cancellationTokenSource.Token); _running[gameId] = cancellationTokenSource; await task; _running.Remove(gameId); await UploadSavesAsync(manifests, installDirectory); } catch (Exception ex) { logger?.LogError(ex, "Game failed to run"); } foreach (var manifest in manifests) { #region Run After Stop Script await scriptClient.RunAfterStopScriptAsync(installDirectory, gameId); if (manifest.Redistributables != null) { foreach (var redistributable in manifest.Redistributables.Where(r => r.Scripts != null)) { await scriptClient.RunAfterStopScriptAsync(installDirectory, gameId, redistributable.Id); } } #endregion } } } 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?.LogTrace("Attempting to upload save"); await saveClient.UploadAsync(installDirectory, manifest.Id); return true; }); } } } 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 manifest = archives?.BaseGame?.Entries; 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) { var manifest = await ManifestHelper.ReadAsync(installDirectory, gameId); await Task.Run(async () => { try { var stream = await StreamLatestArchiveAsync(gameId); _reader = ReaderFactory.Open(stream); while (_reader.MoveToNextEntry()) { if (_reader.Cancelled) break; try { if (entries.Contains(_reader.Entry.Key)) { var destination = Path.Combine(installDirectory, _reader.Entry.Key?.Replace('/', Path.DirectorySeparatorChar) ?? string.Empty); _reader.WriteEntryToFile(destination, new ExtractionOptions { Overwrite = true, PreserveFileTime = true, }); } else // Skip to next entry try { _reader.OpenEntryStream().Dispose(); } catch (Exception ex) { logger?.LogError(ex, "Could not skip to the next entry in the archive"); } } catch (IOException ex) { var errorCode = ex.HResult & 0xFFFF; if (errorCode == 87) throw; else logger?.LogTrace("Not replacing existing file/folder on disk: {Message}", ex.Message); // Skip to next entry _reader.OpenEntryStream().Dispose(); } } _reader.Dispose(); _transferStream.Dispose(); } 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); else 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); else 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); else 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); else 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); } } }