2024-01-10 17:57:00 -06:00
using Force.Crc32 ;
using LANCommander.SDK.Enums ;
2024-01-24 23:36:52 -06:00
using LANCommander.SDK.Exceptions ;
2023-11-10 00:29:16 -06:00
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 ;
2026-03-11 23:59:21 -05:00
using System.Net ;
using System.Net.Http ;
2023-11-10 00:29:16 -06:00
using System.Text ;
2024-01-10 17:57:00 -06:00
using System.Threading ;
2024-05-20 20:26:46 -05:00
using System.Threading.Tasks ;
2025-09-22 00:29:51 -05:00
using LANCommander.SDK.Abstractions ;
using LANCommander.SDK.Factories ;
2026-07-23 19:47:22 -05:00
using LANCommander.SDK.Plugins ;
using LANCommander.SDK.Plugins.Events ;
2025-10-09 02:16:20 -05:00
using Action = System . Action ;
2023-11-10 00:29:16 -06:00
2024-10-04 23:37:49 -05:00
namespace LANCommander.SDK.Services
2023-11-10 00:29:16 -06:00
{
2025-01-31 00:34:37 -06:00
public class InstallProgress
2024-08-07 01:14:13 -05:00
{
public Game Game { get ; set ; }
2025-02-04 02:34:31 -06:00
public string Title { get ; set ; }
2025-01-29 22:52:27 -06:00
public Guid IconId { get ; set ; }
2025-01-31 00:34:37 -06:00
public InstallStatus Status { get ; set ; }
2025-02-16 15:47:57 -06:00
public bool Indeterminate { get ; set ; }
2024-08-07 01:14:13 -05:00
public float Progress
{
get
{
2025-02-16 15:48:34 -06:00
return BytesTransferred / ( float ) TotalBytes ;
2024-08-07 01:14:13 -05:00
}
set { }
}
2025-01-29 23:02:59 -06:00
public long TransferSpeed { get ; set ; }
2025-02-16 15:48:34 -06:00
public long BytesTransferred { get ; set ; }
2024-08-07 01:14:13 -05:00
public long TotalBytes { get ; set ; }
2025-01-29 22:52:27 -06:00
public TimeSpan TimeRemaining { get ; set ; }
2024-08-07 01:14:13 -05:00
}
2025-05-18 08:31:56 +02:00
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 ;
}
2025-09-24 20:58:23 -05:00
public class GameClient (
ILogger < GameClient > logger ,
2025-09-22 00:29:51 -05:00
ApiRequestFactory apiRequestFactory ,
ProcessExecutionContextFactory processExecutionContextFactory ,
INetworkInformationProvider networkInformationProvider ,
2025-10-06 20:29:34 -05:00
ISettingsProvider settingsProvider ,
2025-09-24 20:58:23 -05:00
IConnectionClient connectionClient ,
RedistributableClient redistributableClient ,
SaveClient saveClient ,
ScriptClient scriptClient ,
ProfileClient profileClient ,
2026-02-10 18:04:49 -06:00
LobbyClient lobbyClient ,
2026-07-23 19:47:22 -05:00
ToolClient toolClient ,
IPluginEventBus pluginEventBus )
2023-11-10 00:29:16 -06:00
{
public delegate void OnArchiveEntryExtractionProgressHandler ( object sender , ArchiveEntryExtractionProgressArgs e ) ;
public event OnArchiveEntryExtractionProgressHandler OnArchiveEntryExtractionProgress ;
2024-01-10 17:54:46 -06:00
public delegate void OnArchiveExtractionProgressHandler ( long position , long length , Game game ) ;
2023-11-10 00:29:16 -06:00
public event OnArchiveExtractionProgressHandler OnArchiveExtractionProgress ;
2025-01-31 00:34:37 -06:00
public delegate void OnInstallProgressUpdateHandler ( InstallProgress e ) ;
public event OnInstallProgressUpdateHandler OnInstallProgressUpdate ;
2024-08-07 01:14:13 -05:00
2026-04-17 22:24:47 -05:00
public delegate void OnTaskProgressHandler ( InstallTaskProgress progress ) ;
public event OnTaskProgressHandler OnTaskProgress ;
2025-08-18 02:47:09 -05:00
private const string PlayerAliasFilename = "PlayerAlias" ;
private const string KeyFilename = "Key" ;
2024-01-19 00:25:38 -06:00
2026-07-21 18:01:34 -05:00
private static readonly TimeSpan ServerNotificationTimeout = TimeSpan . FromSeconds ( 15 ) ;
2025-08-18 02:47:09 -05:00
private TrackableStream _transferStream ;
2026-03-09 22:12:01 -05:00
private IAsyncReader _reader ;
2023-11-12 01:04:05 -06:00
2025-08-18 02:47:09 -05:00
private readonly InstallProgress _installProgress = new ( ) ;
2024-08-07 01:14:13 -05:00
2025-08-18 02:47:09 -05:00
private readonly Dictionary < Guid , CancellationTokenSource > _running = new ( ) ;
2024-08-26 20:18:54 -05:00
2024-05-20 20:26:46 -05:00
public async Task < IEnumerable < Game > > GetAsync ( )
{
2025-09-22 00:29:51 -05:00
return await apiRequestFactory
. Create ( )
. UseAuthenticationToken ( )
. UseVersioning ( )
. UseRoute ( "/api/Games" )
. GetAsync < IEnumerable < Game > > ( ) ;
2024-01-02 02:34:58 -06:00
}
2024-05-20 20:26:46 -05:00
public async Task < Game > GetAsync ( Guid id )
{
2025-09-22 00:29:51 -05:00
return await apiRequestFactory
. Create ( )
. UseAuthenticationToken ( )
. UseVersioning ( )
. UseRoute ( $"/api/Games/{id}" )
. GetAsync < Game > ( ) ;
2024-05-20 20:26:46 -05:00
}
2025-11-29 18:24:27 -06:00
public async Task < Models . Manifest . Game > GetManifestAsync ( Guid id )
2024-01-02 02:34:58 -06:00
{
2025-09-22 00:29:51 -05:00
return await apiRequestFactory
. Create ( )
. UseAuthenticationToken ( )
. UseVersioning ( )
. UseRoute ( $"/api/Games/{id}/Manifest" )
2025-11-29 18:24:27 -06:00
. GetAsync < Models . Manifest . Game > ( ) ;
2024-01-02 02:34:58 -06:00
}
2026-06-30 20:48:13 -05:00
public async Task < Models . Manifest . Game > GetManifestAsync ( Guid id , Guid versionId )
{
return await apiRequestFactory
. Create ( )
. UseAuthenticationToken ( )
. UseVersioning ( )
. UseRoute ( $"/api/Games/{id}/Versions/{versionId}/Manifest" )
. GetAsync < Models . Manifest . Game > ( ) ;
}
public async Task < IEnumerable < Models . GameVersion > > GetVersionsAsync ( Guid id )
{
return await apiRequestFactory
. Create ( )
. UseAuthenticationToken ( )
. UseVersioning ( )
. UseRoute ( $"/api/Games/{id}/Versions" )
. GetAsync < IEnumerable < Models . GameVersion > > ( ) ;
}
2025-11-29 18:24:27 -06:00
public async Task < ICollection < Models . Manifest . Game > > GetManifestsAsync ( string installDirectory , Guid id )
2024-09-05 00:48:04 -05:00
{
2025-11-29 18:24:27 -06:00
var manifests = new List < Models . Manifest . Game > ( ) ;
var mainManifest = await ManifestHelper . ReadAsync < Models . Manifest . Game > ( installDirectory , id ) ;
2024-09-05 00:48:04 -05:00
if ( mainManifest = = null )
return manifests ;
manifests . Add ( mainManifest ) ;
2025-11-29 18:24:27 -06:00
if ( mainManifest . Addons ! = null )
2024-09-05 00:48:04 -05:00
{
2025-11-29 18:24:27 -06:00
foreach ( var addon in mainManifest . Addons )
2024-09-05 00:48:04 -05:00
{
try
{
2025-11-29 18:24:27 -06:00
if ( ManifestHelper . Exists ( installDirectory , addon . Id ) )
2025-05-18 04:11:54 +02:00
{
2025-11-29 18:24:27 -06:00
var addonManifest = await ManifestHelper . ReadAsync < SDK . Models . Manifest . Game > ( installDirectory , addon . Id ) ;
2024-09-05 00:48:04 -05:00
2025-11-29 18:24:27 -06:00
if ( addonManifest ? . Type = = GameType . Expansion | | addonManifest ? . Type = = GameType . Mod )
manifests . Add ( addon ) ;
2025-05-18 03:09:22 +02:00
}
2024-09-05 00:48:04 -05:00
}
catch ( Exception ex )
{
2025-11-29 18:24:27 -06:00
logger ? . LogError ( ex , $"Could not load manifest from dependent game {addon.Id}" ) ;
2024-09-05 00:48:04 -05:00
}
}
}
return manifests ;
}
2025-11-29 18:24:27 -06:00
public async Task < IEnumerable < Models . Manifest . Action > > GetActionsAsync ( string installDirectory , Guid id )
2024-09-05 00:48:04 -05:00
{
2025-11-29 18:24:27 -06:00
var actions = new List < Models . Manifest . Action > ( ) ;
2024-09-05 00:48:04 -05:00
2026-03-19 23:57:05 -05:00
var manifests = await GetManifestsAsync ( installDirectory , id ) ;
var installedIds = manifests . Select ( m = > m . Id ) . ToHashSet ( ) ;
2025-01-25 01:34:31 -06:00
try
2024-09-05 00:48:04 -05:00
{
2026-03-08 14:39:15 -05:00
if ( connectionClient . IsConnected ( ) & & ! connectionClient . IsOfflineMode ( ) )
2025-09-22 00:29:51 -05:00
{
2026-03-08 14:39:15 -05:00
using var cts = new CancellationTokenSource ( TimeSpan . FromSeconds ( 5 ) ) ;
2026-03-19 23:57:05 -05:00
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 ,
2026-07-08 02:15:54 -05:00
Variables = a . Variables ,
Platforms = a . Platforms
2026-03-19 23:57:05 -05:00
} ) ) ;
2025-09-22 00:29:51 -05:00
}
2024-09-05 00:48:04 -05:00
}
2025-01-25 01:34:31 -06:00
catch ( Exception ex )
2024-09-05 00:48:04 -05:00
{
2025-09-22 00:29:51 -05:00
logger ? . LogError ( ex , "Could not get actions from server" ) ;
2025-01-25 01:34:31 -06:00
}
2024-09-05 00:48:04 -05:00
2025-01-25 01:34:31 -06:00
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 ( ) ;
2024-09-05 00:48:04 -05:00
}
2026-03-19 23:57:05 -05:00
2026-06-21 21:16:36 -05:00
// Merge in actions from tools that are actually installed. Tool actions are persisted to
// the game's install directory (its manifest) only when the tool is installed, so the
// presence of the tool manifest on disk gates whether its actions appear.
var mainManifest = manifests . FirstOrDefault ( m = > m . Id = = id ) ;
if ( mainManifest ? . Tools ! = null )
{
foreach ( var tool in mainManifest . Tools )
{
if ( ! ManifestHelper . Exists ( installDirectory , tool . Id ) )
continue ;
try
{
var toolManifest = await ManifestHelper . ReadAsync < Models . Manifest . Tool > ( installDirectory , tool . Id ) ;
if ( toolManifest ? . Actions ! = null )
actions . AddRange ( toolManifest . Actions ) ;
}
catch ( Exception ex )
{
logger ? . LogError ( ex , "Could not load actions from installed tool {ToolId}" , tool . Id ) ;
}
}
}
2026-04-03 19:23:30 -05:00
if ( manifests . Any ( m = > m . MultiplayerModes ? . Any ( m = > m . NetworkProtocol = = NetworkProtocol . Lobby ) ? ? false ) )
2024-09-05 00:48:04 -05:00
{
2026-04-03 19:23:30 -05:00
var primaryAction = actions . First ( a = > a . IsPrimaryAction ) ;
2026-03-19 23:57:05 -05:00
2024-11-03 16:19:39 -06:00
try
2024-09-05 00:48:04 -05:00
{
2025-09-24 20:58:23 -05:00
var lobbies = lobbyClient . GetSteamLobbies ( installDirectory , id ) ;
2024-11-03 16:19:39 -06:00
foreach ( var lobby in lobbies )
2024-09-05 00:48:04 -05:00
{
2026-03-13 01:38:58 -05:00
var lobbyAction = new Models . Manifest . Action
2024-11-03 16:19:39 -06:00
{
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 )
{
2025-09-22 00:29:51 -05:00
logger ? . LogError ( ex , "Could not get lobbies" ) ;
2024-09-05 00:48:04 -05:00
}
}
2026-07-08 02:15:54 -05:00
// Only surface actions that support the runtime the launcher is currently running on.
actions = actions
. Where ( a = > EnvironmentHelper . SupportsCurrentRuntime ( a . Platforms ) )
. ToList ( ) ;
2024-09-05 00:48:04 -05:00
return actions ;
}
2026-03-11 19:36:24 -05:00
2025-01-27 01:25:32 -06:00
public async Task < IEnumerable < Game > > GetAddonsAsync ( Guid id )
{
2025-09-22 00:29:51 -05:00
return await apiRequestFactory
. Create ( )
. UseAuthenticationToken ( )
. UseVersioning ( )
. UseRoute ( $"/api/Games/{id}/Addons" )
. GetAsync < IEnumerable < Game > > ( ) ;
2025-01-27 01:25:32 -06:00
}
2026-02-10 18:04:49 -06:00
public async Task < IEnumerable < Tool > > GetToolsAsync ( Guid id )
{
return await apiRequestFactory
. Create ( )
. UseAuthenticationToken ( )
. UseVersioning ( )
. UseRoute ( $"/api/Games/{id}/Tools" )
. GetAsync < IEnumerable < Tool > > ( ) ;
}
2026-03-11 19:36:24 -05:00
public async Task < IEnumerable < Script > > GetScriptsAsync ( Guid id )
{
return await apiRequestFactory
. Create ( )
. UseAuthenticationToken ( )
. UseVersioning ( )
. UseRoute ( $"/api/Games/{id}/Scripts" )
. GetAsync < IEnumerable < Script > > ( ) ;
}
2026-06-30 20:48:13 -05:00
public async Task < IEnumerable < Script > > GetScriptsAsync ( Guid id , Guid versionId )
{
return await apiRequestFactory
. Create ( )
. UseAuthenticationToken ( )
. UseVersioning ( )
. UseRoute ( $"/api/Games/{id}/Versions/{versionId}/Scripts" )
. GetAsync < IEnumerable < Script > > ( ) ;
}
2025-01-25 01:48:37 -06:00
public async Task < bool > CheckForUpdateAsync ( Guid id , string currentVersion )
{
2025-09-22 00:29:51 -05:00
return await apiRequestFactory
. Create ( )
. UseAuthenticationToken ( )
. UseVersioning ( )
. UseRoute ( $"/api/Games/{id}/CheckForUpdate?version={currentVersion}" )
. GetAsync < bool > ( ) ;
2025-01-25 01:48:37 -06:00
}
2026-06-08 19:00:56 -05:00
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 ;
}
2025-11-20 22:05:02 -06:00
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 )
2024-01-02 02:34:58 -06:00
{
2025-09-22 00:29:51 -05:00
return await apiRequestFactory
. Create ( )
. UseAuthenticationToken ( )
. UseVersioning ( )
. UseRoute ( $"/api/Games/{id}/Download" )
. StreamAsync ( ) ;
2024-01-02 02:34:58 -06:00
}
2025-02-23 15:39:06 -06:00
public async Task StartedAsync ( Guid id )
2024-07-08 19:51:17 -05:00
{
2025-09-24 20:58:23 -05:00
if ( ! connectionClient . IsConnected ( ) )
return ;
2025-09-22 00:29:51 -05:00
logger ? . LogTrace ( "Signaling to the server that we started the game..." ) ;
2024-07-08 19:51:17 -05:00
2026-07-21 18:01:34 -05:00
using var timeout = new CancellationTokenSource ( ServerNotificationTimeout ) ;
2025-02-23 15:39:06 -06:00
try
{
2025-09-22 00:29:51 -05:00
await apiRequestFactory
. Create ( )
. UseAuthenticationToken ( )
. UseVersioning ( )
. UseRoute ( $"/api/Games/{id}/Started" )
2026-07-21 18:01:34 -05:00
. UseCancellationToken ( timeout . Token )
2025-09-22 00:29:51 -05:00
. GetAsync < object > ( ) ;
2025-02-23 15:39:06 -06:00
}
catch ( Exception ex )
{
2025-09-22 00:29:51 -05:00
logger ? . LogError ( ex , "Failed sending start request to server" ) ;
2025-02-23 15:39:06 -06:00
}
2024-07-08 19:51:17 -05:00
}
2025-02-23 15:39:06 -06:00
public async Task StoppedAsync ( Guid id )
2024-07-08 19:51:17 -05:00
{
2025-09-24 20:58:23 -05:00
if ( ! connectionClient . IsConnected ( ) )
return ;
2025-09-22 00:29:51 -05:00
logger ? . LogTrace ( "Signaling to the server that we stopped the game..." ) ;
2024-07-08 19:51:17 -05:00
2026-07-21 18:01:34 -05:00
using var timeout = new CancellationTokenSource ( ServerNotificationTimeout ) ;
2024-10-02 12:28:46 -05:00
try
2026-07-21 18:01:34 -05:00
{
2025-09-22 00:29:51 -05:00
await apiRequestFactory
. Create ( )
. UseAuthenticationToken ( )
. UseVersioning ( )
. UseRoute ( $"/api/Games/{id}/Stopped" )
2026-07-21 18:01:34 -05:00
. UseCancellationToken ( timeout . Token )
2025-09-22 00:29:51 -05:00
. GetAsync < object > ( ) ;
2024-10-02 12:28:46 -05:00
}
catch ( Exception ex )
{
2025-09-22 00:29:51 -05:00
logger ? . LogError ( ex , "Failed sending stop request to server" ) ;
2024-10-02 12:28:46 -05:00
}
2024-01-02 02:34:58 -06:00
}
2024-07-15 18:17:04 -05:00
public async Task < string > GetAllocatedKeyAsync ( Guid id )
{
2025-09-22 00:29:51 -05:00
logger ? . LogTrace ( "Requesting allocated key..." ) ;
2024-07-15 18:17:04 -05:00
var request = new KeyRequest ( )
{
GameId = id ,
2025-09-22 00:29:51 -05:00
MacAddress = networkInformationProvider . GetMacAddress ( ) ,
2024-07-15 18:17:04 -05:00
ComputerName = Environment . MachineName ,
2025-09-22 00:29:51 -05:00
IpAddress = networkInformationProvider . GetIpAddress ( ) ,
2024-07-15 18:17:04 -05:00
} ;
2025-09-22 00:29:51 -05:00
var response = await apiRequestFactory
. Create ( )
. UseAuthenticationToken ( )
. UseVersioning ( )
. UseRoute ( $"/api/Keys/GetAllocated/{id}" )
. AddBody ( request )
. PostAsync < Key > ( ) ;
2024-07-15 18:17:04 -05:00
if ( response = = null )
2024-10-04 23:37:49 -05:00
return string . Empty ;
2024-07-15 18:17:04 -05:00
return response . Value ;
}
2025-09-22 00:29:51 -05:00
public async Task < string > GetNewKey ( Guid id )
2024-01-02 02:34:58 -06:00
{
2025-09-22 00:29:51 -05:00
logger ? . LogTrace ( "Requesting new key allocation..." ) ;
2024-01-02 02:34:58 -06:00
var request = new KeyRequest ( )
{
GameId = id ,
2025-09-22 00:29:51 -05:00
MacAddress = networkInformationProvider . GetMacAddress ( ) ,
2024-01-02 02:34:58 -06:00
ComputerName = Environment . MachineName ,
2025-09-22 00:29:51 -05:00
IpAddress = networkInformationProvider . GetIpAddress ( ) ,
2024-01-02 02:34:58 -06:00
} ;
2025-09-22 00:29:51 -05:00
var response = await apiRequestFactory
. Create ( )
. UseAuthenticationToken ( )
. UseVersioning ( )
. UseRoute ( $"/api/Keys/Allocate/{id}" )
. AddBody ( request )
. PostAsync < Key > ( ) ;
2024-01-02 02:34:58 -06:00
if ( response = = null )
2024-10-04 23:37:49 -05:00
return string . Empty ;
2024-01-02 02:34:58 -06:00
return response . Value ;
}
2023-11-10 00:29:16 -06:00
/// <summary>
/// Downloads, extracts, and runs post-install scripts for the specified game
/// </summary>
2025-05-18 08:31:56 +02:00
/// <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>
2023-11-10 00:29:16 -06:00
/// <param name="maxAttempts">Maximum attempts in case of transmission error</param>
2025-05-18 08:31:56 +02:00
/// <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>
2025-10-15 21:26:40 -05:00
public async Task < InstallResult > InstallAsync ( Guid gameId , string installDirectory = "" , Guid [ ] addonIds = null , int maxAttempts = 10 , CancellationToken cancellationToken = default )
2023-11-10 00:29:16 -06:00
{
2025-05-18 08:31:56 +02:00
var installResult = new InstallResult ( installDirectory , gameId ) ;
var gameFileList = installResult . FileList ;
2025-11-29 18:24:27 -06:00
SDK . Models . Manifest . Game manifest = null ;
2023-11-17 11:48:45 -06:00
2024-10-04 23:37:49 -05:00
if ( string . IsNullOrWhiteSpace ( installDirectory ) )
2025-10-06 20:29:34 -05:00
installDirectory = settingsProvider . CurrentValue . Games . InstallDirectories . First ( ) ;
2024-09-12 19:09:13 -05:00
2025-09-22 00:29:51 -05:00
var game = await GetAsync ( gameId ) ;
2024-11-12 02:17:26 -06:00
var destination = await GetInstallDirectory ( game , installDirectory ) ;
2023-11-10 00:29:16 -06:00
2025-01-31 00:34:37 -06:00
_installProgress . Game = game ;
2025-02-04 02:34:31 -06:00
_installProgress . Title = game . Title ;
2025-01-31 00:34:37 -06:00
_installProgress . Status = InstallStatus . Downloading ;
_installProgress . Progress = 0 ;
_installProgress . TransferSpeed = 0 ;
2025-02-04 02:34:31 -06:00
_installProgress . TotalBytes = 0 ;
2025-02-16 15:48:34 -06:00
_installProgress . BytesTransferred = 0 ;
2024-08-07 01:14:13 -05:00
2025-01-31 00:34:37 -06:00
OnInstallProgressUpdate ? . Invoke ( _installProgress ) ;
2024-08-07 01:14:13 -05:00
2024-05-01 23:58:07 -05:00
// Handle Standalone Mods
2024-11-12 02:17:26 -06:00
if ( game . Type = = GameType . StandaloneMod & & game . BaseGameId ! = Guid . Empty )
2024-01-08 18:52:00 -06:00
{
2025-09-22 00:29:51 -05:00
var baseGame = await GetAsync ( game . BaseGameId ) ;
2024-11-12 02:17:26 -06:00
destination = await GetInstallDirectory ( baseGame , installDirectory ) ;
2024-01-08 18:52:00 -06:00
if ( ! Directory . Exists ( destination ) )
2025-05-18 08:31:56 +02:00
{
2025-10-15 21:26:40 -05:00
var baseGameFileList = await InstallAsync ( game . BaseGameId , installDirectory , null , maxAttempts , cancellationToken ) ;
2025-05-18 08:31:56 +02:00
destination = installResult . InstallDirectory ;
}
2024-01-08 18:52:00 -06:00
}
2023-11-17 11:48:45 -06:00
2023-11-20 18:20:34 -06:00
try
{
2024-01-09 01:22:56 -06:00
if ( ManifestHelper . Exists ( destination , game . Id ) )
2025-11-29 18:24:27 -06:00
manifest = await ManifestHelper . ReadAsync < SDK . Models . Manifest . Game > ( destination , game . Id ) ;
2023-11-20 18:20:34 -06:00
}
catch ( Exception ex )
{
2025-09-22 00:29:51 -05:00
logger ? . LogTrace ( ex , "Error reading manifest before install" ) ;
2023-11-20 18:20:34 -06:00
}
2023-11-10 00:29:16 -06:00
2025-09-22 00:29:51 -05:00
logger ? . LogTrace ( "Installing game {GameTitle} ({GameId})" , game . Title , game . Id ) ;
2023-11-10 00:29:16 -06:00
2024-05-01 23:58:07 -05:00
// Download and extract
2024-10-04 23:37:49 -05:00
var result = await RetryHelper . RetryOnExceptionAsync ( maxAttempts , TimeSpan . FromMilliseconds ( 500 ) , new ExtractionResult ( ) , async ( ) = >
2024-01-10 17:57:00 -06:00
{
2025-09-22 00:29:51 -05:00
logger ? . LogTrace ( "Attempting to download and extract game" ) ;
2023-11-10 00:29:16 -06:00
2025-10-15 21:26:40 -05:00
return await Task . Run ( async ( ) = > await DownloadAndExtractAsync ( game , destination , cancellationToken ) ) ;
2024-01-10 17:57:00 -06:00
} ) ;
2023-11-10 00:29:16 -06:00
2024-01-10 17:57:00 -06:00
if ( ! result . Success & & ! result . Canceled )
2024-01-24 23:36:52 -06:00
throw new InstallException ( "Could not extract the installer. Retry the install or check your connection" ) ;
2024-01-10 17:57:00 -06:00
else if ( result . Canceled )
2024-01-25 00:46:10 -06:00
throw new InstallCanceledException ( "Game install was canceled" ) ;
2023-11-10 00:29:16 -06:00
2024-01-10 17:57:00 -06:00
game . InstallDirectory = result . Directory ;
2025-05-18 08:31:56 +02:00
installResult . InstallDirectory = result . Directory ;
2023-11-10 00:29:16 -06:00
2024-05-01 23:58:07 -05:00
// Game is extracted, get metadata
2025-01-30 23:57:12 -06:00
var writeManifestSuccess = await RetryHelper . RetryOnExceptionAsync ( maxAttempts , TimeSpan . FromSeconds ( 1 ) , false , async ( ) = >
2023-11-10 00:29:16 -06:00
{
2025-09-22 00:29:51 -05:00
logger ? . LogTrace ( "Attempting to get game manifest" ) ;
2025-05-17 02:02:57 +02:00
manifest = await WriteManifestAsync ( game . InstallDirectory , game ) ;
2023-11-10 00:29:16 -06:00
return true ;
} ) ;
if ( ! writeManifestSuccess )
2024-01-24 23:36:52 -06:00
throw new InstallException ( "Could not grab the manifest file. Retry the install or check your connection" ) ;
2023-11-10 00:29:16 -06:00
2025-05-17 02:02:57 +02:00
// store scripts locally
await WriteScriptsAsync ( game . InstallDirectory , game ) ;
2023-11-10 00:29:16 -06:00
2025-05-18 08:31:56 +02:00
// 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
2024-04-29 18:33:31 -05:00
{
2025-05-18 08:31:56 +02:00
EntryPath = x . EntryPath ,
LocalPath = x . LocalPath ,
} ) ;
gameFileList . BaseGame . AddFiles ( gameFiles ? ? [ ] ) ;
2023-11-10 00:29:16 -06:00
2025-01-31 00:34:37 -06:00
_installProgress . Progress = 1 ;
2025-02-16 15:48:34 -06:00
_installProgress . BytesTransferred = _installProgress . TotalBytes ;
2025-01-31 00:34:37 -06:00
_installProgress . Status = InstallStatus . InstallingRedistributables ;
2024-08-07 01:14:13 -05:00
2025-01-31 00:34:37 -06:00
OnInstallProgressUpdate ? . Invoke ( _installProgress ) ;
2024-08-07 01:14:13 -05:00
#region Install Redistributables
if ( game . Redistributables ! = null & & game . Redistributables . Any ( ) )
{
2025-09-22 00:29:51 -05:00
logger ? . LogTrace ( "Installing redistributables" ) ;
2024-08-07 01:14:13 -05:00
2025-09-24 20:58:23 -05:00
await redistributableClient . InstallAsync ( game ) ;
2024-08-07 01:14:13 -05:00
}
#endregion
#region Download Latest Save
2026-05-25 04:16:19 -05:00
logger ? . LogInformation ( "Downloading latest save for game {GameTitle} ({GameId}) during install" , game . Title , game . Id ) ;
2024-08-07 01:14:13 -05:00
2025-01-31 00:34:37 -06:00
_installProgress . Status = InstallStatus . DownloadingSaves ;
2024-08-07 01:14:13 -05:00
2025-01-31 00:34:37 -06:00
OnInstallProgressUpdate ? . Invoke ( _installProgress ) ;
2024-08-07 01:14:13 -05:00
2025-09-24 20:58:23 -05:00
await saveClient . DownloadAsync ( game . InstallDirectory , game . Id ) ;
2024-08-07 01:14:13 -05:00
#endregion
2024-10-26 15:48:47 -05:00
await RunPostInstallScripts ( game ) ;
2024-08-07 01:14:13 -05:00
2024-10-26 15:48:47 -05:00
if ( addonIds ! = null )
2025-05-18 08:31:56 +02:00
{
var addonsResult = await InstallAddonsAsync ( installDirectory , game , addonIds ) ;
gameFileList . MergeDependentGames ( addonsResult . FileList ) ;
}
2024-08-07 01:14:13 -05:00
2025-01-31 00:34:37 -06:00
_installProgress . Status = InstallStatus . Complete ;
_installProgress . Progress = 1 ;
2025-02-16 15:48:34 -06:00
_installProgress . BytesTransferred = _installProgress . TotalBytes ;
2024-08-07 01:14:13 -05:00
2025-01-31 00:34:37 -06:00
OnInstallProgressUpdate ? . Invoke ( _installProgress ) ;
2024-08-07 01:14:13 -05:00
2025-05-18 08:31:56 +02:00
return installResult ;
2024-10-26 15:48:47 -05:00
}
2024-08-07 01:14:13 -05:00
2025-05-18 08:31:56 +02:00
public async Task < InstallResult > InstallAddonsAsync ( string installDirectory , Guid baseGameId , IEnumerable < Guid > addonIds )
2024-10-26 15:48:47 -05:00
{
2025-09-22 00:29:51 -05:00
var game = await GetAsync ( baseGameId ) ;
2024-08-07 01:14:13 -05:00
2025-05-18 08:31:56 +02:00
return await InstallAddonsAsync ( installDirectory , game , addonIds ) ;
2024-10-26 15:48:47 -05:00
}
2024-08-07 01:14:13 -05:00
2025-05-18 08:31:56 +02:00
public async Task < InstallResult > InstallAddonsAsync ( string installDirectory , Game game , IEnumerable < Guid > addonIds )
2024-10-26 15:48:47 -05:00
{
2025-05-18 08:31:56 +02:00
var installResult = new InstallResult ( installDirectory , game . Id ) ;
var gameFileList = installResult . FileList ;
2024-11-15 23:42:17 -06:00
if ( addonIds ! = null )
2024-10-26 15:48:47 -05:00
{
2025-01-29 22:52:27 -06:00
var addons = new List < Game > ( ) ;
2024-11-15 23:42:17 -06:00
foreach ( var addonId in addonIds )
{
2025-01-29 22:52:27 -06:00
try
{
2025-09-22 00:29:51 -05:00
addons . Add ( await GetAsync ( addonId ) ) ;
2025-01-29 22:52:27 -06:00
}
catch ( Exception ex )
{
2025-09-22 00:29:51 -05:00
logger ? . LogError ( ex , "Could not get information for addon with ID {AddonId}, skipping install" , addonId ) ;
2025-01-29 22:52:27 -06:00
}
}
var expansions = addons . Where ( a = > a ? . Type = = GameType . Expansion ) . ToList ( ) ;
foreach ( var expansion in expansions )
{
try
{
2025-01-31 00:34:37 -06:00
_installProgress . Status = InstallStatus . Downloading ;
_installProgress . Game = expansion ;
_installProgress . Progress = 0 ;
2025-02-16 15:48:34 -06:00
_installProgress . BytesTransferred = 0 ;
2025-01-31 00:34:37 -06:00
_installProgress . TotalBytes = 1 ;
2025-02-16 15:48:34 -06:00
_installProgress . BytesTransferred = 0 ;
2025-01-31 00:34:37 -06:00
OnInstallProgressUpdate ? . Invoke ( _installProgress ) ;
2025-05-18 08:31:56 +02:00
var expansionResult = await InstallAddonAsync ( installDirectory , expansion ) ;
gameFileList . MergeBaseAsDependentGame ( expansion . Id , expansionResult . FileList ) ;
2025-01-29 22:52:27 -06:00
}
catch ( Exception ex )
{
2025-09-22 00:29:51 -05:00
logger ? . LogError ( ex , "Could not install expansion with ID {AddonId}" , expansion . Id ) ;
2025-01-29 22:52:27 -06:00
}
}
var mods = addons . Where ( a = > a ? . Type = = GameType . Mod ) . ToList ( ) ;
foreach ( var mod in mods )
{
try
{
2025-01-31 00:34:37 -06:00
_installProgress . Status = InstallStatus . Downloading ;
_installProgress . Game = mod ;
_installProgress . Progress = 0 ;
2025-02-16 15:48:34 -06:00
_installProgress . BytesTransferred = 0 ;
2025-01-31 00:34:37 -06:00
_installProgress . TotalBytes = 1 ;
2025-02-16 15:48:34 -06:00
_installProgress . BytesTransferred = 0 ;
2025-01-31 00:34:37 -06:00
OnInstallProgressUpdate ? . Invoke ( _installProgress ) ;
2025-05-18 08:31:56 +02:00
var modResult = await InstallAddonAsync ( installDirectory , mod ) ;
gameFileList . MergeBaseAsDependentGame ( mod . Id , modResult . FileList ) ;
2025-01-29 22:52:27 -06:00
}
catch ( Exception ex )
{
2025-09-22 00:29:51 -05:00
logger ? . LogError ( ex , "Could not install mod with ID {AddonId}" , mod . Id ) ;
2025-01-29 22:52:27 -06:00
}
2024-11-15 23:42:17 -06:00
}
2024-10-26 15:48:47 -05:00
}
2025-05-18 08:31:56 +02:00
return installResult ;
2024-10-26 15:48:47 -05:00
}
2024-08-07 01:14:13 -05:00
2025-05-18 08:31:56 +02:00
public async Task < InstallResult > InstallAddonAsync ( string installDirectory , Game addon )
2024-10-26 15:48:47 -05:00
{
2025-05-18 08:31:56 +02:00
var installResult = new InstallResult ( installDirectory , addon . Id ) ;
var gameFileList = installResult . FileList ;
2024-11-12 02:17:26 -06:00
if ( ! addon . IsAddon )
2025-05-18 08:31:56 +02:00
return installResult ;
2024-08-07 01:14:13 -05:00
2025-01-31 00:34:37 -06:00
OnInstallProgressUpdate ? . Invoke ( _installProgress ) ;
2024-08-07 01:14:13 -05:00
2024-10-26 15:48:47 -05:00
try
{
2025-05-18 08:31:56 +02:00
var addonResult = await InstallAsync ( addon . Id , installDirectory ) ;
gameFileList . Merge ( addonResult . FileList ) ;
2024-10-26 15:48:47 -05:00
}
catch ( InstallCanceledException ex )
{
2025-09-22 00:29:51 -05:00
logger ? . LogDebug ( "Install canceled" ) ;
2024-08-07 01:14:13 -05:00
2025-01-31 00:34:37 -06:00
_installProgress . Status = InstallStatus . Canceled ;
OnInstallProgressUpdate ? . Invoke ( _installProgress ) ;
2024-08-07 01:14:13 -05:00
2024-10-26 15:48:47 -05:00
throw ;
2024-08-07 01:14:13 -05:00
}
2024-10-26 15:48:47 -05:00
catch ( Exception ex )
{
2025-09-22 00:29:51 -05:00
logger ? . LogError ( ex , "Failed to install addon {AddonTitle} ({AddonId})" , addon . Title , addon . Id ) ;
2024-08-07 01:14:13 -05:00
2025-01-31 00:34:37 -06:00
_installProgress . Status = InstallStatus . Failed ;
OnInstallProgressUpdate ? . Invoke ( _installProgress ) ;
2024-08-07 01:14:13 -05:00
2024-10-26 15:48:47 -05:00
throw ;
}
2024-08-07 01:14:13 -05:00
2024-10-26 15:48:47 -05:00
await RunPostInstallScripts ( addon ) ;
2025-05-18 08:31:56 +02:00
return installResult ;
2023-11-10 00:29:16 -06:00
}
2026-04-17 22:24:47 -05:00
/// <summary>
/// Generates an install plan for a game, producing a list of queue items and their tasks
/// without executing anything.
/// </summary>
2026-05-14 00:28:31 -05:00
public async Task < InstallPlan > GenerateInstallPlanAsync ( Guid gameId , string installDirectory , Guid [ ] addonIds = null , Guid [ ] toolIds = null )
2026-04-17 22:24:47 -05:00
{
2026-04-17 23:52:22 -05:00
logger ? . LogInformation ( "[InstallQueue] GenerateInstallPlan: gameId={GameId}, installDir={InstallDir}, addonIds={AddonIds}" ,
gameId , installDirectory , addonIds ! = null ? string . Join ( "," , addonIds ) : "none" ) ;
2026-04-17 22:24:47 -05:00
var plan = new InstallPlan ( ) ;
var game = await GetAsync ( gameId ) ;
2026-04-17 23:52:22 -05:00
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 ) ;
2026-04-17 22:24:47 -05:00
if ( string . IsNullOrWhiteSpace ( installDirectory ) )
installDirectory = settingsProvider . CurrentValue . Games . InstallDirectories . First ( ) ;
var destination = await GetInstallDirectory ( game , installDirectory ) ;
2026-04-17 23:52:22 -05:00
logger ? . LogInformation ( "[InstallQueue] GenerateInstallPlan: Resolved install directory to {Destination}" , destination ) ;
2026-04-17 22:24:47 -05:00
// 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 ;
2026-04-18 18:55:28 -05:00
gameItem . Tasks . Add ( new InstallTaskDefinition
{
Type = InstallTaskType . VerifyFiles ,
Title = "Verify local files" ,
Order = taskOrder + + ,
TargetId = game . Id ,
TargetName = game . Title ,
IsCritical = false ,
} ) ;
2026-04-17 22:24:47 -05:00
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 ,
} ) ;
}
2026-04-22 01:54:07 -05:00
if ( game . Media ! = null & & game . Media . Any ( m = > m . Type = = MediaType . Manual ) )
2026-04-17 22:24:47 -05:00
{
2026-04-22 01:54:07 -05:00
var manualIds = game . Media
. Where ( m = > m . Type = = MediaType . Manual )
. Select ( m = > m . Id . ToString ( ) ) ;
gameItem . Tasks . Add ( new InstallTaskDefinition
2026-04-17 22:24:47 -05:00
{
2026-04-22 01:54:07 -05:00
Type = InstallTaskType . DownloadManual ,
Title = "Download manuals" ,
Order = taskOrder + + ,
TargetId = game . Id ,
TargetName = game . Title ,
IsCritical = false ,
Parameters = new Dictionary < string , string >
2026-04-17 22:24:47 -05:00
{
2026-04-22 01:54:07 -05:00
["ManualIds"] = string . Join ( "," , manualIds ) ,
} ,
} ) ;
2026-04-17 22:24:47 -05:00
}
plan . Items . Add ( gameItem ) ;
// Addon items
if ( addonIds ! = null )
{
foreach ( var addonId in addonIds )
{
var addon = await GetAsync ( addonId ) ;
var addonItem = new InstallPlanItem
{
EntityId = addon . Id ,
Title = addon . Title ,
Type = InstallPlanItemType . Addon ,
InstallDirectory = destination ,
Order = plan . Items . Count ,
DependsOnId = game . Id ,
} ;
int addonTaskOrder = 0 ;
addonItem . Tasks . Add ( new InstallTaskDefinition
{
Type = InstallTaskType . DownloadAndExtract ,
Title = $"Download {addon.Title}" ,
Order = addonTaskOrder + + ,
TargetId = addon . Id ,
TargetName = addon . Title ,
IsCritical = true ,
ReportsProgress = true ,
} ) ;
addonItem . Tasks . Add ( new InstallTaskDefinition
{
Type = InstallTaskType . WriteManifest ,
Title = "Write manifest" ,
Order = addonTaskOrder + + ,
TargetId = addon . Id ,
TargetName = addon . Title ,
IsCritical = true ,
} ) ;
addonItem . Tasks . Add ( new InstallTaskDefinition
{
Type = InstallTaskType . WriteScripts ,
Title = "Save scripts" ,
Order = addonTaskOrder + + ,
TargetId = addon . Id ,
TargetName = addon . Title ,
IsCritical = false ,
} ) ;
if ( addon . Scripts ! = null & & addon . Scripts . Any ( ) )
{
addonItem . Tasks . Add ( new InstallTaskDefinition
{
Type = InstallTaskType . RunInstallScript ,
Title = "Run install script" ,
Order = addonTaskOrder + + ,
TargetId = addon . Id ,
TargetName = addon . Title ,
IsCritical = false ,
} ) ;
addonItem . Tasks . Add ( new InstallTaskDefinition
{
Type = InstallTaskType . RunKeyChangeScript ,
Title = "Apply key" ,
Order = addonTaskOrder + + ,
TargetId = addon . Id ,
TargetName = addon . Title ,
IsCritical = false ,
} ) ;
addonItem . Tasks . Add ( new InstallTaskDefinition
{
Type = InstallTaskType . RunNameChangeScript ,
Title = "Apply player name" ,
Order = addonTaskOrder + + ,
TargetId = addon . Id ,
TargetName = addon . Title ,
IsCritical = false ,
} ) ;
}
plan . Items . Add ( addonItem ) ;
}
}
2026-07-02 20:41:18 -05:00
// Tool items
var toolIdSet = new HashSet < Guid > ( toolIds ? ? Array . Empty < Guid > ( ) ) ;
// Always-install tools are installed alongside the game regardless of user selection
try
{
var gameTools = await GetToolsAsync ( game . Id ) ;
if ( gameTools ! = null )
{
foreach ( var alwaysInstallTool in gameTools . Where ( t = > t . AlwaysInstall ) )
toolIdSet . Add ( alwaysInstallTool . Id ) ;
}
}
catch ( Exception ex )
{
logger ? . LogWarning ( ex , "[InstallQueue] GenerateInstallPlan: Could not resolve always-install tools for game {GameId}" , game . Id ) ;
}
foreach ( var toolId in toolIdSet )
{
var tool = await toolClient . GetAsync ( toolId ) ;
var toolPlan = await toolClient . GenerateInstallPlanAsync ( tool , destination ) ;
foreach ( var toolPlanItem in toolPlan . Items )
{
toolPlanItem . Order = plan . Items . Count ;
toolPlanItem . DependsOnId = game . Id ;
plan . Items . Add ( toolPlanItem ) ;
}
}
// Redistributable items
if ( game . Redistributables ! = null )
{
foreach ( var redist in game . Redistributables )
{
var redistItem = new InstallPlanItem
{
EntityId = redist . Id ,
Title = redist . Name ,
Type = InstallPlanItemType . Redistributable ,
InstallDirectory = destination ,
Order = plan . Items . Count ,
DependsOnId = game . Id ,
} ;
redistItem . Tasks . Add ( new InstallTaskDefinition
{
Type = InstallTaskType . DownloadAndExtract ,
Title = $"Download {redist.Name}" ,
Order = 0 ,
TargetId = redist . Id ,
TargetName = redist . Name ,
IsCritical = true ,
ReportsProgress = true ,
Parameters = new Dictionary < 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 ) ;
}
}
2026-04-17 22:24:47 -05:00
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 )
{
2026-04-17 23:52:22 -05:00
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 ) ;
2026-04-17 22:24:47 -05:00
var game = await GetAsync ( planItem . EntityId ) ;
2026-04-17 23:52:22 -05:00
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}" ) ;
}
2026-04-18 18:55:28 -05:00
// Set the progress context so OnInstallProgressUpdate events carry the game reference
_installProgress . Game = game ;
_installProgress . Title = game . Title ;
2026-04-17 22:24:47 -05:00
var gameFileList = installResult . FileList ;
SDK . Models . Manifest . Game manifest = null ;
2026-04-18 18:55:28 -05:00
// Files confirmed to exist locally and match FileList.txt — skip during extraction
HashSet < string > verifiedFiles = null ;
2026-04-17 22:24:47 -05:00
foreach ( var taskDef in planItem . Tasks . OrderBy ( t = > t . Order ) )
{
cancellationToken . ThrowIfCancellationRequested ( ) ;
2026-04-17 23:52:22 -05:00
logger ? . LogInformation ( "[InstallQueue] ExecuteGamePlanItem: Running task [{Order}] {Type}: {Title} (critical={IsCritical})" ,
taskDef . Order , taskDef . Type , taskDef . Title , taskDef . IsCritical ) ;
2026-04-17 22:24:47 -05:00
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 )
{
2026-04-18 18:55:28 -05:00
case InstallTaskType . VerifyFiles :
verifiedFiles = await VerifyLocalFilesAsync ( planItem . InstallDirectory , game . Id , cancellationToken ) ;
logger ? . LogInformation ( "[InstallQueue] VerifyFiles: {Count} files verified as present" , verifiedFiles ? . Count ? ? 0 ) ;
break ;
2026-04-17 22:24:47 -05:00
case InstallTaskType . DownloadAndExtract :
2026-04-18 18:55:28 -05:00
var skipFiles = verifiedFiles ;
2026-06-20 03:33:29 -05:00
var maxAttempts = Math . Max ( 1 , settingsProvider . CurrentValue . Games . MaxInstallAttempts ) ;
var result = await RetryHelper . RetryOnExceptionAsync ( maxAttempts , TimeSpan . FromMilliseconds ( 500 ) , new ExtractionResult ( ) , async ( ) = >
2026-04-17 22:24:47 -05:00
{
2026-04-18 18:55:28 -05:00
return await Task . Run ( async ( ) = > await DownloadAndExtractAsync ( game , planItem . InstallDirectory , cancellationToken , skipFiles ) ) ;
2026-04-17 22:24:47 -05:00
} ) ;
if ( ! result . Success & & ! result . Canceled )
throw new InstallException ( "Could not extract the installer. Retry the install or check your connection" ) ;
2026-05-24 00:06:46 -05:00
if ( result . Canceled )
2026-04-17 22:24:47 -05:00
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 ( ) ;
2026-05-24 00:06:46 -05:00
2026-04-17 22:24:47 -05:00
if ( firstTask = = null )
return ;
// Get parent game context from task parameters
2026-05-24 00:06:46 -05:00
var parentGameId = Guid . Empty ;
2026-04-17 22:24:47 -05:00
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 ) ;
}
}
2025-05-18 08:31:56 +02:00
public async Task < InstallResult > UninstallAsync ( string installDirectory , Guid gameId )
2023-11-10 00:29:16 -06:00
{
2025-05-18 08:31:56 +02:00
var installResult = new InstallResult ( installDirectory , gameId ) ;
var gameFileList = installResult . FileList ;
2025-11-29 18:24:27 -06:00
var manifest = await ManifestHelper . ReadAsync < SDK . Models . Manifest . Game > ( installDirectory , gameId ) ;
2025-05-18 03:09:22 +02:00
if ( manifest = = null )
{
2025-09-22 00:29:51 -05:00
logger ? . LogInformation ( "Unable to read or find manifest for game with ID {GameId}. Skip uninstallation!" , gameId ) ;
2025-05-18 08:31:56 +02:00
return installResult ;
2025-05-18 03:09:22 +02:00
}
2024-08-07 01:47:13 -05:00
2025-05-18 08:31:56 +02:00
// 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 ;
2024-08-07 01:47:13 -05:00
2025-11-29 18:24:27 -06:00
#region Uninstall Addons
if ( manifest . Addons ! = null )
2024-08-07 01:47:13 -05:00
{
2025-11-29 18:24:27 -06:00
foreach ( var addon in manifest . Addons )
2024-08-07 01:47:13 -05:00
{
2025-01-27 00:05:09 -06:00
try
{
2025-11-29 18:24:27 -06:00
if ( ManifestHelper . Exists ( installDirectory , addon . Id ) )
2025-05-18 03:09:22 +02:00
{
2025-11-29 18:24:27 -06:00
var dependentResult = await UninstallAsync ( installDirectory , addon . Id ) ;
2025-05-18 08:31:56 +02:00
gameFileList . MergeDependentGames ( dependentResult . FileList ) ;
2025-05-18 03:09:22 +02:00
}
2025-01-27 00:05:09 -06:00
}
catch ( Exception ex )
{
2025-09-22 00:29:51 -05:00
logger ? . LogWarning ( "Could not uninstall dependent game with ID {GameId}. Assuming it's already uninstalled or never installed..." , gameId ) ;
2025-01-27 00:05:09 -06:00
}
2024-08-07 01:47:13 -05:00
}
}
#endregion
2026-05-23 22:59:56 -05:00
#region Delete Redistributable Files
if ( manifest . Redistributables ! = null )
{
foreach ( var redistributable in manifest . Redistributables )
{
try
{
2026-06-19 20:37:13 -05:00
await scriptClient . Redistributable_RunUninstallScriptAsync ( installDirectory , gameId , redistributable . Id ) ;
2026-05-23 22:59:56 -05:00
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
2026-07-02 20:21:08 -05:00
#region Delete Tool Files
if ( manifest . Tools ! = null )
{
foreach ( var tool in manifest . Tools )
{
try
{
if ( ManifestHelper . Exists ( installDirectory , tool . Id ) )
await toolClient . UninstallAsync ( installDirectory , tool . Id ) ;
}
catch ( Exception ex )
{
logger ? . LogWarning ( ex , "Could not clean up tool {ToolId}" , tool . Id ) ;
}
}
}
#endregion
2024-08-07 01:47:13 -05:00
#region Delete Files
2024-10-04 23:37:49 -05:00
var fileListPath = GetMetadataFilePath ( installDirectory , gameId , "FileList.txt" ) ;
2023-11-10 00:29:16 -06:00
2024-01-15 16:01:15 -06:00
if ( File . Exists ( fileListPath ) )
2024-01-10 01:59:51 -06:00
{
2024-08-07 01:47:13 -05:00
var fileList = await File . ReadAllLinesAsync ( fileListPath ) ;
2025-08-18 02:44:55 -05:00
var files = fileList . Select ( l = > l . Split ( '|' ) . FirstOrDefault ( ) ? . Trim ( ) ) ;
2024-01-10 01:59:51 -06:00
2025-09-22 00:29:51 -05:00
logger ? . LogDebug ( "Attempting to delete the install files" ) ;
2024-01-15 16:01:15 -06:00
2025-08-18 02:44:55 -05:00
foreach ( var file in files . Where ( f = > f ! = null & & ! f . EndsWith ( "/" ) ) )
2024-01-15 16:01:15 -06:00
{
var localPath = Path . Combine ( installDirectory , file ) ;
2025-05-18 08:31:56 +02:00
baseFileList . AddFile ( new GameInstallationFileListEntry . FileEntry
{
EntryPath = file ,
LocalPath = localPath ,
} ) ;
2024-01-15 16:01:15 -06:00
2024-02-03 13:55:40 -06:00
try
{
if ( File . Exists ( localPath ) )
File . Delete ( localPath ) ;
2024-08-07 01:47:13 -05:00
2025-09-22 00:29:51 -05:00
logger ? . LogTrace ( "Deleted file {LocalPath}" , localPath ) ;
2024-02-03 13:55:40 -06:00
}
catch ( Exception ex )
{
2025-09-22 00:29:51 -05:00
logger ? . LogWarning ( ex , "Could not remove file {LocalPath}" , localPath ) ;
2024-02-03 13:55:40 -06:00
}
2024-01-15 16:01:15 -06:00
}
2023-11-10 00:29:16 -06:00
2025-09-22 00:29:51 -05:00
logger ? . LogDebug ( "Attempting to delete any empty directories" ) ;
2024-01-10 01:59:51 -06:00
2024-01-15 16:01:15 -06:00
DirectoryHelper . DeleteEmptyDirectories ( installDirectory ) ;
2024-01-10 01:59:51 -06:00
2024-01-15 16:01:15 -06:00
if ( ! Directory . Exists ( installDirectory ) )
2025-09-22 00:29:51 -05:00
logger ? . LogDebug ( "Deleted install directory {InstallDirectory}" , installDirectory ) ;
2024-01-15 16:01:15 -06:00
else
2025-09-22 00:29:51 -05:00
logger ? . LogTrace ( "Removed game files for {GameTitle} ({GameId})" , manifest . Title , gameId ) ;
2024-01-15 16:01:15 -06:00
}
2024-01-10 01:59:51 -06:00
else
2024-01-15 16:01:15 -06:00
{
Directory . Delete ( installDirectory , true ) ;
}
2024-08-07 01:47:13 -05:00
#endregion
2026-02-10 17:34:35 -06:00
await scriptClient . Game_RunUninstallScriptAsync ( installDirectory , gameId ) ;
2024-08-07 01:47:13 -05:00
#region Cleanup Install Directory
var metadataPath = GetMetadataDirectoryPath ( installDirectory , gameId ) ;
if ( Directory . Exists ( metadataPath ) )
Directory . Delete ( metadataPath , true ) ;
DirectoryHelper . DeleteEmptyDirectories ( installDirectory ) ;
#endregion
2025-05-18 08:31:56 +02:00
return installResult ;
2023-11-10 00:29:16 -06:00
}
2025-05-18 08:31:56 +02:00
public async Task < InstallResult > UninstallAddonsAsync ( string installDirectory , Guid baseGameId , IEnumerable < Guid > addonIds )
2025-05-16 01:04:03 +02:00
{
2025-05-18 08:31:56 +02:00
var installResult = new InstallResult ( installDirectory , baseGameId ) ;
var gameFileList = installResult . FileList ;
2025-11-29 18:24:27 -06:00
var baseManifest = await ManifestHelper . ReadAsync < SDK . Models . Manifest . Game > ( installDirectory , baseGameId ) ;
2026-05-24 00:06:46 -05:00
2025-05-18 08:31:56 +02:00
if ( baseManifest = = null )
{
2025-09-22 00:29:51 -05:00
logger ? . LogInformation ( "Unable to read or find manifest for addon game with ID {GameId}. Skip uninstallation!" , baseGameId ) ;
2025-05-18 08:31:56 +02:00
return installResult ;
}
// store manifest for current addon game, skip any files
gameFileList . BaseGame . Manifest = baseManifest ;
gameFileList . InstallDirectory = installDirectory ;
2025-05-16 01:04:03 +02:00
addonIds ? ? = [ ] ;
2026-05-24 00:06:46 -05:00
2025-11-29 18:24:27 -06:00
foreach ( var addon in baseManifest . Addons )
2025-05-16 01:04:03 +02:00
{
2025-11-29 18:24:27 -06:00
if ( ! addonIds . Contains ( addon . Id ) )
2025-05-16 01:04:03 +02:00
continue ;
try
{
2025-11-29 18:24:27 -06:00
var dependentResult = await UninstallAddonAsync ( installDirectory , addon . Id ) ;
gameFileList . MergeBaseAsDependentGame ( addon . Id , dependentResult . FileList ) ;
2025-05-16 01:04:03 +02:00
}
catch ( Exception ex )
{
2025-11-29 18:24:27 -06:00
logger ? . LogWarning ( ex , $"Could not uninstall dependent game {addon} of base game {baseGameId}. Assuming it's already uninstalled or never installed..." ) ;
2025-05-16 01:04:03 +02:00
}
}
2025-05-18 08:31:56 +02:00
return installResult ;
2025-05-16 01:04:03 +02:00
}
2025-05-18 08:31:56 +02:00
public async Task < InstallResult > UninstallAddonAsync ( string installDirectory , Guid addonGameId )
2025-05-16 01:04:03 +02:00
{
2025-05-18 08:31:56 +02:00
var installResult = new InstallResult ( installDirectory , addonGameId ) ;
var gameFileList = installResult . FileList ;
2025-11-29 18:24:27 -06:00
var manifest = await ManifestHelper . ReadAsync < SDK . Models . Manifest . Game > ( installDirectory , addonGameId ) ;
2025-05-16 01:04:03 +02:00
if ( manifest ! = null )
{
2025-05-18 08:31:56 +02:00
var dependentResult = await UninstallAsync ( installDirectory , manifest . Id ) ;
gameFileList . BaseGame . Manifest = manifest ;
gameFileList . Merge ( dependentResult . FileList ) ;
2025-05-16 01:04:03 +02:00
}
2025-05-18 08:31:56 +02:00
return installResult ;
2023-11-10 00:29:16 -06:00
}
2024-10-26 15:48:47 -05:00
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 > ( ) ;
2025-01-31 00:34:37 -06:00
_installProgress . Game = game ;
2025-02-16 15:47:57 -06:00
_installProgress . Status = InstallStatus . EnumeratingFiles ;
_installProgress . Indeterminate = true ;
_installProgress . Progress = 0 ;
2025-01-31 00:34:37 -06:00
OnInstallProgressUpdate ? . Invoke ( _installProgress ) ;
2024-10-26 15:48:47 -05:00
gameAndAddons . Add ( game ) ;
2024-11-12 02:17:26 -06:00
foreach ( var dependentGameId in game . DependentGames )
{
2025-09-22 00:29:51 -05:00
var dependentGame = await GetAsync ( dependentGameId ) ;
2024-11-12 02:17:26 -06:00
if ( dependentGame . IsAddon )
gameAndAddons . Add ( dependentGame ) ;
}
2024-10-26 15:48:47 -05:00
foreach ( var entry in gameAndAddons )
{
2024-11-12 02:17:26 -06:00
if ( await IsInstalled ( oldInstallDirectory , game , entry . Id ) )
2025-09-24 20:58:23 -05:00
await saveClient . UploadAsync ( oldInstallDirectory , entry . Id ) ;
2024-10-26 15:48:47 -05:00
}
if ( Directory . Exists ( newInstallDirectory ) )
2025-02-16 15:42:00 -06:00
{
// Trigger notification eventually
_installProgress . Status = InstallStatus . Failed ;
OnInstallProgressUpdate ? . Invoke ( _installProgress ) ;
return newInstallDirectory ;
}
2024-10-26 15:48:47 -05:00
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 ) ;
2025-02-16 15:42:00 -06:00
long totalPos = 0 ;
2024-10-26 15:48:47 -05:00
2025-02-16 15:47:57 -06:00
_installProgress . Status = InstallStatus . Moving ;
_installProgress . Indeterminate = false ;
2025-02-16 15:48:34 -06:00
_installProgress . BytesTransferred = totalPos ;
2025-02-16 15:47:57 -06:00
_installProgress . TotalBytes = totalSize ;
2024-10-26 15:48:47 -05:00
foreach ( var directory in directories )
{
Directory . CreateDirectory ( directory . Replace ( oldInstallDirectory , newInstallDirectory ) ) ;
}
2025-02-16 15:42:00 -06:00
using ( var fileTransferMonitor = new FileTransferMonitor ( totalSize ) )
2024-10-26 15:48:47 -05:00
{
2025-02-16 15:42:00 -06:00
foreach ( var fileInfo in fileInfos )
2024-10-26 15:48:47 -05:00
{
2025-02-16 15:42:00 -06:00
using ( FileStream sourceStream = File . Open ( fileInfo . FullName , FileMode . Open ) )
using ( FileStream destinationStream = File . Create ( fileInfo . FullName . Replace ( oldInstallDirectory , newInstallDirectory ) ) )
2024-10-26 15:48:47 -05:00
{
2025-02-16 15:42:00 -06:00
_installProgress . TotalBytes = totalSize ;
var buffer = new byte [ 81920 ] ;
int bytesRead ;
while ( ( bytesRead = await sourceStream . ReadAsync ( buffer , 0 , buffer . Length ) ) > 0 )
2024-10-26 15:48:47 -05:00
{
2025-02-16 15:42:00 -06:00
await destinationStream . WriteAsync ( buffer , 0 , bytesRead ) ;
totalPos + = bytesRead ;
2025-01-29 22:52:27 -06:00
if ( fileTransferMonitor . CanUpdate ( ) )
{
2025-02-16 15:42:00 -06:00
fileTransferMonitor . Update ( totalPos ) ;
2025-01-29 22:52:27 -06:00
2025-01-31 00:34:37 -06:00
_installProgress . TimeRemaining = fileTransferMonitor . GetTimeRemaining ( ) ;
2025-02-16 15:48:34 -06:00
_installProgress . BytesTransferred = fileTransferMonitor . GetBytesTransferred ( ) ;
2025-01-31 00:34:37 -06:00
_installProgress . TransferSpeed = fileTransferMonitor . GetSpeed ( ) ;
2025-02-16 15:42:00 -06:00
2025-01-31 00:34:37 -06:00
OnInstallProgressUpdate ? . Invoke ( _installProgress ) ;
2025-01-29 22:52:27 -06:00
}
2025-02-16 15:42:00 -06:00
}
2025-01-29 22:52:27 -06:00
}
2024-10-26 15:48:47 -05:00
}
}
2025-02-16 15:48:34 -06:00
_installProgress . BytesTransferred = totalSize ;
2025-01-31 00:34:37 -06:00
_installProgress . Progress = 1 ;
_installProgress . Status = InstallStatus . RunningScripts ;
OnInstallProgressUpdate ? . Invoke ( _installProgress ) ;
2024-10-26 15:48:47 -05:00
Directory . Delete ( oldInstallDirectory , true ) ;
foreach ( var entry in gameAndAddons )
{
2024-11-12 02:17:26 -06:00
if ( await IsInstalled ( newInstallDirectory , game , entry . Id ) )
2024-10-26 15:48:47 -05:00
{
await RunPostInstallScripts ( entry ) ;
2025-09-22 00:29:51 -05:00
2025-09-24 20:58:23 -05:00
await saveClient . DownloadAsync ( newInstallDirectory , entry . Id ) ;
2024-10-26 15:48:47 -05:00
}
}
2025-01-31 00:34:37 -06:00
_installProgress . Status = InstallStatus . Complete ;
OnInstallProgressUpdate ? . Invoke ( _installProgress ) ;
2024-10-26 15:48:47 -05:00
return newInstallDirectory ;
}
2024-11-12 02:17:26 -06:00
public async Task < bool > IsInstalled ( string installDirectory , Game game , Guid ? addonId = null )
2024-10-26 15:48:47 -05:00
{
2024-11-12 02:17:26 -06:00
installDirectory = await GetInstallDirectory ( game , installDirectory ) ;
2024-10-26 15:48:47 -05:00
var metadataPath = ManifestHelper . GetPath ( installDirectory , addonId ? ? game . Id ) ;
return File . Exists ( metadataPath ) ;
}
2025-05-17 02:02:57 +02:00
public async Task UpdateGameInstallationAsync ( string installDirectory , Game game )
{
// update game and scripts locally
await WriteManifestAsync ( installDirectory , game ) ;
await WriteScriptsAsync ( installDirectory , game ) ;
}
2026-06-10 20:39:50 -05:00
/// <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 ) ;
}
}
2026-06-30 20:48:13 -05:00
/// <summary>
/// Refreshes the on-disk manifest and scripts for an installed game using a specific version,
/// writing the version-scoped manifest and its scripts to the game's install directory. Used
/// when installing or rolling back to a particular version so the local config matches exactly.
/// </summary>
public async Task RefreshManifestAndScriptsAsync ( string installDirectory , Guid gameId , Guid versionId )
{
logger ? . LogTrace ( "Refreshing version {VersionId} manifest and scripts for game {GameId} in {InstallDirectory}" , versionId , gameId , installDirectory ) ;
var manifest = await GetManifestAsync ( gameId , versionId ) ;
await ManifestHelper . WriteAsync ( manifest , installDirectory ) ;
var scripts = await GetScriptsAsync ( gameId , versionId ) ;
if ( scripts ! = null & & scripts . Any ( ) )
{
var game = new Game { Id = gameId } ;
foreach ( var script in scripts )
await ScriptHelper . SaveScriptAsync ( game , script , installDirectory ) ;
}
}
2025-11-29 18:24:27 -06:00
private async Task < Models . Manifest . Game > WriteManifestAsync ( string installDirectory , Game game )
2025-05-17 02:02:57 +02:00
{
2025-09-22 00:29:51 -05:00
logger ? . LogTrace ( $"Retrieving game manifest for game {game.Title} with id {game.Id}" ) ;
2025-11-29 18:24:27 -06:00
var manifest = await GetManifestAsync ( game . Id ) ;
2025-09-22 00:29:51 -05:00
logger ? . LogTrace ( $"Saving Manifest for game {game.Id} into {installDirectory}" ) ;
2025-11-29 18:24:27 -06:00
2025-05-17 02:02:57 +02:00
await ManifestHelper . WriteAsync ( manifest , installDirectory ) ;
2025-11-29 18:24:27 -06:00
2025-05-17 02:02:57 +02:00
return manifest ;
}
private async Task WriteScriptsAsync ( string installDirectory , Game game )
{
2026-03-11 19:36:24 -05:00
var scripts = await GetScriptsAsync ( game . Id ) ;
if ( scripts ! = null & & scripts . Any ( ) )
2025-05-17 02:02:57 +02:00
{
2025-09-22 00:29:51 -05:00
logger ? . LogTrace ( $"Saving scripts for game {game.Title} with id {game.Id} into {installDirectory}" ) ;
2025-08-07 01:02:22 -05:00
2026-03-11 19:36:24 -05:00
foreach ( var script in scripts )
2026-03-11 20:12:09 -05:00
await ScriptHelper . SaveScriptAsync ( game , script , installDirectory ) ;
2025-05-17 02:02:57 +02:00
}
}
2024-10-26 15:48:47 -05:00
private async Task RunPostInstallScripts ( Game game )
{
if ( game . Scripts ! = null & & game . Scripts . Any ( ) )
{
2025-01-31 00:34:37 -06:00
_installProgress . Status = InstallStatus . RunningScripts ;
2024-10-26 15:48:47 -05:00
2025-01-31 00:34:37 -06:00
OnInstallProgressUpdate ? . Invoke ( _installProgress ) ;
2024-10-26 15:48:47 -05:00
try
{
var allocatedKey = await GetAllocatedKeyAsync ( game . Id ) ;
2026-02-10 17:34:35 -06:00
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 ( ) ) ;
2024-10-26 15:48:47 -05:00
}
catch ( Exception ex )
{
2025-09-22 00:29:51 -05:00
logger ? . LogError ( ex , "Scripts failed to execute for game/addon {GameTitle} ({GameId})" , game . Title , game . Id ) ;
2024-10-26 15:48:47 -05:00
}
}
}
2026-04-18 18:55:28 -05:00
/// <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 )
2023-11-10 00:29:16 -06:00
{
if ( game = = null )
{
2025-09-22 00:29:51 -05:00
logger ? . LogTrace ( "Game failed to download, no game was specified" ) ;
2023-11-10 00:29:16 -06:00
throw new ArgumentNullException ( "No game was specified" ) ;
}
2025-09-22 00:29:51 -05:00
logger ? . LogTrace ( "Downloading and extracting {Game} to path {Destination}" , game . Title , destination ) ;
2023-11-10 00:29:16 -06:00
2023-11-12 01:04:05 -06:00
var extractionResult = new ExtractionResult
{
Canceled = false ,
} ;
2025-11-20 22:05:02 -06:00
if ( ! await CanStreamLatestArchiveAsync ( game . Id ) )
{
extractionResult . Success = false ;
extractionResult . Canceled = true ;
return extractionResult ;
}
2024-01-09 20:43:54 -06:00
var fileManifest = new StringBuilder ( ) ;
2025-05-18 08:31:56 +02:00
var files = new List < ExtractionResult . FileEntry > ( ) ;
2024-01-09 20:43:54 -06:00
2026-06-20 03:33:29 -05:00
// Tracked outside the try so the catch blocks can report exactly where extraction failed
TrackableStream stream = null ;
string currentEntryKey = null ;
var entriesProcessed = 0 ;
2023-11-10 00:29:16 -06:00
try
{
Directory . CreateDirectory ( destination ) ;
2026-06-20 03:33:29 -05:00
stream = await StreamLatestArchiveAsync ( game . Id ) ;
2023-11-12 01:04:05 -06:00
2026-03-09 22:12:01 -05:00
var monitor = new FileTransferMonitor ( stream . Length ) ;
var progress = new Progress < ProgressReport > ( report = >
2023-11-10 00:29:16 -06:00
{
2026-03-09 22:12:01 -05:00
if ( cancellationToken . IsCancellationRequested )
2024-08-07 17:39:55 -05:00
{
2026-03-09 22:12:01 -05:00
_reader ? . Cancel ( ) ;
2025-10-15 21:26:40 -05:00
2026-03-09 22:12:01 -05:00
_installProgress . Status = InstallStatus . Canceled ;
2025-02-04 02:34:31 -06:00
2026-03-09 22:12:01 -05:00
OnInstallProgressUpdate ? . Invoke ( _installProgress ) ;
return ;
}
if ( monitor . CanUpdate ( ) )
{
monitor . Update ( stream . Position ) ;
2023-11-10 00:29:16 -06:00
2026-03-09 22:12:01 -05:00
_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 ) ;
2026-04-12 00:19:08 -05:00
_installProgress . Status = InstallStatus . Downloading ;
2026-04-03 19:23:52 -05:00
OnInstallProgressUpdate ? . Invoke ( _installProgress ) ;
2026-03-09 22:12:01 -05:00
while ( await _reader . MoveToNextEntryAsync ( cancellationToken ) )
2023-11-12 01:04:05 -06:00
{
2025-08-18 02:47:09 -05:00
if ( _reader . Cancelled )
2023-11-12 01:04:05 -06:00
break ;
2024-02-22 02:12:18 -06:00
try
{
2026-04-18 18:55:28 -05:00
var entryKey = _reader . Entry . Key ;
2026-06-20 03:33:29 -05:00
currentEntryKey = entryKey ;
2026-04-18 18:55:28 -05:00
var localFile = Path . Combine ( destination , entryKey ) ;
2024-01-09 20:43:54 -06:00
2026-04-18 18:55:28 -05:00
fileManifest . AppendLine ( $"{entryKey} | {_reader.Entry.Crc.ToString(" X ")}" ) ;
2025-05-18 08:31:56 +02:00
files . Add ( new ExtractionResult . FileEntry
{
2026-04-18 18:55:28 -05:00
EntryPath = entryKey ,
2025-05-18 08:31:56 +02:00
LocalPath = localFile ,
} ) ;
2024-02-22 02:12:18 -06:00
2026-04-18 18:55:28 -05:00
// If pre-flight verification confirmed this file exists locally, skip it
bool shouldSkip = skipFiles ! = null & & skipFiles . Contains ( entryKey ) ;
if ( ! shouldSkip )
2026-03-09 22:12:01 -05:00
await _reader . WriteEntryToDirectoryAsync ( destination , new ExtractionOptions ( )
2024-02-22 02:12:18 -06:00
{
ExtractFullPath = true ,
Overwrite = true ,
PreserveFileTime = true
2026-03-09 22:12:01 -05:00
} , cancellationToken ) ;
2024-02-22 02:12:18 -06:00
else // Skip to next entry
2024-06-26 18:05:22 -05:00
try
{
2026-03-09 22:12:01 -05:00
await using var es = await _reader . OpenEntryStreamAsync ( cancellationToken ) ;
2024-06-26 18:05:22 -05:00
}
2025-08-18 02:44:55 -05:00
catch
{
2026-04-18 18:55:28 -05:00
logger ? . LogError ( "Could not skip to next entry in archive: {EntryKey}" , entryKey ) ;
2025-08-18 02:44:55 -05:00
}
2026-06-20 03:33:29 -05:00
entriesProcessed + + ;
2024-01-10 17:57:00 -06:00
}
2024-02-22 02:12:18 -06:00
catch ( IOException ex )
{
var errorCode = ex . HResult & 0xFFFF ;
2024-01-10 17:57:00 -06:00
2024-02-22 02:12:18 -06:00
if ( errorCode = = 87 )
2026-06-20 03:33:29 -05:00
{
logger ? . LogError ( ex , "Fatal IO error (HResult 0x{HResult:X8}, Win32 {ErrorCode}) writing entry {EntryKey} for game {GameTitle} ({GameId}) after {EntriesProcessed} entries at {Position}/{Length} bytes" ,
ex . HResult , errorCode , currentEntryKey , game . Title , game . Id , entriesProcessed , stream ? . Position , stream ? . Length ) ;
2024-02-22 02:12:18 -06:00
throw ex ;
2026-06-20 03:33:29 -05:00
}
logger ? . LogTrace ( "Not replacing existing file/folder on disk: {EntryKey} (HResult 0x{HResult:X8}) - {Message}" , currentEntryKey , ex . HResult , ex . Message ) ;
2024-01-10 17:57:00 -06:00
2024-02-22 02:12:18 -06:00
// Skip to next entry
2026-03-09 22:12:01 -05:00
await using var es = await _reader . OpenEntryStreamAsync ( cancellationToken ) ;
2024-02-22 02:12:18 -06:00
}
2023-11-10 00:29:16 -06:00
}
2023-11-12 01:04:05 -06:00
2026-03-09 22:12:01 -05:00
await _reader . DisposeAsync ( ) ;
2025-10-09 02:16:20 -05:00
await stream . DisposeAsync ( ) ;
// _transferStream.Dispose();
2023-11-10 00:29:16 -06:00
}
2023-11-28 21:20:07 -06:00
catch ( ReaderCancelledException ex )
2023-11-10 00:29:16 -06:00
{
2025-09-22 00:29:51 -05:00
logger ? . LogTrace ( ex , "User cancelled the download" ) ;
2023-11-12 01:04:05 -06:00
2023-11-28 21:20:07 -06:00
extractionResult . Canceled = true ;
2023-11-12 01:04:05 -06:00
2023-11-28 21:20:07 -06:00
if ( Directory . Exists ( destination ) )
2023-11-10 00:29:16 -06:00
{
2025-09-22 00:29:51 -05:00
logger ? . LogTrace ( "Cleaning up orphaned files after cancelled install" ) ;
2023-11-10 00:29:16 -06:00
2023-11-28 21:20:07 -06:00
Directory . Delete ( destination , true ) ;
}
}
catch ( Exception ex )
{
2026-06-20 03:33:29 -05:00
logger ? . LogError ( ex , "Could not extract game {GameTitle} ({GameId}) to {Destination}. Failed on entry {EntryKey} (entry #{EntriesProcessed}) at {Position}/{Length} bytes with {ExceptionType} (HResult 0x{HResult:X8})" ,
game . Title , game . Id , destination , currentEntryKey , entriesProcessed , stream ? . Position , stream ? . Length , ex . GetType ( ) . Name , ex . HResult ) ;
2023-11-10 00:29:16 -06:00
2023-11-28 21:20:07 -06:00
if ( Directory . Exists ( destination ) )
{
2025-09-22 00:29:51 -05:00
logger ? . LogTrace ( "Cleaning up orphaned install files after bad install" ) ;
2023-11-10 00:29:16 -06:00
2023-11-28 21:20:07 -06:00
Directory . Delete ( destination , true ) ;
2023-11-10 00:29:16 -06:00
}
2023-11-28 21:20:07 -06:00
throw new Exception ( "The game archive could not be extracted, is it corrupted? Please try again" ) ;
2023-11-10 00:29:16 -06:00
}
if ( ! extractionResult . Canceled )
{
extractionResult . Success = true ;
extractionResult . Directory = destination ;
2025-05-18 08:31:56 +02:00
extractionResult . Files = files ;
2023-11-10 00:29:16 -06:00
2024-01-09 20:43:54 -06:00
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 ( ) ) ;
2025-09-22 00:29:51 -05:00
logger ? . LogTrace ( "Game {Game} successfully downloaded and extracted to {Destination}" , game . Title , destination ) ;
2023-11-10 00:29:16 -06:00
}
return extractionResult ;
}
2023-11-12 01:04:05 -06:00
2024-11-12 02:17:26 -06:00
public async Task < string > GetInstallDirectory ( Game game , string installDirectory )
2024-01-08 18:52:00 -06:00
{
2024-10-04 23:37:49 -05:00
if ( string . IsNullOrWhiteSpace ( installDirectory ) )
2025-10-06 20:29:34 -05:00
installDirectory = settingsProvider . CurrentValue . Games . InstallDirectories . First ( ) ;
2024-09-12 19:09:13 -05:00
2024-11-12 02:17:26 -06:00
if ( ( game . Type = = GameType . Expansion | | game . Type = = GameType . Mod | | game . Type = = GameType . StandaloneMod ) & & game . BaseGameId ! = Guid . Empty )
{
2025-05-15 02:40:34 +02:00
// 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
{
2025-09-22 00:29:51 -05:00
var baseGame = await GetAsync ( game . BaseGameId ) ;
2024-11-12 02:17:26 -06:00
2025-05-15 02:40:34 +02:00
return await GetInstallDirectory ( baseGame , installDirectory ) ;
}
2024-11-12 02:17:26 -06:00
}
2024-01-10 01:25:46 -06:00
else
2024-09-12 19:09:13 -05:00
return Path . Combine ( installDirectory , game . Title . SanitizeFilename ( ) ) ;
2024-01-08 18:52:00 -06:00
}
2023-11-12 01:04:05 -06:00
public void CancelInstall ( )
{
2025-08-18 02:47:09 -05:00
_reader ? . Cancel ( ) ;
2023-11-12 01:04:05 -06:00
}
2024-01-10 01:59:51 -06:00
2025-11-29 18:24:27 -06:00
public async Task < ICollection < SDK . Models . Manifest . Game > > ReadManifestsAsync ( string installDirectory , Guid gameId )
2024-08-26 20:18:54 -05:00
{
2025-11-29 18:24:27 -06:00
var manifests = new List < SDK . Models . Manifest . Game > ( ) ;
var mainManifest = await ManifestHelper . ReadAsync < SDK . Models . Manifest . Game > ( installDirectory , gameId ) ;
2024-08-26 20:18:54 -05:00
if ( mainManifest = = null )
return manifests ;
manifests . Add ( mainManifest ) ;
2025-11-29 18:24:27 -06:00
if ( mainManifest . Addons ! = null )
2024-08-26 20:18:54 -05:00
{
2025-11-29 18:24:27 -06:00
foreach ( var addon in mainManifest . Addons )
2024-08-26 20:18:54 -05:00
{
try
{
2025-11-29 18:24:27 -06:00
var dependentGameManifest = await ManifestHelper . ReadAsync < SDK . Models . Manifest . Game > ( installDirectory , addon . Id ) ;
2024-08-26 20:18:54 -05:00
if ( dependentGameManifest . Type = = GameType . Expansion | | dependentGameManifest . Type = = GameType . Mod )
manifests . Add ( dependentGameManifest ) ;
}
catch ( Exception ex )
{
2025-11-29 18:24:27 -06:00
logger ? . LogError ( ex , "Could not load manifest from dependent game {AddonId}" , addon . Id ) ;
2024-08-26 20:18:54 -05:00
}
}
}
return manifests ;
}
2025-05-18 03:09:22 +02:00
/// <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>
2025-11-29 18:24:27 -06:00
protected async Task < IEnumerable < ArchiveEntry > > GetGameInstallationArchiveEntries ( Guid gameId , Models . Manifest . Game manifest )
2025-05-18 03:09:22 +02:00
{
2025-09-22 00:29:51 -05:00
var entries = await apiRequestFactory
. Create ( )
. UseAuthenticationToken ( )
. UseVersioning ( )
. UseRoute ( $"/api/Archives/Contents/{manifest.Id}/{manifest.Version}" )
. GetAsync < IEnumerable < ArchiveEntry > > ( ) ;
2025-05-18 03:09:22 +02:00
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 ) ;
2026-05-24 00:06:46 -05:00
2025-05-18 03:09:22 +02:00
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 ) ) ;
2026-05-24 00:06:46 -05:00
2025-05-18 03:09:22 +02:00
if ( baseManifest ! = null )
{
var entries = await GetGameInstallationArchiveEntries ( gameId , baseManifest ) ;
gameArchives . BaseGame . Entries . AddRange ( entries ) ;
2025-08-18 02:44:55 -05:00
manifests = manifests . Except ( [ baseManifest ] ) . ToList ( ) ;
2025-05-18 03:09:22 +02:00
2025-09-24 20:58:23 -05:00
var savePathEntries = baseManifest . SavePaths ? . SelectMany ( p = > saveClient . GetFileSavePathEntries ( p , installDirectory ) ) . ToList ( ) ? ? [ ] ;
2025-05-18 03:09:22 +02:00
gameArchives . BaseGame . SavePaths = savePathEntries ;
}
// Processes dependent game manifests and their corresponding archive entries.
foreach ( var depManifest in manifests ? ? [ ] )
{
var depEntries = await GetGameInstallationArchiveEntries ( gameId , depManifest ) ;
2025-11-29 18:24:27 -06:00
if ( ! gameArchives . Addons . TryGetValue ( depManifest . Id , out var depArchiveInfo ) )
2025-05-18 03:09:22 +02:00
{
depArchiveInfo = new ( ) ;
2025-11-29 18:24:27 -06:00
gameArchives . Addons . Add ( depManifest . Id , depArchiveInfo ) ;
2025-05-18 03:09:22 +02:00
}
depArchiveInfo . Manifest = depManifest ;
depArchiveInfo . Entries . AddRange ( depEntries ) ;
2025-09-24 20:58:23 -05:00
var savePathEntries = depManifest . SavePaths ? . SelectMany ( p = > saveClient . GetFileSavePathEntries ( p , installDirectory ) ) . ToList ( ) ? ? [ ] ;
2026-05-24 00:06:46 -05:00
2025-05-18 03:09:22 +02:00
depArchiveInfo . SavePaths = savePathEntries ;
}
return gameArchives ;
}
2025-11-29 18:24:27 -06:00
public async Task RunAsync ( string installDirectory , Guid gameId , Models . Manifest . Action action , DateTime ? lastRun , string args = "" )
2024-08-26 20:18:54 -05:00
{
var screen = DisplayHelper . GetScreen ( ) ;
2025-09-22 00:29:51 -05:00
using ( var context = processExecutionContextFactory . Create ( ) )
2024-08-26 20:18:54 -05:00
{
2025-09-24 20:58:23 -05:00
context . AddVariable ( "ServerAddress" , connectionClient . GetServerAddress ( ) . ToString ( ) ) ;
2025-01-15 02:28:11 -06:00
2025-08-18 02:37:52 -05:00
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 )
{
2025-09-22 00:29:51 -05:00
logger ? . LogError ( ex , "Could not get display information for execution context variables" ) ;
2025-08-18 02:37:52 -05:00
}
try
{
2025-10-06 20:29:34 -05:00
if ( connectionClient . IsConnected ( ) & & ! String . IsNullOrWhiteSpace ( settingsProvider . CurrentValue . IPXRelay . Host ) )
2025-08-18 02:37:52 -05:00
{
2025-10-06 20:29:34 -05:00
context . AddVariable ( "IPXRelayHost" , settingsProvider . CurrentValue . IPXRelay . Host ) ;
context . AddVariable ( "IPXRelayPort" , settingsProvider . CurrentValue . IPXRelay . Port . ToString ( ) ) ;
2025-08-18 02:37:52 -05:00
}
}
catch ( Exception ex )
2025-01-15 02:28:11 -06:00
{
2025-09-22 00:29:51 -05:00
logger ? . LogError ( ex , "Could not connect to IPXRelay host" ) ;
2025-01-15 02:28:11 -06:00
}
2024-08-26 20:18:54 -05:00
2026-03-15 17:15:50 -05:00
if ( action . Variables ! = null )
{
foreach ( var variable in action . Variables )
context . AddVariable ( variable . Key , variable . Value ) ;
}
2026-06-27 17:59:17 -05:00
// When an action references {ServerHost} but the game server didn't specify a host,
// fall back to the host of the LANCommander server the launcher is connected to.
if ( action . Variables = = null
| | ! action . Variables . TryGetValue ( "ServerHost" , out var serverHost )
| | String . IsNullOrWhiteSpace ( serverHost ) )
{
var serverAddress = connectionClient . GetServerAddress ( ) ;
if ( serverAddress ! = null )
context . AddVariable ( "ServerHost" , serverAddress . Host ) ;
}
2024-08-26 20:18:54 -05:00
#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
2025-09-24 20:58:23 -05:00
if ( connectionClient . IsConnected ( ) )
2025-01-15 02:28:11 -06:00
{
2025-09-24 20:58:23 -05:00
var alias = await profileClient . GetAliasAsync ( ) ;
2025-01-30 23:57:12 -06:00
2025-01-15 02:28:11 -06:00
if ( currentGamePlayerAlias ! = alias )
2025-01-30 23:57:12 -06:00
{
2026-02-10 17:34:35 -06:00
await scriptClient . Game_RunNameChangeScriptAsync ( installDirectory , gameId , alias ) ;
2025-01-30 23:57:12 -06:00
if ( manifest . Redistributables ! = null )
{
foreach ( var redistributable in manifest . Redistributables . Where ( r = > r . Scripts ! = null ) )
{
2026-02-10 17:34:35 -06:00
await scriptClient . Redistributable_RunNameChangeScriptAsync ( installDirectory , gameId , redistributable . Id , alias ) ;
2025-01-30 23:57:12 -06:00
}
}
}
2025-01-15 02:28:11 -06:00
}
2024-08-26 20:18:54 -05:00
#endregion
#region Check Key Allocation
2025-09-24 20:58:23 -05:00
if ( connectionClient . IsConnected ( ) )
2024-08-26 20:18:54 -05:00
{
2025-09-22 00:29:51 -05:00
var newKey = await GetAllocatedKeyAsync ( manifest . Id ) ;
2024-08-26 20:18:54 -05:00
if ( currentGameKey ! = newKey )
2026-02-10 17:34:35 -06:00
await scriptClient . Game_RunKeyChangeScriptAsync ( installDirectory , manifest . Id , newKey ) ;
2024-08-26 20:18:54 -05:00
}
#endregion
#region Download Latest Saves
2025-09-24 20:58:23 -05:00
if ( connectionClient . IsConnected ( ) )
2024-08-26 20:18:54 -05:00
{
2024-09-27 18:07:14 -05:00
await RetryHelper . RetryOnExceptionAsync ( 10 , TimeSpan . FromSeconds ( 1 ) , false , async ( ) = >
2024-08-26 20:18:54 -05:00
{
2026-05-25 04:16:19 -05:00
logger ? . LogTrace ( "Checking for latest save for game {GameId}" , manifest . Id ) ;
2024-09-27 18:07:14 -05:00
2026-03-11 23:59:21 -05:00
try
{
var latestSave = await saveClient . GetLatestAsync ( manifest . Id ) ;
2024-08-26 20:18:54 -05:00
2026-05-25 04:16:19 -05:00
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 ) ;
2026-03-11 23:59:21 -05:00
await saveClient . DownloadAsync ( installDirectory , manifest . Id ) ;
2026-05-25 04:16:19 -05:00
}
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 ) ;
}
2026-03-11 23:59:21 -05:00
}
catch ( HttpRequestException ex )
{
if ( ex . StatusCode = = HttpStatusCode . NotFound )
2026-05-25 04:16:19 -05:00
{
logger ? . LogDebug ( "No saves found on server for game {GameId} (404)" , manifest . Id ) ;
2026-03-11 23:59:21 -05:00
return true ;
2026-05-25 04:16:19 -05:00
}
2026-03-11 23:59:21 -05:00
throw ;
}
2024-09-27 18:07:14 -05:00
return true ;
} ) ;
2024-08-26 20:18:54 -05:00
}
2026-05-25 04:16:19 -05:00
else
{
logger ? . LogDebug ( "Skipping save download for game {GameId}, not connected to server" , manifest . Id ) ;
}
2024-08-26 20:18:54 -05:00
#endregion
#region Run Before Start Script
2026-02-10 17:34:35 -06:00
await scriptClient . Game_RunBeforeStartScriptAsync ( installDirectory , manifest . Id ) ;
2025-01-30 23:57:12 -06:00
if ( manifest . Redistributables ! = null )
{
foreach ( var redistributable in manifest . Redistributables . Where ( r = > r . Scripts ! = null ) )
{
2026-02-10 17:34:35 -06:00
await scriptClient . Redistributable_RunBeforeStartScriptAsync ( installDirectory , gameId , redistributable . Id ) ;
2025-01-30 23:57:12 -06:00
}
}
2024-08-26 20:18:54 -05:00
#endregion
}
#endregion
2026-07-23 19:47:22 -05:00
await pluginEventBus . PublishAsync ( new GameBeforeLaunchEvent ( gameId , installDirectory , action ? . Name ) ) ;
2026-06-14 00:53:19 -05:00
Task heartbeatTask = null ;
2024-08-26 20:18:54 -05:00
try
{
2024-10-29 01:27:51 -05:00
var cancellationTokenSource = new CancellationTokenSource ( ) ;
2025-08-18 02:47:09 -05:00
_running [ gameId ] = cancellationTokenSource ;
2024-08-26 20:18:54 -05:00
2026-06-14 00:53:19 -05:00
heartbeatTask = SendKeepAlivesAsync ( gameId , cancellationTokenSource . Token ) ;
2026-05-26 19:59:06 -05:00
#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 ) ;
2024-10-29 18:12:14 -05:00
2025-08-18 02:47:09 -05:00
_running . Remove ( gameId ) ;
2026-06-14 00:53:19 -05:00
await StopHeartbeatAsync ( cancellationTokenSource , heartbeatTask ) ;
2026-05-09 19:41:52 -05:00
cancellationTokenSource . Dispose ( ) ;
2025-08-18 02:38:21 -05:00
await UploadSavesAsync ( manifests , installDirectory ) ;
2024-08-26 20:18:54 -05:00
}
catch ( Exception ex )
{
2026-05-09 19:41:52 -05:00
if ( _running . TryGetValue ( gameId , out var cts ) )
{
_running . Remove ( gameId ) ;
2026-06-14 00:53:19 -05:00
await StopHeartbeatAsync ( cts , heartbeatTask ) ;
2026-05-09 19:41:52 -05:00
cts . Dispose ( ) ;
}
2025-09-22 00:29:51 -05:00
logger ? . LogError ( ex , "Game failed to run" ) ;
2026-05-07 23:12:00 -05:00
throw ;
2024-08-26 20:18:54 -05:00
}
foreach ( var manifest in manifests )
{
2025-01-30 23:57:12 -06:00
#region Run After Stop Script
2026-02-10 17:34:35 -06:00
await scriptClient . Game_RunAfterStopScriptAsync ( installDirectory , gameId ) ;
2025-01-30 23:57:12 -06:00
if ( manifest . Redistributables ! = null )
{
foreach ( var redistributable in manifest . Redistributables . Where ( r = > r . Scripts ! = null ) )
{
2026-02-10 17:34:35 -06:00
await scriptClient . Redistributable_RunAfterStopScriptAsync ( installDirectory , gameId , redistributable . Id ) ;
2025-01-30 23:57:12 -06:00
}
}
#endregion
2024-08-26 20:18:54 -05:00
}
2026-07-23 19:47:22 -05:00
await pluginEventBus . PublishAsync ( new GameAfterExitEvent ( gameId , installDirectory ) ) ;
2024-08-26 20:18:54 -05:00
}
}
2025-11-29 18:24:27 -06:00
private async Task UploadSavesAsync ( ICollection < SDK . Models . Manifest . Game > manifests , string installDirectory )
2025-08-18 02:38:21 -05:00
{
2025-09-24 20:58:23 -05:00
if ( connectionClient . IsConnected ( ) )
2025-08-18 02:38:21 -05:00
{
foreach ( var manifest in manifests )
{
await RetryHelper . RetryOnExceptionAsync ( 10 , TimeSpan . FromSeconds ( 1 ) , false , async ( ) = >
{
2026-05-25 04:16:19 -05:00
logger ? . LogDebug ( "Uploading save for game {GameId}" , manifest . Id ) ;
2025-08-18 02:38:21 -05:00
2026-07-04 14:27:00 -05:00
try
{
await saveClient . UploadAsync ( installDirectory , manifest . Id ) ;
}
catch ( Exception ex )
{
logger ? . LogError ( ex , "Save upload attempt failed for game {GameId}" , manifest . Id ) ;
throw ;
}
2025-08-18 02:38:21 -05:00
2026-05-25 04:16:19 -05:00
logger ? . LogInformation ( "Save uploaded successfully for game {GameId}" , manifest . Id ) ;
2025-08-18 02:38:21 -05:00
return true ;
} ) ;
}
}
2026-05-25 04:16:19 -05:00
else
{
logger ? . LogDebug ( "Skipping save upload, not connected to server" ) ;
}
2025-08-18 02:38:21 -05:00
}
2026-06-14 00:53:19 -05:00
// 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 )
{
}
}
}
2024-08-26 20:18:54 -05:00
public async Task Stop ( Guid gameId )
{
2025-08-18 02:47:09 -05:00
if ( _running . ContainsKey ( gameId ) )
2024-08-26 20:18:54 -05:00
{
2025-08-18 02:47:09 -05:00
await _running [ gameId ] . CancelAsync ( ) ;
2024-08-26 20:18:54 -05:00
2025-08-18 02:47:09 -05:00
_running . Remove ( gameId ) ;
2024-10-29 01:27:51 -05:00
}
2024-08-26 20:18:54 -05:00
}
public bool IsRunning ( Guid gameId )
{
2025-08-18 02:47:09 -05:00
if ( ! _running . ContainsKey ( gameId ) )
2024-08-26 20:18:54 -05:00
return false ;
2025-08-18 02:47:09 -05:00
return ! _running [ gameId ] . IsCancellationRequested ;
2024-08-26 20:18:54 -05:00
}
2024-08-12 19:33:26 -05:00
public async Task ImportAsync ( string archivePath )
{
using ( var fs = new FileStream ( archivePath , FileMode . Open , FileAccess . Read ) )
{
2025-09-22 00:29:51 -05:00
var objectKey = await apiRequestFactory
. Create ( )
. UseAuthenticationToken ( )
. UseVersioning ( )
2025-10-06 20:29:34 -05:00
. UploadInChunksAsync ( settingsProvider . CurrentValue . Archives . UploadChunkSize , fs ) ;
2024-08-12 19:33:26 -05:00
if ( objectKey ! = Guid . Empty )
2025-09-22 00:29:51 -05:00
await apiRequestFactory
. Create ( )
. UseAuthenticationToken ( )
. UseVersioning ( )
. UseRoute ( $"/api/Games/Import/{objectKey}" )
2025-10-01 18:45:30 -05:00
. PostAsync ( ) ;
2024-08-12 19:33:26 -05:00
}
}
2025-09-22 00:29:51 -05:00
[Obsolete("Servers no longer do \"Full\" exports")]
2024-08-13 17:42:43 -05:00
public async Task ExportAsync ( string destinationPath , Guid gameId )
{
2025-09-22 00:29:51 -05:00
await apiRequestFactory
. Create ( )
. UseAuthenticationToken ( )
. UseVersioning ( )
. UseRoute ( $"/api/Games/Export/Full" )
. DownloadAsync ( destinationPath ) ;
2024-08-13 17:42:43 -05:00
}
2024-10-01 17:56:58 -05:00
public async Task UploadArchiveAsync ( string archivePath , Guid gameId , string version , string changelog = "" )
{
using ( var fs = new FileStream ( archivePath , FileMode . Open , FileAccess . Read ) )
{
2025-09-22 00:29:51 -05:00
var objectKey = await apiRequestFactory
. Create ( )
. UseAuthenticationToken ( )
. UseVersioning ( )
2025-10-06 20:29:34 -05:00
. UploadInChunksAsync ( settingsProvider . CurrentValue . Archives . UploadChunkSize , fs ) ;
2024-10-01 17:56:58 -05:00
if ( objectKey ! = Guid . Empty )
2025-09-22 00:29:51 -05:00
await apiRequestFactory
. Create ( )
. UseAuthenticationToken ( )
. UseVersioning ( )
. UseRoute ( "/api/Games/UploadArchive" )
. AddBody ( new UploadArchiveRequest
{
Id = gameId ,
ObjectKey = objectKey ,
Version = version ,
Changelog = changelog
} )
2025-10-01 18:45:30 -05:00
. PostAsync ( ) ;
2024-10-01 17:56:58 -05:00
}
}
2024-11-04 17:31:37 -06:00
/// <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>
2024-11-14 20:53:08 -06:00
public async Task < IEnumerable < ArchiveValidationConflict > > ValidateFilesAsync ( string installDirectory , Guid gameId )
2024-11-04 17:31:37 -06:00
{
2025-05-18 03:09:22 +02:00
var archives = await GetGameInstallationArchivesEntries ( installDirectory , gameId ) ;
var entries = archives ? . BaseGame ? . Entries ? . ToList ( ) ? ? [ ] ;
2025-11-29 18:24:27 -06:00
foreach ( ( var dependentGameId , var dependentGameInfo ) in archives ? . Addons ? ? [ ] )
2025-05-18 03:09:22 +02:00
{
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
2025-11-29 18:24:27 -06:00
var lookupEntry = archives ? . Addons ?
2025-05-18 03:09:22 +02:00
. 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 ) ) ;
2024-11-04 17:31:37 -06:00
2024-11-14 20:53:08 -06:00
var conflictedEntries = new List < ArchiveValidationConflict > ( ) ;
2025-05-18 03:09:22 +02:00
var savePathEntries = archives ? . BaseGame ? . SavePaths . ToList ( ) ? ? [ ] ;
2025-11-29 18:24:27 -06:00
var depSavePathEntries = archives ? . Addons ? . SelectMany ( dep = > dep . Value ? . SavePaths ? ? [ ] ) . ToList ( ) ? ? [ ] ;
2025-05-18 03:09:22 +02:00
savePathEntries . AddRange ( depSavePathEntries ) ;
2024-11-04 17:31:37 -06:00
foreach ( var entry in entries )
{
2024-11-14 20:53:08 -06:00
if ( savePathEntries . Any ( e = > e . ArchivePath . Equals ( entry . FullName , StringComparison . OrdinalIgnoreCase ) ) )
continue ;
if ( entry . FullName . EndsWith ( '/' ) )
continue ;
2024-11-04 17:31:37 -06:00
var localFile = Path . Combine ( installDirectory , entry . FullName . Replace ( '/' , Path . DirectorySeparatorChar ) ) ;
if ( ! Path . Exists ( localFile ) )
2024-11-14 20:53:08 -06:00
conflictedEntries . Add ( new ArchiveValidationConflict
{
2025-05-18 03:09:22 +02:00
GameId = lookupEntry [ entry ] ? . FirstOrDefault ( ) ? ? gameId ,
2024-11-14 20:53:08 -06:00
Name = entry . Name ,
FullName = entry . FullName ,
Crc32 = entry . Crc32 ,
Length = entry . Length ,
} ) ;
2024-11-04 17:31:37 -06:00
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 )
2024-11-14 20:53:08 -06:00
conflictedEntries . Add ( new ArchiveValidationConflict
{
2025-05-18 03:09:22 +02:00
GameId = lookupEntry [ entry ] ? . FirstOrDefault ( ) ? ? gameId ,
2024-11-14 20:53:08 -06:00
Name = entry . Name ,
FullName = entry . FullName ,
Crc32 = entry . Crc32 ,
LocalFileInfo = new FileInfo ( localFile )
} ) ;
2024-11-04 17:31:37 -06:00
}
}
return conflictedEntries ;
}
2025-05-18 03:09:22 +02:00
/// <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 )
{
2025-08-18 02:44:55 -05:00
await DownloadFilesAsync ( installDirectory , group . Key , group . Select ( x = > x . FilePath ) . ToList ( ) ) ;
2025-05-18 03:09:22 +02:00
}
}
/// <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>
2026-03-09 22:12:01 -05:00
public async Task DownloadFilesAsync ( string installDirectory , Guid gameId , ICollection < string > entries , CancellationToken cancellationToken = default )
2024-11-04 17:31:37 -06:00
{
2025-11-29 18:24:27 -06:00
var manifest = await ManifestHelper . ReadAsync < SDK . Models . Manifest . Game > ( installDirectory , gameId ) ;
2024-11-04 17:31:37 -06:00
2026-03-09 22:12:01 -05:00
try
2024-11-04 17:31:37 -06:00
{
2026-03-09 22:12:01 -05:00
var stream = await StreamLatestArchiveAsync ( gameId ) ;
_reader = await ReaderFactory . OpenAsyncReader ( stream , new ReaderOptions ( ) , cancellationToken ) ;
while ( await _reader . MoveToNextEntryAsync ( cancellationToken ) )
2024-11-04 17:31:37 -06:00
{
2026-03-09 22:12:01 -05:00
if ( _reader . Cancelled )
break ;
2024-11-04 17:31:37 -06:00
2026-03-09 22:12:01 -05:00
try
2024-11-04 17:31:37 -06:00
{
2026-03-09 22:12:01 -05:00
if ( entries . Contains ( _reader . Entry . Key ) )
2024-11-04 17:31:37 -06:00
{
2026-03-09 22:12:01 -05:00
await _reader . WriteEntryToDirectoryAsync ( installDirectory , new ExtractionOptions
2024-11-04 17:31:37 -06:00
{
2026-03-09 22:12:01 -05:00
ExtractFullPath = true ,
Overwrite = true ,
PreserveFileTime = true ,
} , cancellationToken ) ;
2024-11-04 17:31:37 -06:00
}
2026-03-09 22:12:01 -05:00
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 ;
2024-11-04 17:31:37 -06:00
2026-03-09 22:12:01 -05:00
if ( errorCode = = 87 )
throw ;
else
logger ? . LogTrace ( "Not replacing existing file/folder on disk: {EntryKey} - {Message}" , _reader . Entry . Key , ex . Message ) ;
2024-11-04 17:31:37 -06:00
2026-03-09 22:12:01 -05:00
// Skip to next entry
await using var es = await _reader . OpenEntryStreamAsync ( cancellationToken ) ;
2024-11-04 17:31:37 -06:00
}
}
2026-03-09 22:12:01 -05:00
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" ) ;
}
2024-11-04 17:31:37 -06:00
}
2025-05-18 08:31:56 +02:00
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 ( ) ;
2026-05-24 00:06:46 -05:00
2025-05-18 08:31:56 +02:00
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 ) ;
}
2024-01-10 01:59:51 -06:00
public static string GetMetadataDirectoryPath ( string installDirectory , Guid gameId )
{
2024-10-04 23:37:49 -05:00
if ( string . IsNullOrWhiteSpace ( installDirectory ) )
2024-08-08 18:28:33 -05:00
return "" ;
2024-01-10 01:59:51 -06:00
return Path . Combine ( installDirectory , ".lancommander" , gameId . ToString ( ) ) ;
}
public static string GetMetadataFilePath ( string installDirectory , Guid gameId , string fileName )
{
return Path . Combine ( GetMetadataDirectoryPath ( installDirectory , gameId ) , fileName ) ;
}
2024-01-19 00:25:38 -06:00
public static string GetPlayerAlias ( string installDirectory , Guid gameId )
{
2024-10-04 23:37:49 -05:00
var aliasFilePath = GetMetadataFilePath ( installDirectory , gameId , PlayerAliasFilename ) ;
2024-01-19 00:25:38 -06:00
if ( File . Exists ( aliasFilePath ) )
return File . ReadAllText ( aliasFilePath ) ;
2026-05-24 00:06:46 -05:00
return string . Empty ;
2024-01-19 00:25:38 -06:00
}
2024-08-26 20:18:54 -05:00
public static async Task < string > GetPlayerAliasAsync ( string installDirectory , Guid gameId )
{
2024-10-04 23:37:49 -05:00
var aliasFilePath = GetMetadataFilePath ( installDirectory , gameId , PlayerAliasFilename ) ;
2024-08-26 20:18:54 -05:00
if ( File . Exists ( aliasFilePath ) )
return await File . ReadAllTextAsync ( aliasFilePath ) ;
2026-05-24 00:06:46 -05:00
return string . Empty ;
2024-08-26 20:18:54 -05:00
}
2024-01-19 00:25:38 -06:00
public static void UpdatePlayerAlias ( string installDirectory , Guid gameId , string newName )
{
2024-10-04 23:37:49 -05:00
File . WriteAllText ( GetMetadataFilePath ( installDirectory , gameId , PlayerAliasFilename ) , newName ) ;
2024-01-19 00:25:38 -06:00
}
2024-02-03 18:32:42 -06:00
2024-08-26 20:18:54 -05:00
public static async Task UpdatePlayerAliasAsync ( string installDirectory , Guid gameId , string newName )
{
2024-10-04 23:37:49 -05:00
await File . WriteAllTextAsync ( GetMetadataFilePath ( installDirectory , gameId , PlayerAliasFilename ) , newName ) ;
2024-08-26 20:18:54 -05:00
}
2024-02-03 18:32:42 -06:00
public static string GetCurrentKey ( string installDirectory , Guid gameId )
{
2024-10-04 23:37:49 -05:00
var keyFilePath = GetMetadataFilePath ( installDirectory , gameId , KeyFilename ) ;
2024-02-03 18:32:42 -06:00
if ( File . Exists ( keyFilePath ) )
return File . ReadAllText ( keyFilePath ) ;
2026-05-24 00:06:46 -05:00
return string . Empty ;
2024-02-03 18:32:42 -06:00
}
2024-08-26 20:18:54 -05:00
public static async Task < string > GetCurrentKeyAsync ( string installDirectory , Guid gameId )
{
2024-10-04 23:37:49 -05:00
var keyFilePath = GetMetadataFilePath ( installDirectory , gameId , KeyFilename ) ;
2024-08-26 20:18:54 -05:00
if ( File . Exists ( keyFilePath ) )
return await File . ReadAllTextAsync ( keyFilePath ) ;
2026-05-24 00:06:46 -05:00
return string . Empty ;
2024-08-26 20:18:54 -05:00
}
2024-02-03 18:32:42 -06:00
public static void UpdateCurrentKey ( string installDirectory , Guid gameId , string newKey )
{
2024-10-04 23:37:49 -05:00
File . WriteAllText ( GetMetadataFilePath ( installDirectory , gameId , KeyFilename ) , newKey ) ;
2024-02-03 18:32:42 -06:00
}
2024-08-26 20:18:54 -05:00
public static async Task UpdateCurrentKeyAsync ( string installDirectory , Guid gameId , string newKey )
{
2024-10-04 23:37:49 -05:00
await File . WriteAllTextAsync ( GetMetadataFilePath ( installDirectory , gameId , KeyFilename ) , newKey ) ;
2024-08-26 20:18:54 -05:00
}
2023-11-10 00:29:16 -06:00
}
}