LANCommander/LANCommander.SDK/Clients/GameClient.cs

1854 lines
74 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.Net;
using System.Net.Http;
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,
ToolClient toolClient)
{
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 IAsyncReader _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>();
var manifests = await GetManifestsAsync(installDirectory, id);
var installedIds = manifests.Select(m => m.Id).ToHashSet();
try
{
if (connectionClient.IsConnected() && !connectionClient.IsOfflineMode())
{
using var cts = new CancellationTokenSource(TimeSpan.FromSeconds(5));
var serverActions = await apiRequestFactory
.Create()
.UseRoute($"/api/Games/{id}/Actions")
.UseAuthenticationToken()
.UseVersioning()
.UseCancellationToken(cts.Token)
.GetAsync<IEnumerable<SDK.Models.Action>>();
actions.AddRange(serverActions
.Where(a => installedIds.Contains(a.GameId))
.Select(a => new Models.Manifest.Action
{
Name = a.Name,
Arguments = a.Arguments,
Path = a.Path,
WorkingDirectory = a.WorkingDirectory,
IsPrimaryAction = a.IsPrimaryAction,
SortOrder = a.SortOrder,
Variables = a.Variables
}));
}
}
catch (Exception ex)
{
logger?.LogError(ex, "Could not get actions from server");
}
if (!actions.Any())
{
actions = manifests
.Where(m => m != null && m.Actions != null)
.SelectMany(m => m.Actions)
.OrderByDescending(a => a.IsPrimaryAction)
.ThenBy(a => a.SortOrder)
.ToList();
}
if (manifests.Any(m => m.MultiplayerModes.Any(m => m.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.Manifest.Action
{
Arguments = $"{primaryAction.Arguments} +connect_lobby {lobby.Id}",
IsPrimaryAction = true,
Name = $"Join {lobby.ExternalUsername}'s lobby",
SortOrder = actions.Count,
Path = primaryAction.Path,
WorkingDirectory = primaryAction.WorkingDirectory
};
actions.Add(lobbyAction);
}
}
catch (Exception ex)
{
logger?.LogError(ex, "Could not get lobbies");
}
}
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<IEnumerable<Tool>> GetToolsAsync(Guid id)
{
return await apiRequestFactory
.Create()
.UseAuthenticationToken()
.UseVersioning()
.UseRoute($"/api/Games/{id}/Tools")
.GetAsync<IEnumerable<Tool>>();
}
public async Task<IEnumerable<Script>> GetScriptsAsync(Guid id)
{
return await apiRequestFactory
.Create()
.UseAuthenticationToken()
.UseVersioning()
.UseRoute($"/api/Games/{id}/Scripts")
.GetAsync<IEnumerable<Script>>();
}
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.Game_RunUninstallScriptAsync(installDirectory, gameId);
#region Cleanup Install Directory
var metadataPath = GetMetadataDirectoryPath(installDirectory, gameId);
if (Directory.Exists(metadataPath))
Directory.Delete(metadataPath, true);
DirectoryHelper.DeleteEmptyDirectories(installDirectory);
#endregion
return installResult;
}
public async Task<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)
{
var scripts = await GetScriptsAsync(game.Id);
if (scripts != null && scripts.Any())
{
logger?.LogTrace($"Saving scripts for game {game.Title} with id {game.Id} into {installDirectory}");
foreach (var script in scripts)
await ScriptHelper.SaveScriptAsync(game, script, installDirectory);
}
}
private async Task RunPostInstallScripts(Game game)
{
if (game.Scripts != null && game.Scripts.Any())
{
_installProgress.Status = InstallStatus.RunningScripts;
OnInstallProgressUpdate?.Invoke(_installProgress);
try
{
var allocatedKey = await GetAllocatedKeyAsync(game.Id);
await scriptClient.Game_RunInstallScriptAsync(game.InstallDirectory, game.Id);
await scriptClient.Game_RunKeyChangeScriptAsync(game.InstallDirectory, game.Id, allocatedKey);
await scriptClient.Game_RunNameChangeScriptAsync(game.InstallDirectory, game.Id, await profileClient.GetAliasAsync());
}
catch (Exception ex)
{
logger?.LogError(ex, "Scripts failed to execute for game/addon {GameTitle} ({GameId})", game.Title, game.Id);
}
}
}
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);
var monitor = new FileTransferMonitor(stream.Length);
var progress = new Progress<ProgressReport>(report =>
{
if (cancellationToken.IsCancellationRequested)
{
_reader?.Cancel();
_installProgress.Status = InstallStatus.Canceled;
OnInstallProgressUpdate?.Invoke(_installProgress);
return;
}
if (monitor.CanUpdate())
{
monitor.Update(stream.Position);
_installProgress.BytesTransferred = monitor.GetBytesTransferred();
_installProgress.TotalBytes = stream.Length;
_installProgress.TransferSpeed = monitor.GetSpeed();
_installProgress.TimeRemaining = monitor.GetTimeRemaining();
OnInstallProgressUpdate?.Invoke(_installProgress);
}
OnArchiveEntryExtractionProgress?.Invoke(this, new ArchiveEntryExtractionProgressArgs
{
Progress = report,
Game = game,
});
});
_reader = await ReaderFactory.OpenAsyncReader(stream, new ReaderOptions { Progress = progress }, cancellationToken);
while (await _reader.MoveToNextEntryAsync(cancellationToken))
{
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 = await fs.ReadAsync(buffer, 0, buffer.Length, cancellationToken);
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)
await _reader.WriteEntryToDirectoryAsync(destination, new ExtractionOptions()
{
ExtractFullPath = true,
Overwrite = true,
PreserveFileTime = true
}, cancellationToken);
else // Skip to next entry
try
{
await using var es = await _reader.OpenEntryStreamAsync(cancellationToken);
}
catch
{
logger?.LogError("Could not skip to next entry in archive: {EntryKey}", _reader.Entry.Key);
}
}
catch (IOException ex)
{
var errorCode = ex.HResult & 0xFFFF;
if (errorCode == 87)
throw ex;
else
logger?.LogTrace("Not replacing existing file/folder on disk: {EntryKey} - {Message}", _reader.Entry.Key, ex.Message);
// Skip to next entry
await using var es = await _reader.OpenEntryStreamAsync(cancellationToken);
}
}
await _reader.DisposeAsync();
await stream.DisposeAsync();
// _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");
}
if (action.Variables != null)
{
foreach (var variable in action.Variables)
context.AddVariable(variable.Key, variable.Value);
}
#region Run Scripts
var manifests = await ReadManifestsAsync(installDirectory, gameId);
foreach (var manifest in manifests)
{
//manifest.Actions
var currentGamePlayerAlias = await GetPlayerAliasAsync(installDirectory, manifest.Id);
var currentGameKey = await GetCurrentKeyAsync(installDirectory, manifest.Id);
#region Check Game's Player Name
if (connectionClient.IsConnected())
{
var alias = await profileClient.GetAliasAsync();
if (currentGamePlayerAlias != alias)
{
await scriptClient.Game_RunNameChangeScriptAsync(installDirectory, gameId, alias);
if (manifest.Redistributables != null)
{
foreach (var redistributable in manifest.Redistributables.Where(r => r.Scripts != null))
{
await scriptClient.Redistributable_RunNameChangeScriptAsync(installDirectory, gameId, redistributable.Id, alias);
}
}
}
}
#endregion
#region Check Key Allocation
if (connectionClient.IsConnected())
{
var newKey = await GetAllocatedKeyAsync(manifest.Id);
if (currentGameKey != newKey)
await scriptClient.Game_RunKeyChangeScriptAsync(installDirectory, manifest.Id, newKey);
}
#endregion
#region Download Latest Saves
if (connectionClient.IsConnected())
{
await RetryHelper.RetryOnExceptionAsync(10, TimeSpan.FromSeconds(1), false, async () =>
{
logger?.LogTrace("Attempting to download save");
try
{
var latestSave = await saveClient.GetLatestAsync(manifest.Id);
if (latestSave != null && (latestSave.CreatedOn > lastRun || lastRun == null))
await saveClient.DownloadAsync(installDirectory, manifest.Id);
}
catch (HttpRequestException ex)
{
if (ex.StatusCode == HttpStatusCode.NotFound)
return true;
throw;
}
return true;
});
}
#endregion
#region Run Before Start Script
await scriptClient.Game_RunBeforeStartScriptAsync(installDirectory, manifest.Id);
if (manifest.Redistributables != null)
{
foreach (var redistributable in manifest.Redistributables.Where(r => r.Scripts != null))
{
await scriptClient.Redistributable_RunBeforeStartScriptAsync(installDirectory, gameId, redistributable.Id);
}
}
#endregion
}
#endregion
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.Game_RunAfterStopScriptAsync(installDirectory, gameId);
if (manifest.Redistributables != null)
{
foreach (var redistributable in manifest.Redistributables.Where(r => r.Scripts != null))
{
await scriptClient.Redistributable_RunAfterStopScriptAsync(installDirectory, gameId, redistributable.Id);
}
}
#endregion
}
}
}
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, CancellationToken cancellationToken = default)
{
var manifest = await ManifestHelper.ReadAsync<SDK.Models.Manifest.Game>(installDirectory, gameId);
try
{
var stream = await StreamLatestArchiveAsync(gameId);
_reader = await ReaderFactory.OpenAsyncReader(stream, new ReaderOptions(), cancellationToken);
while (await _reader.MoveToNextEntryAsync(cancellationToken))
{
if (_reader.Cancelled)
break;
try
{
if (entries.Contains(_reader.Entry.Key))
{
await _reader.WriteEntryToDirectoryAsync(installDirectory, new ExtractionOptions
{
ExtractFullPath = true,
Overwrite = true,
PreserveFileTime = true,
}, cancellationToken);
}
else // Skip to next entry
try
{
await using var es = await _reader.OpenEntryStreamAsync(cancellationToken);
}
catch (Exception ex)
{
logger?.LogError(ex, "Could not skip to the next entry in the archive: {EntryKey}", _reader.Entry.Key);
}
}
catch (IOException ex)
{
var errorCode = ex.HResult & 0xFFFF;
if (errorCode == 87)
throw;
else
logger?.LogTrace("Not replacing existing file/folder on disk: {EntryKey} - {Message}", _reader.Entry.Key, ex.Message);
// Skip to next entry
await using var es = await _reader.OpenEntryStreamAsync(cancellationToken);
}
}
await _reader.DisposeAsync();
await stream.DisposeAsync();
}
catch (Exception ex)
{
throw new Exception("The game archive could not be extracted, is it corrupted? Please try again");
}
}
public Task RestoreFilesAsync(string installDirectory, Guid gameId, GameInstallationFileList fileListRemoved, GameInstallationFileList fileListAdded)
{
var listRemoved = fileListRemoved?.ToFlatDistinctFileEntries() ?? [];
var listAdded = fileListAdded?.ToFlatDistinctFileEntries() ?? [];
var uniqueList = listRemoved.ExceptBy(listAdded.Select(x => x.EntryPath), x => x.EntryPath, StringComparer.OrdinalIgnoreCase);
var possibleRestoreEntries = uniqueList.Select(x => x.EntryPath).ToArray();
return RestoreFilesAsync(installDirectory, gameId, possibleRestoreEntries);
}
/// <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);
}
}
}