LANCommander/LANCommander.SDK/Clients/GameClient.cs
Pat Hartl ecaecd21bb Send keepalive while game is running
Sends a keepalive/heartbeat signal across RPC while a game is running. Server-side this enables the ability to manage play session state with realtime information instead of hoping that the client will stop the play session. Keepalives are tracked in server cache allowing for future potential horizontally-scaled server instances.
2026-06-14 00:53:45 -05:00

2801 lines
114 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;
public delegate void OnTaskProgressHandler(InstallTaskProgress progress);
public event OnTaskProgressHandler OnTaskProgress;
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) ?? false))
{
var primaryAction = actions.First(a => a.IsPrimaryAction);
try
{
var lobbies = lobbyClient.GetSteamLobbies(installDirectory, id);
foreach (var lobby in lobbies)
{
var lobbyAction = new Models.Manifest.Action
{
Arguments = $"{primaryAction.Arguments} +connect_lobby {lobby.Id}",
IsPrimaryAction = true,
Name = $"Join {lobby.ExternalUsername}'s lobby",
SortOrder = actions.Count,
Path = primaryAction.Path,
WorkingDirectory = primaryAction.WorkingDirectory
};
actions.Add(lobbyAction);
}
}
catch (Exception ex)
{
logger?.LogError(ex, "Could not get lobbies");
}
}
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>();
}
public async Task<IEnumerable<Archive>> GetUpdatesAsync(Guid gameId, string version)
{
return await apiRequestFactory
.Create()
.UseAuthenticationToken()
.UseVersioning()
.UseRoute($"/api/Games/{gameId}/Updates?version={version}")
.GetAsync<IEnumerable<Archive>>();
}
private async Task<TrackableStream> StreamArchiveAsync(Guid archiveId)
{
return await apiRequestFactory
.Create()
.UseAuthenticationToken()
.UseVersioning()
.UseRoute($"/Download/Archive/{archiveId}")
.StreamAsync();
}
/// <summary>
/// Downloads and extracts a specific archive for a game update.
/// </summary>
/// <returns>True if successful, false if canceled.</returns>
public async Task<bool> ApplyUpdateArchiveAsync(Guid archiveId, Guid gameId, string destination, CancellationToken cancellationToken = default)
{
var game = await GetAsync(gameId);
if (game == null)
throw new InstallException($"Could not fetch game info for game {gameId}");
_installProgress.Game = game;
_installProgress.Title = game.Title;
var result = await DownloadAndExtractArchiveAsync(archiveId, game, destination, cancellationToken);
if (result.Canceled)
return false;
if (!result.Success)
throw new InstallException("Could not extract the update archive. Retry the update or check your connection");
return true;
}
internal async Task<ExtractionResult> DownloadAndExtractArchiveAsync(Guid archiveId, Game game, string destination, CancellationToken cancellationToken = default)
{
if (game == null)
throw new ArgumentNullException(nameof(game), "No game was specified");
logger?.LogTrace("Downloading archive {ArchiveId} and extracting {Game} to path {Destination}", archiveId, game.Title, destination);
var extractionResult = new ExtractionResult
{
Canceled = false,
};
var fileManifest = new StringBuilder();
var files = new List<ExtractionResult.FileEntry>();
try
{
Directory.CreateDirectory(destination);
var stream = await StreamArchiveAsync(archiveId);
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);
_installProgress.Status = InstallStatus.Downloading;
OnInstallProgressUpdate?.Invoke(_installProgress);
while (await _reader.MoveToNextEntryAsync(cancellationToken))
{
if (_reader.Cancelled)
break;
try
{
var entryKey = _reader.Entry.Key;
var localFile = Path.Combine(destination, entryKey);
fileManifest.AppendLine($"{entryKey} | {_reader.Entry.Crc.ToString("X")}");
files.Add(new ExtractionResult.FileEntry
{
EntryPath = entryKey,
LocalPath = localFile,
});
await _reader.WriteEntryToDirectoryAsync(destination, new ExtractionOptions()
{
ExtractFullPath = true,
Overwrite = true,
PreserveFileTime = true
}, cancellationToken);
}
catch (IOException ex)
{
var errorCode = ex.HResult & 0xFFFF;
if (errorCode == 87)
throw;
else
logger?.LogTrace("Not replacing existing file/folder on disk: {EntryKey} - {Message}", _reader.Entry.Key, ex.Message);
await using var es = await _reader.OpenEntryStreamAsync(cancellationToken);
}
}
await _reader.DisposeAsync();
await stream.DisposeAsync();
}
catch (ReaderCancelledException ex)
{
logger?.LogTrace(ex, "User cancelled the download");
extractionResult.Canceled = true;
}
catch (Exception ex)
{
logger?.LogError(ex, "Could not extract archive {ArchiveId} to path {Destination}", archiveId, destination);
throw new Exception("The game archive could not be extracted, is it corrupted? Please try again");
}
if (!extractionResult.Canceled)
{
extractionResult.Success = true;
extractionResult.Directory = destination;
extractionResult.Files = files;
var fileListDestination = Path.Combine(destination, ".lancommander", game.Id.ToString(), "FileList.txt");
if (!Directory.Exists(Path.GetDirectoryName(fileListDestination)))
Directory.CreateDirectory(Path.GetDirectoryName(fileListDestination));
File.WriteAllText(fileListDestination, fileManifest.ToString());
}
return extractionResult;
}
private async Task<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?.LogInformation("Downloading latest save for game {GameTitle} ({GameId}) during install", game.Title, game.Id);
_installProgress.Status = InstallStatus.DownloadingSaves;
OnInstallProgressUpdate?.Invoke(_installProgress);
await saveClient.DownloadAsync(game.InstallDirectory, game.Id);
#endregion
await RunPostInstallScripts(game);
if (addonIds != null)
{
var addonsResult = await InstallAddonsAsync(installDirectory, game, addonIds);
gameFileList.MergeDependentGames(addonsResult.FileList);
}
_installProgress.Status = InstallStatus.Complete;
_installProgress.Progress = 1;
_installProgress.BytesTransferred = _installProgress.TotalBytes;
OnInstallProgressUpdate?.Invoke(_installProgress);
return installResult;
}
public async Task<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;
}
/// <summary>
/// Generates an install plan for a game, producing a list of queue items and their tasks
/// without executing anything.
/// </summary>
public async Task<InstallPlan> GenerateInstallPlanAsync(Guid gameId, string installDirectory, Guid[] addonIds = null, Guid[] toolIds = null)
{
logger?.LogInformation("[InstallQueue] GenerateInstallPlan: gameId={GameId}, installDir={InstallDir}, addonIds={AddonIds}",
gameId, installDirectory, addonIds != null ? string.Join(",", addonIds) : "none");
var plan = new InstallPlan();
var game = await GetAsync(gameId);
logger?.LogInformation("[InstallQueue] GenerateInstallPlan: Fetched game {Title} ({Id}), type={Type}, baseGameId={BaseGameId}, redistCount={RedistCount}, scriptCount={ScriptCount}",
game?.Title, game?.Id, game?.Type, game?.BaseGameId, game?.Redistributables?.Count() ?? 0, game?.Scripts?.Count() ?? 0);
if (string.IsNullOrWhiteSpace(installDirectory))
installDirectory = settingsProvider.CurrentValue.Games.InstallDirectories.First();
var destination = await GetInstallDirectory(game, installDirectory);
logger?.LogInformation("[InstallQueue] GenerateInstallPlan: Resolved install directory to {Destination}", destination);
// Handle standalone mods — need base game first
if (game.Type == GameType.StandaloneMod && game.BaseGameId != Guid.Empty)
{
var baseGame = await GetAsync(game.BaseGameId);
var baseDestination = await GetInstallDirectory(baseGame, installDirectory);
if (!Directory.Exists(baseDestination))
{
var basePlan = await GenerateInstallPlanAsync(game.BaseGameId, installDirectory);
plan.Items.AddRange(basePlan.Items);
}
destination = baseDestination;
}
// Base game item
var gameItem = new InstallPlanItem
{
EntityId = game.Id,
Title = game.Title,
Type = InstallPlanItemType.Game,
InstallDirectory = destination,
Order = plan.Items.Count,
};
int taskOrder = 0;
gameItem.Tasks.Add(new InstallTaskDefinition
{
Type = InstallTaskType.VerifyFiles,
Title = "Verify local files",
Order = taskOrder++,
TargetId = game.Id,
TargetName = game.Title,
IsCritical = false,
});
gameItem.Tasks.Add(new InstallTaskDefinition
{
Type = InstallTaskType.DownloadAndExtract,
Title = $"Download {game.Title}",
Order = taskOrder++,
TargetId = game.Id,
TargetName = game.Title,
IsCritical = true,
ReportsProgress = true,
});
gameItem.Tasks.Add(new InstallTaskDefinition
{
Type = InstallTaskType.WriteManifest,
Title = "Write manifest",
Order = taskOrder++,
TargetId = game.Id,
TargetName = game.Title,
IsCritical = true,
});
gameItem.Tasks.Add(new InstallTaskDefinition
{
Type = InstallTaskType.WriteScripts,
Title = "Save scripts",
Order = taskOrder++,
TargetId = game.Id,
TargetName = game.Title,
IsCritical = false,
});
gameItem.Tasks.Add(new InstallTaskDefinition
{
Type = InstallTaskType.DownloadSaves,
Title = "Download saves",
Order = taskOrder++,
TargetId = game.Id,
TargetName = game.Title,
IsCritical = false,
ReportsProgress = true,
});
if (game.Scripts != null && game.Scripts.Any())
{
gameItem.Tasks.Add(new InstallTaskDefinition
{
Type = InstallTaskType.RunInstallScript,
Title = "Run install script",
Order = taskOrder++,
TargetId = game.Id,
TargetName = game.Title,
IsCritical = false,
});
gameItem.Tasks.Add(new InstallTaskDefinition
{
Type = InstallTaskType.RunKeyChangeScript,
Title = "Apply key",
Order = taskOrder++,
TargetId = game.Id,
TargetName = game.Title,
IsCritical = false,
});
gameItem.Tasks.Add(new InstallTaskDefinition
{
Type = InstallTaskType.RunNameChangeScript,
Title = "Apply player name",
Order = taskOrder++,
TargetId = game.Id,
TargetName = game.Title,
IsCritical = false,
});
}
if (game.Media != null && game.Media.Any(m => m.Type == MediaType.Manual))
{
var manualIds = game.Media
.Where(m => m.Type == MediaType.Manual)
.Select(m => m.Id.ToString());
gameItem.Tasks.Add(new InstallTaskDefinition
{
Type = InstallTaskType.DownloadManual,
Title = "Download manuals",
Order = taskOrder++,
TargetId = game.Id,
TargetName = game.Title,
IsCritical = false,
Parameters = new Dictionary<string, string>
{
["ManualIds"] = string.Join(",", manualIds),
},
});
}
plan.Items.Add(gameItem);
// Redistributable items
if (game.Redistributables != null)
{
foreach (var redist in game.Redistributables)
{
var redistItem = new InstallPlanItem
{
EntityId = redist.Id,
Title = redist.Name,
Type = InstallPlanItemType.Redistributable,
InstallDirectory = destination,
Order = plan.Items.Count,
DependsOnId = game.Id,
};
redistItem.Tasks.Add(new InstallTaskDefinition
{
Type = InstallTaskType.DownloadAndExtract,
Title = $"Download {redist.Name}",
Order = 0,
TargetId = redist.Id,
TargetName = redist.Name,
IsCritical = true,
ReportsProgress = true,
Parameters = new Dictionary<string, string>
{
["ParentGameId"] = game.Id.ToString(),
},
});
redistItem.Tasks.Add(new InstallTaskDefinition
{
Type = InstallTaskType.RunRedistributableInstallScript,
Title = $"Install {redist.Name}",
Order = 1,
TargetId = redist.Id,
TargetName = redist.Name,
IsCritical = false,
Parameters = new Dictionary<string, string>
{
["ParentGameId"] = game.Id.ToString(),
},
});
plan.Items.Add(redistItem);
}
}
// Tool items
if (toolIds != null)
{
foreach (var toolId in toolIds)
{
var tool = await toolClient.GetAsync(toolId);
var toolPlan = await toolClient.GenerateInstallPlanAsync(tool, settingsProvider.CurrentValue.Tools.InstallDirectory);
foreach (var toolPlanItem in toolPlan.Items)
{
toolPlanItem.Order = plan.Items.Count;
toolPlanItem.DependsOnId = game.Id;
plan.Items.Add(toolPlanItem);
}
}
}
// Addon items
if (addonIds != null)
{
foreach (var addonId in addonIds)
{
var addon = await GetAsync(addonId);
var addonItem = new InstallPlanItem
{
EntityId = addon.Id,
Title = addon.Title,
Type = InstallPlanItemType.Addon,
InstallDirectory = destination,
Order = plan.Items.Count,
DependsOnId = game.Id,
};
int addonTaskOrder = 0;
addonItem.Tasks.Add(new InstallTaskDefinition
{
Type = InstallTaskType.DownloadAndExtract,
Title = $"Download {addon.Title}",
Order = addonTaskOrder++,
TargetId = addon.Id,
TargetName = addon.Title,
IsCritical = true,
ReportsProgress = true,
});
addonItem.Tasks.Add(new InstallTaskDefinition
{
Type = InstallTaskType.WriteManifest,
Title = "Write manifest",
Order = addonTaskOrder++,
TargetId = addon.Id,
TargetName = addon.Title,
IsCritical = true,
});
addonItem.Tasks.Add(new InstallTaskDefinition
{
Type = InstallTaskType.WriteScripts,
Title = "Save scripts",
Order = addonTaskOrder++,
TargetId = addon.Id,
TargetName = addon.Title,
IsCritical = false,
});
if (addon.Scripts != null && addon.Scripts.Any())
{
addonItem.Tasks.Add(new InstallTaskDefinition
{
Type = InstallTaskType.RunInstallScript,
Title = "Run install script",
Order = addonTaskOrder++,
TargetId = addon.Id,
TargetName = addon.Title,
IsCritical = false,
});
addonItem.Tasks.Add(new InstallTaskDefinition
{
Type = InstallTaskType.RunKeyChangeScript,
Title = "Apply key",
Order = addonTaskOrder++,
TargetId = addon.Id,
TargetName = addon.Title,
IsCritical = false,
});
addonItem.Tasks.Add(new InstallTaskDefinition
{
Type = InstallTaskType.RunNameChangeScript,
Title = "Apply player name",
Order = addonTaskOrder++,
TargetId = addon.Id,
TargetName = addon.Title,
IsCritical = false,
});
}
plan.Items.Add(addonItem);
}
}
return plan;
}
/// <summary>
/// Executes a single install plan item's tasks in order, firing OnTaskProgress events for each.
/// </summary>
public async Task<InstallResult> ExecuteInstallPlanItemAsync(InstallPlanItem planItem, CancellationToken cancellationToken = default)
{
var installResult = new InstallResult(planItem.InstallDirectory, planItem.EntityId);
switch (planItem.Type)
{
case InstallPlanItemType.Game:
case InstallPlanItemType.Addon:
await ExecuteGamePlanItemAsync(planItem, installResult, cancellationToken);
break;
case InstallPlanItemType.Redistributable:
await ExecuteRedistributablePlanItemAsync(planItem, installResult, cancellationToken);
break;
case InstallPlanItemType.Tool:
var toolResult = await toolClient.ExecuteInstallPlanItemAsync(planItem, cancellationToken);
installResult.InstallDirectory = toolResult.InstallDirectory;
break;
}
return installResult;
}
private async Task ExecuteGamePlanItemAsync(InstallPlanItem planItem, InstallResult installResult, CancellationToken cancellationToken)
{
logger?.LogInformation("[InstallQueue] ExecuteGamePlanItem: Starting for {Title} ({EntityId}), type={Type}, installDir={InstallDir}, taskCount={TaskCount}",
planItem.Title, planItem.EntityId, planItem.Type, planItem.InstallDirectory, planItem.Tasks?.Count ?? 0);
var game = await GetAsync(planItem.EntityId);
if (game == null)
{
logger?.LogInformation("[InstallQueue] ExecuteGamePlanItem: ERROR - Could not fetch game {EntityId} from server", planItem.EntityId);
throw new InstallException($"Could not fetch game info for {planItem.Title}");
}
// Set the progress context so OnInstallProgressUpdate events carry the game reference
_installProgress.Game = game;
_installProgress.Title = game.Title;
var gameFileList = installResult.FileList;
SDK.Models.Manifest.Game manifest = null;
// Files confirmed to exist locally and match FileList.txt — skip during extraction
HashSet<string> verifiedFiles = null;
foreach (var taskDef in planItem.Tasks.OrderBy(t => t.Order))
{
cancellationToken.ThrowIfCancellationRequested();
logger?.LogInformation("[InstallQueue] ExecuteGamePlanItem: Running task [{Order}] {Type}: {Title} (critical={IsCritical})",
taskDef.Order, taskDef.Type, taskDef.Title, taskDef.IsCritical);
var taskProgress = new InstallTaskProgress
{
QueueItemId = planItem.EntityId,
TaskId = taskDef.Id,
TaskType = taskDef.Type,
TaskTitle = taskDef.Title,
TaskStatus = InstallTaskStatus.Running,
};
OnTaskProgress?.Invoke(taskProgress);
try
{
switch (taskDef.Type)
{
case InstallTaskType.VerifyFiles:
verifiedFiles = await VerifyLocalFilesAsync(planItem.InstallDirectory, game.Id, cancellationToken);
logger?.LogInformation("[InstallQueue] VerifyFiles: {Count} files verified as present", verifiedFiles?.Count ?? 0);
break;
case InstallTaskType.DownloadAndExtract:
var skipFiles = verifiedFiles;
var result = await RetryHelper.RetryOnExceptionAsync(10, TimeSpan.FromMilliseconds(500), new ExtractionResult(), async () =>
{
return await Task.Run(async () => await DownloadAndExtractAsync(game, planItem.InstallDirectory, cancellationToken, skipFiles));
});
if (!result.Success && !result.Canceled)
throw new InstallException("Could not extract the installer. Retry the install or check your connection");
if (result.Canceled)
throw new InstallCanceledException("Game install was canceled");
game.InstallDirectory = result.Directory;
installResult.InstallDirectory = result.Directory;
planItem.InstallDirectory = result.Directory;
gameFileList.BaseGame.AddFiles(result.Files?
.Where(x => !x.EntryPath.EndsWith("/"))
.Select(x => new GameInstallationFileListEntry.FileEntry
{
EntryPath = x.EntryPath,
LocalPath = x.LocalPath,
}) ?? []);
break;
case InstallTaskType.WriteManifest:
manifest = await RetryHelper.RetryOnExceptionAsync(10, TimeSpan.FromSeconds(1), (SDK.Models.Manifest.Game)null, async () =>
{
return await WriteManifestAsync(planItem.InstallDirectory, game);
});
if (manifest == null)
throw new InstallException("Could not grab the manifest file. Retry the install or check your connection");
gameFileList.BaseGame.Manifest = manifest;
break;
case InstallTaskType.WriteScripts:
await WriteScriptsAsync(planItem.InstallDirectory, game);
break;
case InstallTaskType.DownloadSaves:
await saveClient.DownloadAsync(planItem.InstallDirectory, game.Id);
break;
case InstallTaskType.RunInstallScript:
await scriptClient.Game_RunInstallScriptAsync(planItem.InstallDirectory, game.Id);
break;
case InstallTaskType.RunKeyChangeScript:
var allocatedKey = await GetAllocatedKeyAsync(game.Id);
await scriptClient.Game_RunKeyChangeScriptAsync(planItem.InstallDirectory, game.Id, allocatedKey);
break;
case InstallTaskType.RunNameChangeScript:
var alias = await profileClient.GetAliasAsync();
await scriptClient.Game_RunNameChangeScriptAsync(planItem.InstallDirectory, game.Id, alias);
break;
case InstallTaskType.DownloadManual:
// Manual download handled by caller (InstallService) since it needs MediaClient
break;
}
taskProgress.TaskStatus = InstallTaskStatus.Completed;
taskProgress.Progress = 1.0f;
OnTaskProgress?.Invoke(taskProgress);
}
catch (InstallCanceledException)
{
taskProgress.TaskStatus = InstallTaskStatus.Canceled;
OnTaskProgress?.Invoke(taskProgress);
throw;
}
catch (Exception ex) when (!taskDef.IsCritical)
{
logger?.LogError(ex, "Non-critical task {TaskTitle} failed for {GameTitle} ({GameId})", taskDef.Title, game.Title, game.Id);
taskProgress.TaskStatus = InstallTaskStatus.Failed;
taskProgress.ErrorMessage = ex.Message;
OnTaskProgress?.Invoke(taskProgress);
}
}
}
private async Task ExecuteRedistributablePlanItemAsync(InstallPlanItem planItem, InstallResult installResult, CancellationToken cancellationToken)
{
// RedistributableClient.InstallAsync bundles download + install into one operation.
// We fire task progress for both tasks but execute them as one call.
var firstTask = planItem.Tasks.OrderBy(t => t.Order).FirstOrDefault();
if (firstTask == null)
return;
// Get parent game context from task parameters
var parentGameId = Guid.Empty;
if (firstTask.Parameters.TryGetValue("ParentGameId", out var parentGameIdStr))
Guid.TryParse(parentGameIdStr, out parentGameId);
var taskProgress = new InstallTaskProgress
{
QueueItemId = planItem.EntityId,
TaskId = firstTask.Id,
TaskType = firstTask.Type,
TaskTitle = firstTask.Title,
TaskStatus = InstallTaskStatus.Running,
};
OnTaskProgress?.Invoke(taskProgress);
try
{
cancellationToken.ThrowIfCancellationRequested();
var game = parentGameId != Guid.Empty ? await GetAsync(parentGameId) : null;
if (game != null)
{
game.InstallDirectory = planItem.InstallDirectory;
var redist = game.Redistributables?.FirstOrDefault(r => r.Id == planItem.EntityId);
if (redist != null)
await redistributableClient.InstallAsync(redist, game);
}
// Mark all tasks as completed
foreach (var taskDef in planItem.Tasks.OrderBy(t => t.Order))
{
OnTaskProgress?.Invoke(new InstallTaskProgress
{
QueueItemId = planItem.EntityId,
TaskId = taskDef.Id,
TaskType = taskDef.Type,
TaskTitle = taskDef.Title,
TaskStatus = InstallTaskStatus.Completed,
Progress = 1.0f,
});
}
}
catch (InstallCanceledException)
{
taskProgress.TaskStatus = InstallTaskStatus.Canceled;
OnTaskProgress?.Invoke(taskProgress);
throw;
}
catch (Exception ex)
{
logger?.LogError(ex, "Redistributable {RedistName} failed to install", planItem.Title);
taskProgress.TaskStatus = InstallTaskStatus.Failed;
taskProgress.ErrorMessage = ex.Message;
OnTaskProgress?.Invoke(taskProgress);
}
}
public async Task<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 Redistributable Files
if (manifest.Redistributables != null)
{
foreach (var redistributable in manifest.Redistributables)
{
try
{
var redistFileListPath = GetMetadataFilePath(installDirectory, redistributable.Id, "FileList.txt");
if (File.Exists(redistFileListPath))
{
var redistFiles = await File.ReadAllLinesAsync(redistFileListPath);
foreach (var file in redistFiles.Where(f => !string.IsNullOrWhiteSpace(f)))
{
var localPath = Path.Combine(installDirectory, file);
try
{
if (File.Exists(localPath))
File.Delete(localPath);
logger?.LogTrace("Deleted redistributable file {LocalPath}", localPath);
}
catch (Exception ex)
{
logger?.LogWarning(ex, "Could not remove redistributable file {LocalPath}", localPath);
}
}
}
var redistMetadataPath = GetMetadataDirectoryPath(installDirectory, redistributable.Id);
if (Directory.Exists(redistMetadataPath))
Directory.Delete(redistMetadataPath, true);
}
catch (Exception ex)
{
logger?.LogWarning(ex, "Could not clean up redistributable {RedistributableId}", redistributable.Id);
}
}
}
#endregion
#region Delete 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);
}
/// <summary>
/// Refreshes the on-disk manifest and scripts for an installed game by fetching the latest
/// versions from the server and writing them to the game's install directory.
/// </summary>
public async Task RefreshManifestAndScriptsAsync(string installDirectory, Guid gameId)
{
logger?.LogTrace("Refreshing manifest and scripts for game {GameId} in {InstallDirectory}", gameId, installDirectory);
var manifest = await GetManifestAsync(gameId);
await ManifestHelper.WriteAsync(manifest, installDirectory);
var scripts = await GetScriptsAsync(gameId);
if (scripts != null && scripts.Any())
{
var game = new Game { Id = gameId };
foreach (var script in scripts)
await ScriptHelper.SaveScriptAsync(game, script, installDirectory);
}
}
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);
}
}
}
/// <summary>
/// Reads the existing FileList.txt and checks which files are present on disk.
/// Returns a set of entry paths (relative) that exist locally and can be skipped during extraction.
/// </summary>
private async Task<HashSet<string>> VerifyLocalFilesAsync(string installDirectory, Guid gameId, CancellationToken cancellationToken)
{
var verified = new HashSet<string>(StringComparer.OrdinalIgnoreCase);
var fileListPath = GetMetadataFilePath(installDirectory, gameId, "FileList.txt");
if (!File.Exists(fileListPath))
return verified;
var lines = await File.ReadAllLinesAsync(fileListPath, cancellationToken);
foreach (var line in lines)
{
if (string.IsNullOrWhiteSpace(line))
continue;
// Format: "path/to/file | CRC32HEX"
var separatorIndex = line.IndexOf('|');
var entryPath = separatorIndex >= 0
? line.Substring(0, separatorIndex).Trim()
: line.Trim();
if (string.IsNullOrEmpty(entryPath) || entryPath.EndsWith("/"))
continue;
var localPath = Path.Combine(installDirectory, entryPath);
if (File.Exists(localPath))
verified.Add(entryPath);
}
return verified;
}
private async Task<ExtractionResult> DownloadAndExtractAsync(Game game, string destination, CancellationToken cancellationToken = default, HashSet<string> skipFiles = null)
{
if (game == null)
{
logger?.LogTrace("Game failed to download, no game was specified");
throw new ArgumentNullException("No game was specified");
}
logger?.LogTrace("Downloading and extracting {Game} to path {Destination}", game.Title, destination);
var extractionResult = new ExtractionResult
{
Canceled = false,
};
if (!await CanStreamLatestArchiveAsync(game.Id))
{
extractionResult.Success = false;
extractionResult.Canceled = true;
return extractionResult;
}
var fileManifest = new StringBuilder();
var files = new List<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);
_installProgress.Status = InstallStatus.Downloading;
OnInstallProgressUpdate?.Invoke(_installProgress);
while (await _reader.MoveToNextEntryAsync(cancellationToken))
{
if (_reader.Cancelled)
break;
try
{
var entryKey = _reader.Entry.Key;
var localFile = Path.Combine(destination, entryKey);
fileManifest.AppendLine($"{entryKey} | {_reader.Entry.Crc.ToString("X")}");
files.Add(new ExtractionResult.FileEntry
{
EntryPath = entryKey,
LocalPath = localFile,
});
// If pre-flight verification confirmed this file exists locally, skip it
bool shouldSkip = skipFiles != null && skipFiles.Contains(entryKey);
if (!shouldSkip)
await _reader.WriteEntryToDirectoryAsync(destination, new ExtractionOptions()
{
ExtractFullPath = true,
Overwrite = true,
PreserveFileTime = true
}, cancellationToken);
else // Skip to next entry
try
{
await using var es = await _reader.OpenEntryStreamAsync(cancellationToken);
}
catch
{
logger?.LogError("Could not skip to next entry in archive: {EntryKey}", entryKey);
}
}
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("Checking for latest save for game {GameId}", manifest.Id);
try
{
var latestSave = await saveClient.GetLatestAsync(manifest.Id);
if (latestSave == null)
{
logger?.LogDebug("No saves found on server for game {GameId}", manifest.Id);
}
else if (lastRun == null)
{
logger?.LogInformation("Downloading save for game {GameId} (first run, save date: {SaveDate})", manifest.Id, latestSave.CreatedOn);
await saveClient.DownloadAsync(installDirectory, manifest.Id);
}
else if (latestSave.CreatedOn > lastRun)
{
logger?.LogInformation("Downloading newer save for game {GameId} (save date: {SaveDate}, last run: {LastRun})", manifest.Id, latestSave.CreatedOn, lastRun);
await saveClient.DownloadAsync(installDirectory, manifest.Id);
}
else
{
logger?.LogDebug("Save for game {GameId} is up to date (save date: {SaveDate}, last run: {LastRun})", manifest.Id, latestSave.CreatedOn, lastRun);
}
}
catch (HttpRequestException ex)
{
if (ex.StatusCode == HttpStatusCode.NotFound)
{
logger?.LogDebug("No saves found on server for game {GameId} (404)", manifest.Id);
return true;
}
throw;
}
return true;
});
}
else
{
logger?.LogDebug("Skipping save download for game {GameId}, not connected to server", manifest.Id);
}
#endregion
#region Run Before Start Script
await scriptClient.Game_RunBeforeStartScriptAsync(installDirectory, manifest.Id);
if (manifest.Redistributables != null)
{
foreach (var redistributable in manifest.Redistributables.Where(r => r.Scripts != null))
{
await scriptClient.Redistributable_RunBeforeStartScriptAsync(installDirectory, gameId, redistributable.Id);
}
}
#endregion
}
#endregion
Task heartbeatTask = null;
try
{
var cancellationTokenSource = new CancellationTokenSource();
_running[gameId] = cancellationTokenSource;
heartbeatTask = SendKeepAlivesAsync(gameId, cancellationTokenSource.Token);
#region Run Wrapper Scripts
bool runWrapperHandled = false;
var gameManifest = await ManifestHelper.ReadAsync<SDK.Models.Manifest.Game>(installDirectory, gameId);
var resolvedAction = action ?? gameManifest.Actions.FirstOrDefault(a => a.IsPrimaryAction);
if (resolvedAction != null && gameManifest.Redistributables != null)
{
var wrapperRedistributables = gameManifest.Redistributables
.Where(r => r.Scripts != null && r.Scripts.Any(s => s.Type == Enums.ScriptType.RunWrapper))
.ToList();
if (wrapperRedistributables.Any())
{
if (gameManifest.CustomFields != null && gameManifest.CustomFields.Any())
{
foreach (var customField in gameManifest.CustomFields)
{
context.AddVariable(customField.Name, customField.Value);
}
}
var executablePath = context.ExpandVariables(resolvedAction.Path, installDirectory);
var arguments = context.ExpandVariables(resolvedAction.Arguments, installDirectory, skipSlashes: true);
var workingDirectory = context.ExpandVariables(resolvedAction.WorkingDirectory, installDirectory);
if (string.IsNullOrWhiteSpace(workingDirectory))
workingDirectory = installDirectory;
if (!string.IsNullOrWhiteSpace(args))
arguments = string.IsNullOrWhiteSpace(arguments) ? args : arguments + " " + args;
foreach (var redistributable in wrapperRedistributables)
{
runWrapperHandled = await scriptClient.Redistributable_RunRunWrapperScriptAsync(installDirectory, gameId, redistributable.Id, executablePath, arguments, workingDirectory, cancellationTokenSource.Token);
if (runWrapperHandled)
break;
}
}
}
#endregion
if (!runWrapperHandled)
await context.ExecuteGameActionAsync(installDirectory, gameId, action, args, cancellationTokenSource.Token);
_running.Remove(gameId);
await StopHeartbeatAsync(cancellationTokenSource, heartbeatTask);
cancellationTokenSource.Dispose();
await UploadSavesAsync(manifests, installDirectory);
}
catch (Exception ex)
{
if (_running.TryGetValue(gameId, out var cts))
{
_running.Remove(gameId);
await StopHeartbeatAsync(cts, heartbeatTask);
cts.Dispose();
}
logger?.LogError(ex, "Game failed to run");
throw;
}
foreach (var manifest in manifests)
{
#region Run After Stop Script
await scriptClient.Game_RunAfterStopScriptAsync(installDirectory, gameId);
if (manifest.Redistributables != null)
{
foreach (var redistributable in manifest.Redistributables.Where(r => r.Scripts != null))
{
await scriptClient.Redistributable_RunAfterStopScriptAsync(installDirectory, gameId, redistributable.Id);
}
}
#endregion
}
}
}
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?.LogDebug("Uploading save for game {GameId}", manifest.Id);
await saveClient.UploadAsync(installDirectory, manifest.Id);
logger?.LogInformation("Save uploaded successfully for game {GameId}", manifest.Id);
return true;
});
}
}
else
{
logger?.LogDebug("Skipping save upload, not connected to server");
}
}
// Heartbeat interval while a game is running. Must stay well below the server's
// KeepAliveTimeout so a session isn't reaped between beats.
private const int KeepAliveIntervalSeconds = 30;
private async Task SendKeepAlivesAsync(Guid gameId, CancellationToken token)
{
try
{
while (!token.IsCancellationRequested)
{
await Task.Delay(TimeSpan.FromSeconds(KeepAliveIntervalSeconds), token);
if (token.IsCancellationRequested)
break;
if (!connectionClient.IsConnected() || RpcClient.Hub == null)
continue;
try
{
await RpcClient.Hub.GameKeepAliveAsync(gameId);
}
catch (Exception ex)
{
logger?.LogTrace(ex, "Keepalive send failed for {GameId}", gameId);
}
}
}
catch (OperationCanceledException)
{
// Expected when the game exits and the token is cancelled.
}
}
private static async Task StopHeartbeatAsync(CancellationTokenSource cancellationTokenSource, Task heartbeatTask)
{
cancellationTokenSource.Cancel();
if (heartbeatTask != null)
{
try
{
await heartbeatTask;
}
catch (OperationCanceledException)
{
}
}
}
public async Task Stop(Guid gameId)
{
if (_running.ContainsKey(gameId))
{
await _running[gameId].CancelAsync();
_running.Remove(gameId);
}
}
public bool IsRunning(Guid gameId)
{
if (!_running.ContainsKey(gameId))
return false;
return !_running[gameId].IsCancellationRequested;
}
public async Task ImportAsync(string archivePath)
{
using (var fs = new FileStream(archivePath, FileMode.Open, FileAccess.Read))
{
var objectKey = await apiRequestFactory
.Create()
.UseAuthenticationToken()
.UseVersioning()
.UploadInChunksAsync(settingsProvider.CurrentValue.Archives.UploadChunkSize, fs);
if (objectKey != Guid.Empty)
await apiRequestFactory
.Create()
.UseAuthenticationToken()
.UseVersioning()
.UseRoute($"/api/Games/Import/{objectKey}")
.PostAsync();
}
}
[Obsolete("Servers no longer do \"Full\" exports")]
public async Task ExportAsync(string destinationPath, Guid gameId)
{
await apiRequestFactory
.Create()
.UseAuthenticationToken()
.UseVersioning()
.UseRoute($"/api/Games/Export/Full")
.DownloadAsync(destinationPath);
}
public async Task UploadArchiveAsync(string archivePath, Guid gameId, string version, string changelog = "")
{
using (var fs = new FileStream(archivePath, FileMode.Open, FileAccess.Read))
{
var objectKey = await apiRequestFactory
.Create()
.UseAuthenticationToken()
.UseVersioning()
.UploadInChunksAsync(settingsProvider.CurrentValue.Archives.UploadChunkSize, fs);
if (objectKey != Guid.Empty)
await apiRequestFactory
.Create()
.UseAuthenticationToken()
.UseVersioning()
.UseRoute("/api/Games/UploadArchive")
.AddBody(new UploadArchiveRequest
{
Id = gameId,
ObjectKey = objectKey,
Version = version,
Changelog = changelog
})
.PostAsync();
}
}
/// <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 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);
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);
return string.Empty;
}
public static void UpdatePlayerAlias(string installDirectory, Guid gameId, string newName)
{
File.WriteAllText(GetMetadataFilePath(installDirectory, gameId, PlayerAliasFilename), newName);
}
public static async Task UpdatePlayerAliasAsync(string installDirectory, Guid gameId, string newName)
{
await File.WriteAllTextAsync(GetMetadataFilePath(installDirectory, gameId, PlayerAliasFilename), newName);
}
public static string GetCurrentKey(string installDirectory, Guid gameId)
{
var keyFilePath = GetMetadataFilePath(installDirectory, gameId, KeyFilename);
if (File.Exists(keyFilePath))
return File.ReadAllText(keyFilePath);
return string.Empty;
}
public static async Task<string> GetCurrentKeyAsync(string installDirectory, Guid gameId)
{
var keyFilePath = GetMetadataFilePath(installDirectory, gameId, KeyFilename);
if (File.Exists(keyFilePath))
return await File.ReadAllTextAsync(keyFilePath);
return string.Empty;
}
public static void UpdateCurrentKey(string installDirectory, Guid gameId, string newKey)
{
File.WriteAllText(GetMetadataFilePath(installDirectory, gameId, KeyFilename), newKey);
}
public static async Task UpdateCurrentKeyAsync(string installDirectory, Guid gameId, string newKey)
{
await File.WriteAllTextAsync(GetMetadataFilePath(installDirectory, gameId, KeyFilename), newKey);
}
}
}