LANCommander/LANCommander.SDK/Services/GameClient.cs
Pat Hartl 6dfc0906ac Refactor launcher library importing, manifests
Switched the library importing in the launcher to work based on a queue. As a new game is added to the queue, it should find any related data and also add it to the queue. This should result in an easier to maintain importer while reducing the load on the launcher's DAL. This may result in more RAM usage while importing. Local manifests have also been refactored to match manifests in import/export LCX files, removing the old manifest format. This is a large breaking change that will probably break existing game installations. A migration will probably have to be created.
2025-11-29 18:24:27 -06:00

1812 lines
72 KiB
C#

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<GameClient> 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<Guid, CancellationTokenSource> _running = new();
public async Task<IEnumerable<Game>> GetAsync()
{
return await apiRequestFactory
.Create()
.UseAuthenticationToken()
.UseVersioning()
.UseRoute("/api/Games")
.GetAsync<IEnumerable<Game>>();
}
public async Task<Game> GetAsync(Guid id)
{
return await apiRequestFactory
.Create()
.UseAuthenticationToken()
.UseVersioning()
.UseRoute($"/api/Games/{id}")
.GetAsync<Game>();
}
public async Task<Models.Manifest.Game> GetManifestAsync(Guid id)
{
return await apiRequestFactory
.Create()
.UseAuthenticationToken()
.UseVersioning()
.UseRoute($"/api/Games/{id}/Manifest")
.GetAsync<Models.Manifest.Game>();
}
public async Task<ICollection<Models.Manifest.Game>> GetManifestsAsync(string installDirectory, Guid id)
{
var manifests = new List<Models.Manifest.Game>();
var mainManifest = await ManifestHelper.ReadAsync<Models.Manifest.Game>(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<SDK.Models.Manifest.Game>(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<IEnumerable<Models.Manifest.Action>> GetActionsAsync(string installDirectory, Guid id)
{
var actions = new List<Models.Manifest.Action>();
try
{
if (connectionClient.IsConnected())
{
actions.AddRange(
await apiRequestFactory
.Create()
.UseRoute($"/api/Games/{id}/Actions")
.UseAuthenticationToken()
.UseVersioning()
.GetAsync<IEnumerable<Models.Manifest.Action>>()
);
}
}
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<IEnumerable<Game>> GetAddonsAsync(Guid id)
{
return await apiRequestFactory
.Create()
.UseAuthenticationToken()
.UseVersioning()
.UseRoute($"/api/Games/{id}/Addons")
.GetAsync<IEnumerable<Game>>();
}
public async Task<bool> CheckForUpdateAsync(Guid id, string currentVersion)
{
return await apiRequestFactory
.Create()
.UseAuthenticationToken()
.UseVersioning()
.UseRoute($"/api/Games/{id}/CheckForUpdate?version={currentVersion}")
.GetAsync<bool>();
}
private async Task<bool> CanStreamLatestArchiveAsync(Guid id)
{
try
{
await apiRequestFactory
.Create()
.UseAuthenticationToken()
.UseVersioning()
.UseRoute($"/api/Games/{id}/Download")
.HeadAsync();
return true;
}
catch
{
return false;
}
}
private async Task<TrackableStream> 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<object>();
}
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<object>();
}
catch (Exception ex)
{
logger?.LogError(ex, "Failed sending stop request to server");
}
}
public async Task<string> 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<Key>();
if (response == null)
return string.Empty;
return response.Value;
}
public async Task<string> 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<Key>();
if (response == null)
return string.Empty;
return response.Value;
}
/// <summary>
/// Downloads, extracts, and runs post-install scripts for the specified game
/// </summary>
/// <param name="gameId">Unique identifier of the game to install.</param>
/// <param name="installDirectory">Optional custom installation directory.</param>
/// <param name="addonIds">Optional list of add-on identifiers to install alongside the game.</param>
/// <param name="maxAttempts">Maximum attempts in case of transmission error</param>
/// <returns>
/// An <see cref="InstallResult"/> containing details about the installation outcome such as the final install path.
/// </returns>
/// <exception cref="Exception">
/// Thrown if installation fails after the maximum retry attempts.
/// </exception>
public async Task<InstallResult> 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<SDK.Models.Manifest.Game>(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<InstallResult> InstallAddonsAsync(string installDirectory, Guid baseGameId, IEnumerable<Guid> addonIds)
{
var game = await GetAsync(baseGameId);
return await InstallAddonsAsync(installDirectory, game, addonIds);
}
public async Task<InstallResult> InstallAddonsAsync(string installDirectory, Game game, IEnumerable<Guid> addonIds)
{
var installResult = new InstallResult(installDirectory, game.Id);
var gameFileList = installResult.FileList;
if (addonIds != null)
{
var addons = new List<Game>();
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<InstallResult> 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<InstallResult> UninstallAsync(string installDirectory, Guid gameId)
{
var installResult = new InstallResult(installDirectory, gameId);
var gameFileList = installResult.FileList;
var manifest = await ManifestHelper.ReadAsync<SDK.Models.Manifest.Game>(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<InstallResult> UninstallAddonsAsync(string installDirectory, Guid baseGameId, IEnumerable<Guid> addonIds)
{
var installResult = new InstallResult(installDirectory, baseGameId);
var gameFileList = installResult.FileList;
var baseManifest = await ManifestHelper.ReadAsync<SDK.Models.Manifest.Game>(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<InstallResult> UninstallAddonAsync(string installDirectory, Guid addonGameId)
{
var installResult = new InstallResult(installDirectory, addonGameId);
var gameFileList = installResult.FileList;
var manifest = await ManifestHelper.ReadAsync<SDK.Models.Manifest.Game>(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<string> MoveAsync(Guid gameId, string oldInstallDirectory, string newInstallDirectory)
{
var game = await GetAsync(gameId);
return await MoveAsync(game, oldInstallDirectory, newInstallDirectory);
}
public async Task<string> MoveAsync(Game game, string oldInstallDirectory, string newInstallDirectory)
{
var gameAndAddons = new List<Game>();
_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<bool> 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<Models.Manifest.Game> 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<ExtractionResult> 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<ExtractionResult.FileEntry>();
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<string> 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<ICollection<SDK.Models.Manifest.Game>> ReadManifestsAsync(string installDirectory, Guid gameId)
{
var manifests = new List<SDK.Models.Manifest.Game>();
var mainManifest = await ManifestHelper.ReadAsync<SDK.Models.Manifest.Game>(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<SDK.Models.Manifest.Game>(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;
}
/// <summary>
/// Retrieves the archive entries of the current game installation from the server for the specified game
/// </summary>
/// <param name="gameId">The unique identifier of the game.</param>
/// <param name="manifest">The manifest containing metadata of the game's installation.</param>
/// <returns>
/// A collection of <see cref="ArchiveEntry"/> representing the archive entries.
/// Returns an empty list if no entries are found.
/// </returns>
/// <exception cref="Exception">
/// Thrown if the request to retrieve archive entries encounters an error.
/// </exception>
protected async Task<IEnumerable<ArchiveEntry>> GetGameInstallationArchiveEntries(Guid gameId, Models.Manifest.Game manifest)
{
var entries = await apiRequestFactory
.Create()
.UseAuthenticationToken()
.UseVersioning()
.UseRoute($"/api/Archives/Contents/{manifest.Id}/{manifest.Version}")
.GetAsync<IEnumerable<ArchiveEntry>>();
return entries ?? [];
}
/// <summary>
/// Retrieves the archive entries for a game installation, including its base game and dependencies.
/// </summary>
/// <param name="installDirectory">The directory where the game is installed.</param>
/// <param name="gameId">The unique identifier of the game.</param>
/// <returns>
/// An instance of <see cref="GameInstallationArchiveEntries"/> containing archive entries
/// for the base game and any dependent games.
/// </returns>
protected async Task<GameInstallationArchiveEntries> 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<SDK.Models.Manifest.Game> 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();
}
}
/// <summary>
/// Get the archive associated with the installed version of the game and return any non-matching files in the current install.
/// </summary>
/// <param name="installDirectory">The game's install directory</param>
/// <param name="gameId">The game's ID</param>
/// <returns>List of file conflicts</returns>
public async Task<IEnumerable<ArchiveValidationConflict>> 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<Guid?>().ToLookup(x => default(ArchiveEntry));
var conflictedEntries = new List<ArchiveValidationConflict>();
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;
}
/// <summary>
/// Downloads the specified files for multiple games (base game, mods, expansions).
/// </summary>
/// <param name="installDirectory">The directory where the games are installed.</param>
/// <param name="entries">
/// A collection of tuples containing the game ID and the corresponding file path.
/// </param>
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());
}
}
/// <summary>
/// Downloads the specified files for a single game.
/// </summary>
/// <param name="installDirectory">The directory where the game is installed.</param>
/// <param name="gameId">The unique identifier of the game.</param>
/// <param name="entries">A collection of file paths to download.</param>
public async Task DownloadFilesAsync(string installDirectory, Guid gameId, ICollection<string> entries)
{
var manifest = await ManifestHelper.ReadAsync<SDK.Models.Manifest.Game>(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);
}
/// <summary>
/// Restores invalidated files matching the specified files.
/// </summary>
/// <param name="installDirectory">The directory where the game is installed.</param>
/// <param name="gameId">The unique identifier of the game.</param>
/// <param name="entries">A collection of file paths to check and compare with invalidated files.</param>
public async Task RestoreFilesAsync(string installDirectory, Guid gameId, IEnumerable<string> 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<string> 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<string> 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);
}
}
}