2026-04-17 22:24:47 -05:00
using LANCommander.Launcher.Data.Models ;
2024-08-04 17:53:19 -05:00
using LANCommander.Launcher.Models ;
2024-08-07 01:14:13 -05:00
using LANCommander.SDK.Enums ;
2024-05-25 15:40:31 -05:00
using LANCommander.SDK.Exceptions ;
2024-09-16 20:49:03 -05:00
using LANCommander.SDK.Extensions ;
2026-04-22 22:43:21 -05:00
using LANCommander.SDK.Helpers ;
2024-09-11 00:24:34 -05:00
using Microsoft.Extensions.Logging ;
2024-05-25 15:40:31 -05:00
using System.Collections.ObjectModel ;
using System.Diagnostics ;
2026-04-17 22:24:47 -05:00
using LANCommander.SDK.Models ;
2026-07-23 19:47:22 -05:00
using LANCommander.SDK.Plugins ;
using LANCommander.SDK.Plugins.Events ;
2025-01-29 22:52:27 -06:00
using LANCommander.SDK.Services ;
2026-06-21 21:16:36 -05:00
using Microsoft.EntityFrameworkCore ;
2026-04-17 22:24:47 -05:00
using Game = LANCommander . Launcher . Data . Models . Game ;
using Tool = LANCommander . Launcher . Data . Models . Tool ;
2024-05-25 15:40:31 -05:00
2024-08-04 17:53:19 -05:00
namespace LANCommander.Launcher.Services
2024-05-25 15:40:31 -05:00
{
2024-10-26 15:48:47 -05:00
public class InstallService : BaseService
2024-05-25 15:40:31 -05:00
{
2026-01-18 04:40:18 -06:00
private readonly GameService _gameService ;
2026-02-10 18:04:49 -06:00
private readonly ToolService _toolService ;
private readonly ImportService _importService ;
2026-01-18 04:40:18 -06:00
private readonly GameClient _gameClient ;
private readonly RedistributableClient _redistributableClient ;
2026-02-10 18:04:49 -06:00
private readonly ToolClient _toolClient ;
2026-01-18 04:40:18 -06:00
private readonly MediaClient _mediaClient ;
2026-07-23 19:47:22 -05:00
private readonly IPluginEventBus _pluginEventBus ;
2024-05-25 15:40:31 -05:00
private Stopwatch Stopwatch { get ; set ; }
2024-10-26 15:48:47 -05:00
public ObservableCollection < IInstallQueueItem > Queue { get ; set ; }
2026-04-17 22:24:47 -05:00
2025-01-31 00:34:37 -06:00
public delegate Task OnProgressHandler ( InstallProgress progress ) ;
2025-01-29 22:52:27 -06:00
public event OnProgressHandler OnProgress ;
2024-05-25 15:40:31 -05:00
2026-04-17 22:24:47 -05:00
public delegate Task OnTaskProgressUpdateHandler ( InstallTaskProgress progress ) ;
public event OnTaskProgressUpdateHandler OnTaskProgressUpdate ;
2024-06-02 17:23:36 -05:00
public delegate Task OnQueueChangedHandler ( ) ;
2024-05-25 15:40:31 -05:00
public event OnQueueChangedHandler OnQueueChanged ;
2024-06-24 19:30:43 -05:00
public delegate Task OnInstallCompleteHandler ( Game game ) ;
2024-05-29 02:02:40 -05:00
public event OnInstallCompleteHandler OnInstallComplete ;
2026-07-02 20:34:22 -05:00
public delegate Task OnToolInstallCompleteHandler ( Game game ) ;
public event OnToolInstallCompleteHandler OnToolInstallComplete ;
2026-06-20 04:25:01 -05:00
public delegate Task OnInstallQueueCompleteHandler ( Game game ) ;
public event OnInstallQueueCompleteHandler OnInstallQueueComplete ;
2024-06-24 19:30:43 -05:00
public delegate Task OnInstallFailHandler ( Game game ) ;
public event OnInstallFailHandler OnInstallFail ;
2026-06-20 04:25:01 -05:00
// Root game ids the user initiated this session that have not yet had a
// batch-complete notification fired. Used to scope notifications to active
// installs and to fire a single notification once a whole group settles.
private readonly HashSet < Guid > _pendingNotificationRoots = new ( ) ;
2024-10-26 15:48:47 -05:00
public InstallService (
ILogger < InstallService > logger ,
2026-01-18 04:40:18 -06:00
GameService gameService ,
2026-02-10 18:04:49 -06:00
ToolService toolService ,
ImportService importService ,
2026-01-18 04:40:18 -06:00
GameClient gameClient ,
RedistributableClient redistributableClient ,
2026-02-10 18:04:49 -06:00
ToolClient toolClient ,
2026-07-23 19:47:22 -05:00
MediaClient mediaClient ,
IPluginEventBus pluginEventBus ) : base ( logger )
2024-05-25 15:40:31 -05:00
{
2026-01-18 04:40:18 -06:00
_gameService = gameService ;
2026-02-10 18:04:49 -06:00
_toolService = toolService ;
_importService = importService ;
2026-01-18 04:40:18 -06:00
_gameClient = gameClient ;
_redistributableClient = redistributableClient ;
2026-02-10 18:04:49 -06:00
_toolClient = toolClient ;
2026-01-18 04:40:18 -06:00
_mediaClient = mediaClient ;
2026-07-23 19:47:22 -05:00
_pluginEventBus = pluginEventBus ;
// Bridge existing lifecycle events to the plugin event bus so plugins can react without
// touching every internal call site.
OnInstallComplete + = game = >
_pluginEventBus . PublishAsync ( new GameInstalledEvent ( game . Id , game . InstallDirectory ? ? string . Empty ) ) ;
OnInstallFail + = game = >
_pluginEventBus . PublishAsync ( new GameInstallFailedEvent ( game . Id , game . InstallDirectory ) ) ;
OnQueueChanged + = ( ) = >
_pluginEventBus . PublishAsync ( new InstallQueueChangedEvent ( ) ) ;
2026-04-17 22:24:47 -05:00
2024-05-25 15:40:31 -05:00
Stopwatch = new Stopwatch ( ) ;
2024-10-26 15:48:47 -05:00
Queue = new ObservableCollection < IInstallQueueItem > ( ) ;
2024-05-25 15:40:31 -05:00
Queue . CollectionChanged + = ( sender , e ) = >
{
OnQueueChanged ? . Invoke ( ) ;
2026-04-17 22:24:47 -05:00
} ;
2024-05-29 20:01:46 -05:00
2026-04-18 18:55:28 -05:00
// Legacy progress forwarding — also update the service queue item
// so that RefreshQueueAsync reads current values
2026-01-18 04:40:18 -06:00
_gameClient . OnInstallProgressUpdate + = ( e ) = >
2024-08-07 01:14:13 -05:00
{
2026-04-18 18:55:28 -05:00
UpdateQueueItemFromProgress ( e ) ;
2025-01-29 22:52:27 -06:00
OnProgress ? . Invoke ( e ) ;
2024-08-07 01:14:13 -05:00
} ;
2026-06-14 19:40:47 -05:00
// Note: RedistributableClient progress is intentionally NOT forwarded here.
// Its InstallProgress never carries a Game, so it can't be matched to a queue
// item, and redistributables are also installed/verified during game launch —
// forwarding those events would drive the queue footer and taskbar with
// out-of-band progress when nothing is actually queued. The game-level
// "Installing Redistributables" status (raised by GameClient with the owning
// game attached) still surfaces the redist phase of a queued install.
2025-02-04 02:34:31 -06:00
2026-04-17 22:24:47 -05:00
// New task-level progress forwarding
_gameClient . OnTaskProgress + = OnSdkTaskProgress ;
_toolClient . OnTaskProgress + = OnSdkTaskProgress ;
2024-05-29 20:01:46 -05:00
}
2026-04-18 18:55:28 -05:00
private void UpdateQueueItemFromProgress ( InstallProgress progress )
{
if ( progress . Game = = null )
return ;
var queueItem = Queue . FirstOrDefault ( i = > i . Id = = progress . Game . Id ) ;
if ( queueItem ! = null )
{
queueItem . BytesDownloaded = progress . BytesTransferred ;
queueItem . TotalBytes = progress . TotalBytes ;
queueItem . TransferSpeed = progress . TransferSpeed ;
}
}
2026-04-17 22:24:47 -05:00
private void OnSdkTaskProgress ( InstallTaskProgress taskProgress )
2024-05-29 20:01:46 -05:00
{
2026-04-17 22:24:47 -05:00
// Update the matching queue item's current task and progress
var queueItem = Queue . FirstOrDefault ( i = > i . Id = = taskProgress . QueueItemId ) ;
2024-05-29 20:01:46 -05:00
2026-04-17 22:24:47 -05:00
if ( queueItem ! = null )
{
queueItem . CurrentTaskId = taskProgress . TaskId ;
if ( taskProgress . TaskStatus = = InstallTaskStatus . Running & & taskProgress . TotalBytes > 0 )
{
queueItem . BytesDownloaded = taskProgress . BytesTransferred ;
queueItem . TotalBytes = taskProgress . TotalBytes ;
queueItem . TransferSpeed = taskProgress . TransferSpeed ;
}
}
OnTaskProgressUpdate ? . Invoke ( taskProgress ) ;
2024-05-29 20:01:46 -05:00
OnQueueChanged ? . Invoke ( ) ;
2024-05-25 15:40:31 -05:00
}
2025-05-15 03:04:48 +02:00
[Obsolete("Use Add(Game, string, Game[] ) instead . ")]
public async Task AddObsolete ( Game game , string installDirectory = "" , Guid [ ] ? addonIds = null )
{
2026-01-18 04:40:18 -06:00
var addons = addonIds ! = null ? await _gameClient . GetAddonsAsync ( game . Id ) : [ ] ;
2025-05-15 03:04:48 +02:00
var selectedAddons = addons ? . Where ( x = > addons . Contains ( x ) ) . ToArray ( ) ;
await Add ( game , installDirectory , selectedAddons ) ;
}
2026-05-14 00:28:31 -05:00
public async Task Add ( Game game , string installDirectory = "" , SDK . Models . Game [ ] ? addons = null , SDK . Models . Tool [ ] ? tools = null )
2024-05-25 15:40:31 -05:00
{
2026-01-18 04:40:18 -06:00
var gameInfo = await _gameClient . GetAsync ( game . Id ) ;
2026-04-17 22:24:47 -05:00
2026-01-18 04:40:18 -06:00
// TODO: Throw exception (and gracefully handle) when gameInfo == null
// Game probably couldn't be found or deserialized from server
2024-05-25 15:40:31 -05:00
2026-04-17 23:52:22 -05:00
Logger ? . LogInformation ( "[InstallQueue] Add: Adding game {GameTitle} ({GameId}) to the queue, installDirectory={InstallDirectory}, addonCount={AddonCount}" ,
gameInfo . Title , gameInfo . Id , installDirectory , addons ? . Length ? ? 0 ) ;
2024-09-16 20:49:03 -05:00
2024-05-25 15:40:31 -05:00
// Check to see if we need to install the base game (this game is probably a mod or expansion)
2024-11-12 02:17:26 -06:00
if ( gameInfo . BaseGameId ! = Guid . Empty )
2024-05-25 15:40:31 -05:00
{
2026-04-17 23:52:22 -05:00
Logger ? . LogInformation ( "[InstallQueue] Add: Game {GameTitle} has BaseGameId={BaseGameId}, checking if base game needs install" , gameInfo . Title , gameInfo . BaseGameId ) ;
2026-01-18 04:40:18 -06:00
var baseGame = await _gameService . GetAsync ( gameInfo . BaseGameId ) ;
2024-05-25 15:40:31 -05:00
if ( baseGame ! = null & & ! baseGame . Installed )
{
2026-04-17 23:52:22 -05:00
Logger ? . LogInformation ( "[InstallQueue] Add: Base game {BaseGameTitle} ({BaseGameId}) is not installed, adding it first" , baseGame . Title , baseGame . Id ) ;
2024-09-12 19:09:13 -05:00
await Add ( baseGame , installDirectory ) ;
2024-05-25 15:40:31 -05:00
}
2026-04-17 23:52:22 -05:00
else
{
Logger ? . LogInformation ( "[InstallQueue] Add: Base game is {Status}" , baseGame = = null ? "not found in local DB" : "already installed" ) ;
}
2024-05-25 15:40:31 -05:00
}
2026-04-22 22:43:21 -05:00
if ( Queue . Any ( i = > i . Id = = game . Id & & i . Status = = InstallStatus . Queued ) )
{
Logger ? . LogInformation ( "[InstallQueue] Add: Game {GameTitle} ({GameId}) already queued, skipping" , gameInfo . Title , game . Id ) ;
return ;
}
// Generate install plan from SDK
var addonIds = addons ? . Select ( x = > x . Id ) . ToArray ( ) ;
2026-05-14 00:28:31 -05:00
var toolIds = tools ? . Select ( x = > x . Id ) . ToArray ( ) ;
Logger ? . LogInformation ( "[InstallQueue] Add: Generating install plan for {GameTitle} ({GameId}) with {AddonCount} addons and {ToolCount} tools" ,
gameInfo . Title , game . Id , addonIds ? . Length ? ? 0 , toolIds ? . Length ? ? 0 ) ;
var plan = await _gameClient . GenerateInstallPlanAsync ( game . Id , installDirectory , addonIds , toolIds ) ;
2026-04-22 22:43:21 -05:00
// Clear all non-active items for entities in the plan to avoid stale history
2024-10-26 15:48:47 -05:00
try
{
2026-04-22 22:43:21 -05:00
var planEntityIds = plan . Items . Select ( i = > i . EntityId ) . ToHashSet ( ) ;
planEntityIds . Add ( game . Id ) ;
2024-10-26 15:48:47 -05:00
2026-04-22 22:43:21 -05:00
var staleItems = Queue . Where ( i = > ! i . State & & planEntityIds . Contains ( i . Id ) ) . ToList ( ) ;
foreach ( var queueItem in staleItems )
2024-10-26 15:48:47 -05:00
{
Queue . Remove ( queueItem ) ;
}
OnQueueChanged ? . Invoke ( ) ;
}
catch ( Exception ex )
{
2026-04-22 22:43:21 -05:00
Logger ? . LogWarning ( ex , "[InstallQueue] Add: Error clearing stale queue items for {GameId}" , game . Id ) ;
2026-04-17 23:52:22 -05:00
}
2024-05-25 15:40:31 -05:00
2026-04-17 23:52:22 -05:00
Logger ? . LogInformation ( "[InstallQueue] Add: Plan generated with {ItemCount} items: {Items}" ,
plan . Items . Count ,
string . Join ( ", " , plan . Items . OrderBy ( i = > i . Order ) . Select ( i = > $"[{i.Order}] {i.Type}:{i.Title} (id={i.EntityId}, depends={i.DependsOnId})" ) ) ) ;
2026-04-17 22:24:47 -05:00
// Add each plan item to the queue
foreach ( var planItem in plan . Items . OrderBy ( i = > i . Order ) )
{
// Skip if already queued
if ( Queue . Any ( i = > i . Id = = planItem . EntityId & & i . Status . ValueIsIn ( InstallStatus . Queued , InstallStatus . Starting , InstallStatus . Downloading ) ) )
2026-04-17 23:52:22 -05:00
{
Logger ? . LogInformation ( "[InstallQueue] Add: Skipping plan item {Title} ({EntityId}), already in queue" , planItem . Title , planItem . EntityId ) ;
2026-04-17 22:24:47 -05:00
continue ;
2026-04-17 23:52:22 -05:00
}
2025-05-15 03:04:48 +02:00
2026-04-17 22:24:47 -05:00
IInstallQueueItem queueItem ;
2024-10-09 19:23:42 -05:00
2026-04-17 22:24:47 -05:00
switch ( planItem . Type )
2024-05-25 15:40:31 -05:00
{
2026-04-17 22:24:47 -05:00
case InstallPlanItemType . Game :
case InstallPlanItemType . Addon :
var addonGame = planItem . Type = = InstallPlanItemType . Addon
? await _gameClient . GetAsync ( planItem . EntityId )
: gameInfo ;
queueItem = new InstallQueueGame ( planItem , addonGame ) ;
if ( addons ! = null & & planItem . Type = = InstallPlanItemType . Game )
{
var gameQueueItem = ( InstallQueueGame ) queueItem ;
gameQueueItem . AddonIds = addonIds ;
gameQueueItem . AddonVersions = addons . ToDictionary (
a = > a . Id ,
a = > a . Archives ? . OrderByDescending ( ar = > ar . CreatedOn ) . FirstOrDefault ( ) ? . Version ) ;
}
2026-06-08 19:29:09 -05:00
2026-06-21 21:16:36 -05:00
if ( planItem . Type = = InstallPlanItemType . Game )
( ( InstallQueueGame ) queueItem ) . ToolIds = toolIds ? ? [ ] ;
2026-06-08 19:29:09 -05:00
// Flag as update if game is already installed with a different version
if ( game . Installed & & ! string . IsNullOrWhiteSpace ( queueItem . Version )
& & queueItem . Version ! = game . InstalledVersion )
{
( ( InstallQueueGame ) queueItem ) . IsUpdate = true ;
}
2026-04-17 22:24:47 -05:00
break ;
case InstallPlanItemType . Redistributable :
var redist = gameInfo . Redistributables ? . FirstOrDefault ( r = > r . Id = = planItem . EntityId ) ;
if ( redist = = null )
2026-04-17 23:52:22 -05:00
{
Logger ? . LogInformation ( "[InstallQueue] Add: Redistributable {EntityId} not found in game redistributables, skipping" , planItem . EntityId ) ;
2026-04-17 22:24:47 -05:00
continue ;
2026-04-17 23:52:22 -05:00
}
2026-04-17 22:24:47 -05:00
queueItem = new DownloadQueueRedistributable ( planItem , redist ) ;
break ;
case InstallPlanItemType . Tool :
var tool = await _toolClient . GetAsync ( planItem . EntityId ) ;
queueItem = new InstallQueueTool ( planItem , tool ) ;
break ;
default :
continue ;
}
2024-05-25 15:40:31 -05:00
2026-04-17 23:52:22 -05:00
Logger ? . LogInformation ( "[InstallQueue] Add: Enqueuing {Type} {Title} ({Id}), dependsOn={DependsOn}, taskCount={TaskCount}" ,
queueItem . ItemType , queueItem . Title , queueItem . Id , queueItem . DependsOnId , queueItem . Tasks ? . Count ? ? 0 ) ;
2026-04-17 22:24:47 -05:00
Queue . Add ( queueItem ) ;
}
2024-05-25 15:40:31 -05:00
2026-04-17 23:52:22 -05:00
Logger ? . LogInformation ( "[InstallQueue] Add: Queue now has {Count} items: {Items}" ,
Queue . Count ,
string . Join ( ", " , Queue . Select ( i = > $"{i.Title}({i.Status}, depends={i.DependsOnId})" ) ) ) ;
2026-06-20 04:25:01 -05:00
// Track this root so a single batch-complete notification fires once the
// whole group (base game + addons/redists/tools) has settled.
_pendingNotificationRoots . Add ( game . Id ) ;
2026-04-17 22:24:47 -05:00
// Start processing if nothing active
if ( ! Queue . Any ( i = > i . State ) )
{
var firstItem = Queue . FirstOrDefault ( i = > i . Status = = InstallStatus . Queued ) ;
if ( firstItem ! = null )
{
2026-04-17 23:52:22 -05:00
Logger ? . LogInformation ( "[InstallQueue] Add: No active items, starting first queued item: {Title} ({Id})" , firstItem . Title , firstItem . Id ) ;
2026-04-17 22:24:47 -05:00
firstItem . Status = InstallStatus . Starting ;
2024-10-26 15:48:47 -05:00
await Next ( ) ;
2024-05-25 15:40:31 -05:00
}
2026-04-17 23:52:22 -05:00
else
{
Logger ? . LogInformation ( "[InstallQueue] Add: No active items and no queued items to start" ) ;
}
}
else
{
Logger ? . LogInformation ( "[InstallQueue] Add: Queue already has active items, not auto-starting" ) ;
2024-05-25 15:40:31 -05:00
}
2026-04-17 22:24:47 -05:00
OnQueueChanged ? . Invoke ( ) ;
2024-05-25 15:40:31 -05:00
}
2026-04-17 22:24:47 -05:00
2026-02-10 18:04:49 -06:00
public async Task Add ( SDK . Models . Tool tool , string installDirectory = "" )
{
var toolInfo = await _toolClient . GetAsync ( tool . Id ) ;
2026-04-17 22:24:47 -05:00
Logger ? . LogTrace ( "Adding tool {ToolName} to the queue" , toolInfo . Name ) ;
2026-02-10 18:04:49 -06:00
try
{
var toolCompletedQueueItems = Queue . Where ( i = > i . Status = = InstallStatus . Complete & & i . Id = = tool . Id ) . ToList ( ) ;
foreach ( var queueItem in toolCompletedQueueItems )
{
Queue . Remove ( queueItem ) ;
}
OnQueueChanged ? . Invoke ( ) ;
}
catch ( Exception ex )
{
}
2026-04-17 22:24:47 -05:00
if ( Queue . Any ( i = > i . Id = = tool . Id & & i . Status = = InstallStatus . Queued ) )
return ;
2026-02-10 18:04:49 -06:00
2026-04-17 22:24:47 -05:00
// Generate install plan from SDK
var plan = await _toolClient . GenerateInstallPlanAsync ( toolInfo , installDirectory ) ;
2026-02-10 18:04:49 -06:00
2026-04-17 22:24:47 -05:00
foreach ( var planItem in plan . Items . OrderBy ( i = > i . Order ) )
{
if ( Queue . Any ( i = > i . Id = = planItem . EntityId & & i . Status . ValueIsIn ( InstallStatus . Queued , InstallStatus . Starting , InstallStatus . Downloading ) ) )
continue ;
2026-02-10 18:04:49 -06:00
2026-04-17 22:24:47 -05:00
var queueItem = new InstallQueueTool ( planItem , toolInfo ) ;
Queue . Add ( queueItem ) ;
}
2026-02-10 18:04:49 -06:00
2026-04-17 22:24:47 -05:00
if ( ! Queue . Any ( i = > i . State ) )
{
var firstItem = Queue . FirstOrDefault ( i = > i . Status = = InstallStatus . Queued ) ;
if ( firstItem ! = null )
{
firstItem . Status = InstallStatus . Starting ;
2026-02-10 18:04:49 -06:00
await Next ( ) ;
}
}
2026-04-17 22:24:47 -05:00
OnQueueChanged ? . Invoke ( ) ;
2026-02-10 18:04:49 -06:00
}
2024-05-25 15:40:31 -05:00
2024-06-09 01:03:29 -05:00
public void Remove ( Guid id )
{
var queueItem = Queue . FirstOrDefault ( i = > i . Id = = id ) ;
2024-09-16 20:49:03 -05:00
if ( queueItem ! = null )
{
2026-04-17 22:24:47 -05:00
Logger ? . LogTrace ( "Removing the item {Title} from the queue" , queueItem . Title ) ;
2024-09-16 20:49:03 -05:00
Remove ( queueItem ) ;
}
2024-06-09 01:03:29 -05:00
}
2024-10-26 15:48:47 -05:00
public void Remove ( IInstallQueueItem queueItem )
2024-06-09 01:03:29 -05:00
{
if ( queueItem ! = null )
2024-09-16 20:49:03 -05:00
{
2026-04-17 22:24:47 -05:00
Logger ? . LogTrace ( "Removing the item {Title} from the queue" , queueItem . Title ) ;
2024-09-16 20:49:03 -05:00
2024-06-09 01:03:29 -05:00
Queue . Remove ( queueItem ) ;
2024-09-16 20:49:03 -05:00
}
2024-06-09 01:03:29 -05:00
}
2026-04-22 22:43:21 -05:00
public void ClearCompleted ( Guid gameId )
{
var staleItems = Queue . Where ( i = > ! i . State & & ( i . Id = = gameId | | i . DependsOnId = = gameId ) ) . ToList ( ) ;
foreach ( var item in staleItems )
{
Logger ? . LogTrace ( "Clearing stale queue item {Title} ({Id}) for game {GameId}" , item . Title , item . Id , gameId ) ;
Queue . Remove ( item ) ;
}
if ( staleItems . Count > 0 )
OnQueueChanged ? . Invoke ( ) ;
}
2025-10-15 21:26:40 -05:00
public async Task CancelInstallAsync ( Guid queueItemId )
2024-05-25 15:40:31 -05:00
{
2025-10-15 21:26:40 -05:00
var queueItem = Queue . FirstOrDefault ( i = > i . Id = = queueItemId ) ;
2026-04-17 22:24:47 -05:00
if ( queueItem = = null )
return ;
2025-10-15 21:26:40 -05:00
await queueItem . CancellationToken . CancelAsync ( ) ;
2026-04-17 22:24:47 -05:00
2025-10-15 21:26:40 -05:00
queueItem . Status = InstallStatus . Canceled ;
2026-04-17 22:24:47 -05:00
2025-10-15 21:26:40 -05:00
OnQueueChanged ? . Invoke ( ) ;
2026-04-17 22:24:47 -05:00
2025-10-15 21:26:40 -05:00
Logger ? . LogTrace ( "Canceling install queue item {QueueItem}" , queueItem . Title ) ;
2024-05-25 15:40:31 -05:00
}
2024-10-26 15:48:47 -05:00
public async Task Next ( )
2024-05-25 15:40:31 -05:00
{
2026-04-17 23:52:22 -05:00
Logger ? . LogInformation ( "[InstallQueue] Next: Evaluating queue. Total items: {Count}, statuses: {Statuses}" ,
Queue . Count ,
string . Join ( ", " , Queue . Select ( i = > $"{i.Title}({i.Status}, type={i.ItemType}, depends={i.DependsOnId})" ) ) ) ;
var pendingItems = Queue . Where ( i = > i . Status . ValueIsIn ( InstallStatus . Queued , InstallStatus . Starting ) ) . ToList ( ) ;
Logger ? . LogInformation ( "[InstallQueue] Next: Found {Count} pending items" , pendingItems . Count ) ;
2024-05-25 15:40:31 -05:00
2026-04-17 22:24:47 -05:00
foreach ( var candidate in pendingItems )
{
// Check dependency — skip items whose dependency hasn't completed
if ( candidate . DependsOnId . HasValue )
{
var dependency = Queue . FirstOrDefault ( i = > i . Id = = candidate . DependsOnId . Value ) ;
if ( dependency ! = null & & dependency . Status ! = InstallStatus . Complete )
2026-04-17 23:52:22 -05:00
{
Logger ? . LogInformation ( "[InstallQueue] Next: Skipping {Title} ({Id}) — dependency {DepTitle} ({DepId}) is {DepStatus}" ,
candidate . Title , candidate . Id , dependency . Title , dependency . Id , dependency . Status ) ;
2026-04-17 22:24:47 -05:00
continue ;
2026-04-17 23:52:22 -05:00
}
if ( dependency = = null )
{
Logger ? . LogInformation ( "[InstallQueue] Next: {Title} ({Id}) depends on {DependsOnId} but dependency not in queue (assumed complete)" ,
candidate . Title , candidate . Id , candidate . DependsOnId . Value ) ;
}
2026-04-17 22:24:47 -05:00
}
2026-04-17 23:52:22 -05:00
Logger ? . LogInformation ( "[InstallQueue] Next: Processing eligible item: {Title} ({Id}), type={Type}, clrType={ClrType}" ,
candidate . Title , candidate . Id , candidate . ItemType , candidate . GetType ( ) . Name ) ;
2026-04-17 22:24:47 -05:00
// Found an eligible item — process it
switch ( candidate )
{
case InstallQueueGame gameQueueItem :
await Next ( gameQueueItem ) ;
return ;
2024-05-25 15:40:31 -05:00
2026-04-17 22:24:47 -05:00
case InstallQueueTool toolQueueItem :
await Next ( toolQueueItem ) ;
return ;
2026-02-10 18:04:49 -06:00
2026-04-17 22:24:47 -05:00
case DownloadQueueRedistributable redistQueueItem :
await Next ( redistQueueItem ) ;
return ;
}
2026-04-17 23:52:22 -05:00
Logger ? . LogInformation ( "[InstallQueue] Next: Item {Title} ({Id}) did not match any known type: {ClrType}" , candidate . Title , candidate . Id , candidate . GetType ( ) . Name ) ;
2026-04-17 22:24:47 -05:00
}
2026-04-17 23:52:22 -05:00
Logger ? . LogInformation ( "[InstallQueue] Next: No eligible items found to process" ) ;
2026-06-20 04:25:01 -05:00
// The queue has settled (nothing eligible to process). Fire a single
// batch-complete notification for any tracked root whose entire group has
// finished.
await NotifySettledGroups ( ) ;
}
// Walks the DependsOnId chain up to the root install item (the base game with no
// dependency) so an item can be attributed to its install group.
private Guid ResolveRootId ( IInstallQueueItem item )
{
var current = item ;
var visited = new HashSet < Guid > ( ) ;
while ( current . DependsOnId . HasValue & & visited . Add ( current . Id ) )
{
var parent = Queue . FirstOrDefault ( i = > i . Id = = current . DependsOnId . Value ) ;
// Parent no longer in queue (assumed complete) — treat the dependency id as the root.
if ( parent = = null )
return current . DependsOnId . Value ;
current = parent ;
}
return current . Id ;
}
private async Task NotifySettledGroups ( )
{
foreach ( var rootId in _pendingNotificationRoots . ToList ( ) )
{
var groupItems = Queue . Where ( i = > ResolveRootId ( i ) = = rootId ) . ToList ( ) ;
// No items map to this root (e.g. user installed an addon whose real root
// is its base game) — nothing to notify for, drop it.
if ( groupItems . Count = = 0 )
{
_pendingNotificationRoots . Remove ( rootId ) ;
continue ;
}
var allTerminal = groupItems . All ( i = >
i . Status . ValueIsIn ( InstallStatus . Complete , InstallStatus . Failed , InstallStatus . Canceled ) ) ;
var rootItem = groupItems . FirstOrDefault ( i = > i . Id = = rootId ) ;
// Wait until everything in the group has settled, and only announce
// completion when the base game itself actually installed.
if ( ! allTerminal | | rootItem = = null | | rootItem . Status ! = InstallStatus . Complete )
continue ;
_pendingNotificationRoots . Remove ( rootId ) ;
var rootGame = await _gameService . GetAsync ( rootId ) ;
if ( rootGame ! = null )
{
Logger ? . LogInformation ( "[InstallQueue] NotifySettledGroups: Install batch complete for {Title} ({Id}), firing notification" , rootGame . Title , rootGame . Id ) ;
OnInstallQueueComplete ? . Invoke ( rootGame ) ;
}
}
2026-02-10 18:04:49 -06:00
}
private async Task Next ( InstallQueueGame queueItem )
{
2026-04-17 23:52:22 -05:00
Logger ? . LogInformation ( "[InstallQueue] Next(Game): Processing game queue item {Title} ({Id}), itemType={ItemType}, installDir={InstallDir}, taskCount={TaskCount}" ,
queueItem . Title , queueItem . Id , queueItem . ItemType , queueItem . InstallDirectory , queueItem . Tasks ? . Count ? ? 0 ) ;
2024-10-26 15:48:47 -05:00
Game localGame = null ;
SDK . Models . Game remoteGame = null ;
2024-05-25 15:40:31 -05:00
try
{
2026-02-10 18:04:49 -06:00
localGame = await _gameService . GetAsync ( queueItem . Id ) ;
remoteGame = await _gameClient . GetAsync ( queueItem . Id ) ;
2024-09-16 20:49:03 -05:00
2026-04-17 23:52:22 -05:00
Logger ? . LogInformation ( "[InstallQueue] Next(Game): localGame={LocalFound}, remoteGame={RemoteFound}, localInstalled={Installed}" ,
localGame ! = null , remoteGame ! = null , localGame ? . Installed ) ;
2024-10-26 15:48:47 -05:00
if ( localGame = = null )
2024-09-16 20:49:03 -05:00
{
2026-04-22 22:43:21 -05:00
Logger ? . LogInformation ( "[InstallQueue] Next(Game): Game {Id} does not exist in local database, importing" , queueItem . Id ) ;
await _importService . ImportGameAsync ( queueItem . Id ) ;
localGame = await _gameService . GetAsync ( queueItem . Id ) ;
if ( localGame = = null )
{
Logger ? . LogError ( "[InstallQueue] Next(Game): Game {Id} could not be imported, skipping" , queueItem . Id ) ;
Remove ( queueItem ) ;
OnQueueChanged ? . Invoke ( ) ;
return ;
}
2024-09-16 20:49:03 -05:00
}
2024-10-26 15:48:47 -05:00
if ( remoteGame = = null )
2024-09-16 20:49:03 -05:00
{
2026-04-17 23:52:22 -05:00
Logger ? . LogInformation ( "[InstallQueue] Next(Game): Game {Id} info could not be retrieved from the server" , queueItem . Id ) ;
2024-10-26 15:48:47 -05:00
2026-02-10 18:04:49 -06:00
queueItem . Status = InstallStatus . Failed ;
2024-10-26 15:48:47 -05:00
OnQueueChanged ? . Invoke ( ) ;
2024-09-16 20:49:03 -05:00
return ;
}
2024-10-26 15:48:47 -05:00
2026-06-30 22:23:38 -05:00
if ( queueItem . TargetVersion ! = null )
{
await SwitchToVersion ( queueItem , localGame , remoteGame ) ;
await Next ( ) ;
return ;
}
2026-04-22 22:43:21 -05:00
if ( localGame . Installed & & ! queueItem . DependsOnId . HasValue
& & ! string . IsNullOrEmpty ( localGame . InstallDirectory )
& & ManifestHelper . Exists ( localGame . InstallDirectory , localGame . Id ) )
2024-10-26 15:48:47 -05:00
{
2026-06-08 19:00:56 -05:00
// Check if this is an update (versions differ)
2026-06-08 19:29:09 -05:00
var isUpdate = queueItem . IsUpdate
| | ( ! string . IsNullOrWhiteSpace ( localGame . LatestVersion )
& & localGame . InstalledVersion ! = localGame . LatestVersion )
| | ( ! string . IsNullOrWhiteSpace ( queueItem . Version )
& & queueItem . Version ! = localGame . InstalledVersion ) ;
2025-05-17 02:02:57 +02:00
2026-06-08 19:00:56 -05:00
if ( isUpdate )
2025-05-15 03:04:48 +02:00
{
2026-06-08 19:00:56 -05:00
await Update ( queueItem , localGame , remoteGame ) ;
2025-05-15 03:04:48 +02:00
}
2024-10-26 15:48:47 -05:00
else
{
2026-06-08 19:00:56 -05:00
// update current local installed game first, might be moved afterwards
await _gameClient . UpdateGameInstallationAsync ( localGame . InstallDirectory , remoteGame ) ;
2026-06-10 21:51:12 -05:00
// Check for and apply redistributable updates
await UpdateRedistributablesForGameAsync ( localGame . InstallDirectory , remoteGame . Redistributables ) ;
2026-06-08 19:00:56 -05:00
// Probably doing a modification of some sort
if ( localGame . InstallDirectory . StartsWith ( queueItem . InstallDirectory ) )
{
2026-07-01 19:37:04 -05:00
var allAddons = ( remoteGame . DependentGames ? ? [ ] ) . ToArray ( ) ;
2026-06-08 19:00:56 -05:00
var removeAddons = allAddons . Except ( queueItem . AddonIds ? ? [ ] ) . ToArray ( ) ;
var addAddons = allAddons . Intersect ( queueItem . AddonIds ? ? [ ] ) . ToArray ( ) ;
var uninstallResult = await _gameClient . UninstallAddonsAsync ( localGame . InstallDirectory , localGame . Id , removeAddons ) ;
var installResult = await _gameClient . InstallAddonsAsync ( localGame . InstallDirectory , localGame . Id , addAddons ) ;
await _gameClient . RestoreFilesAsync ( localGame . InstallDirectory , localGame . Id , uninstallResult . FileList , installResult . FileList ) ;
2026-06-21 21:16:36 -05:00
// Uninstall any tools that were deselected. Selected tools are installed
2026-06-27 11:51:55 -05:00
// via their own queue items, so we only handle removal here. Tool install
// state is tracked per game, so this only affects this game's copy.
2026-06-21 21:16:36 -05:00
var selectedToolIds = queueItem . ToolIds ? ? [ ] ;
2026-06-27 11:51:55 -05:00
var installedTools = await _toolService . GetInstalledToolsForGameAsync ( localGame . Id ) ;
2026-06-21 21:16:36 -05:00
2026-06-27 11:51:55 -05:00
foreach ( var gameTool in installedTools . Where ( gt = > ! selectedToolIds . Contains ( gt . ToolId ) ) )
2026-06-21 21:16:36 -05:00
{
try
{
2026-06-27 11:51:55 -05:00
await _toolClient . UninstallAsync ( localGame . InstallDirectory , gameTool . ToolId ) ;
2026-06-21 21:16:36 -05:00
2026-06-27 11:51:55 -05:00
await _toolService . SetToolUninstalledAsync ( localGame . Id , gameTool . ToolId ) ;
2026-06-21 21:16:36 -05:00
}
catch ( Exception ex )
{
2026-06-27 11:51:55 -05:00
Logger ? . LogError ( ex , "Could not uninstall tool {ToolId} from game {GameId}" , gameTool . ToolId , localGame . Id ) ;
2026-06-21 21:16:36 -05:00
}
}
2026-06-08 19:00:56 -05:00
UpdateGameState ( queueItem , localGame , localGame . InstallDirectory ) ;
UpdateAddonStates ( queueItem , localGame ) ;
await _gameService . UpdateAsync ( localGame ) ;
queueItem . Status = InstallStatus . Complete ;
OnQueueChanged ? . Invoke ( ) ;
OnInstallComplete ? . Invoke ( localGame ) ;
}
else
{
await Move ( queueItem , localGame , remoteGame ) ;
}
2024-10-26 15:48:47 -05:00
}
2026-04-08 18:07:32 -05:00
await Next ( ) ;
2024-10-26 15:48:47 -05:00
}
else
{
2026-02-10 18:04:49 -06:00
await Install ( queueItem , localGame , remoteGame ) ;
2024-10-26 15:48:47 -05:00
}
2024-05-25 15:40:31 -05:00
}
catch ( Exception ex )
{
2026-04-22 22:43:21 -05:00
Logger ? . LogError ( ex , "An unknown error occured while trying to process game {GameTitle} ({GameId})" , queueItem . Title , queueItem . Id ) ;
queueItem . Status = InstallStatus . Failed ;
OnQueueChanged ? . Invoke ( ) ;
await Next ( ) ;
2024-05-25 15:40:31 -05:00
}
2024-10-26 15:48:47 -05:00
}
2024-05-25 15:40:31 -05:00
2026-02-10 18:04:49 -06:00
private async Task Next ( InstallQueueTool queueItem )
{
Tool localTool = null ;
SDK . Models . Tool remoteTool = null ;
try
{
localTool = await _toolService . GetAsync ( queueItem . Id ) ;
remoteTool = await _toolClient . GetAsync ( queueItem . Id ) ;
2026-04-17 22:24:47 -05:00
2026-02-10 18:04:49 -06:00
if ( remoteTool = = null )
{
Logger ? . LogError ( "Tool info could not be retrieved from the server" ) ;
queueItem . Status = InstallStatus . Failed ;
OnQueueChanged ? . Invoke ( ) ;
return ;
}
if ( localTool = = null )
{
Logger ? . LogError ( "Tool does not exist in local database, importing" ) ;
2026-04-17 22:24:47 -05:00
2026-02-10 18:04:49 -06:00
await _importService . ImportToolAsync ( queueItem . Id ) ;
await Next ( queueItem ) ;
return ;
}
2026-06-27 11:51:55 -05:00
var alreadyInstalled = queueItem . DependsOnId . HasValue
& & await _toolService . IsToolInstalledForGameAsync ( queueItem . DependsOnId . Value , localTool . Id ) ;
if ( alreadyInstalled )
2026-02-10 18:04:49 -06:00
{
2026-04-17 22:24:47 -05:00
// Modify — currently no-op
2026-02-10 18:04:49 -06:00
}
else
{
await Install ( queueItem , localTool , remoteTool ) ;
}
}
catch
{
}
}
2026-04-17 22:24:47 -05:00
private async Task Next ( DownloadQueueRedistributable queueItem )
{
try
{
2026-06-10 21:51:12 -05:00
if ( queueItem . IsUpdate )
await UpdateRedistributable ( queueItem ) ;
else
await InstallRedistributable ( queueItem ) ;
2026-04-17 22:24:47 -05:00
}
catch ( Exception ex )
{
Logger ? . LogError ( ex , "An error occurred while installing redistributable {Title}" , queueItem . Title ) ;
}
}
2026-02-10 18:04:49 -06:00
public async Task Install ( InstallQueueGame currentItem , Game localGame , SDK . Models . Game remoteGame )
2024-10-26 15:48:47 -05:00
{
using ( var operation = Logger . BeginOperation ( "Installing game {GameTitle} ({GameId})" , localGame . Title , localGame . Id ) )
2024-06-20 17:14:59 -05:00
{
2026-04-17 23:52:22 -05:00
Logger ? . LogInformation ( "[InstallQueue] Install(Game): Starting install of {Title} ({Id}), itemType={ItemType}, installDir={InstallDir}, taskCount={TaskCount}, tasks={Tasks}" ,
currentItem . Title , currentItem . Id , currentItem . ItemType , currentItem . InstallDirectory ,
currentItem . Tasks ? . Count ? ? 0 ,
string . Join ( ", " , ( currentItem . Tasks ? ? [ ] ) . Select ( t = > $"{t.Type}:{t.Title}" ) ) ) ;
2025-01-31 00:34:37 -06:00
currentItem . Status = InstallStatus . Downloading ;
2024-10-26 15:48:47 -05:00
OnQueueChanged ? . Invoke ( ) ;
2026-07-23 19:47:22 -05:00
await _pluginEventBus . PublishAsync ( new GameInstallingEvent ( localGame . Id , currentItem . InstallDirectory ) ) ;
2024-09-16 20:49:03 -05:00
try
2024-06-20 17:14:59 -05:00
{
2026-04-17 22:24:47 -05:00
// Build a plan item from the queue item's tasks
var planItem = new InstallPlanItem
{
EntityId = currentItem . Id ,
Title = currentItem . Title ,
Type = currentItem . ItemType ,
InstallDirectory = currentItem . InstallDirectory ,
Tasks = currentItem . Tasks ,
} ;
2026-04-17 23:52:22 -05:00
Logger ? . LogInformation ( "[InstallQueue] Install(Game): Executing plan item with {TaskCount} tasks, type={Type}" , planItem . Tasks ? . Count ? ? 0 , planItem . Type ) ;
2026-04-17 22:24:47 -05:00
var result = await _gameClient . ExecuteInstallPlanItemAsync ( planItem , currentItem . CancellationToken . Token ) ;
2026-04-17 23:52:22 -05:00
Logger ? . LogInformation ( "[InstallQueue] Install(Game): ExecuteInstallPlanItemAsync completed, installDir={InstallDir}" , result . InstallDirectory ) ;
2026-04-17 22:24:47 -05:00
UpdateGameState ( currentItem , localGame , result . InstallDirectory ) ;
2024-09-16 20:49:03 -05:00
}
catch ( InstallCanceledException ex )
{
Logger ? . LogError ( "Install canceled, removing from queue" ) ;
Queue . Remove ( currentItem ) ;
return ;
}
catch ( InstallException ex )
{
Logger ? . LogError ( ex , "An error occurred during install, removing from queue" ) ;
2026-04-17 22:24:47 -05:00
currentItem . Status = InstallStatus . Failed ;
OnQueueChanged ? . Invoke ( ) ;
OnInstallFail ? . Invoke ( localGame ) ;
await Next ( ) ;
2024-09-16 20:49:03 -05:00
return ;
}
catch ( Exception ex )
{
Logger ? . LogError ( ex , "An unknown error occurred during install, removing from queue" ) ;
2026-04-17 22:24:47 -05:00
currentItem . Status = InstallStatus . Failed ;
OnQueueChanged ? . Invoke ( ) ;
OnInstallFail ? . Invoke ( localGame ) ;
await Next ( ) ;
2024-09-16 20:49:03 -05:00
return ;
}
2024-06-20 17:14:59 -05:00
2024-09-16 20:49:03 -05:00
#region Download Manuals
try
{
2024-10-26 15:48:47 -05:00
foreach ( var manual in remoteGame . Media . Where ( m = > m . Type = = SDK . Enums . MediaType . Manual ) )
2024-06-20 17:14:59 -05:00
{
2026-01-18 04:40:18 -06:00
var localPath = Path . Combine ( _mediaClient . GetLocalPath ( manual ) , $"{manual.FileId}-{manual.Crc32}" ) ;
2024-09-16 20:49:03 -05:00
if ( ! File . Exists ( localPath ) )
{
2026-01-18 04:40:18 -06:00
foreach ( var staleFile in _mediaClient . GetStaleLocalPaths ( manual ) )
2024-09-16 20:49:03 -05:00
File . Delete ( staleFile ) ;
2026-01-18 04:40:18 -06:00
await _mediaClient . DownloadAsync ( new SDK . Models . Media
2024-09-16 20:49:03 -05:00
{
Id = manual . Id ,
FileId = manual . FileId
} , localPath ) ;
}
}
2024-06-20 17:14:59 -05:00
}
2024-09-16 20:49:03 -05:00
catch ( Exception ex )
{
2024-10-26 15:48:47 -05:00
Logger ? . LogError ( ex , "An unknown error occurred while trying to download game manuals for game {GameTitle} ({GameId})" , localGame . Title , localGame . Id ) ;
2024-09-16 20:49:03 -05:00
}
#endregion
2024-06-20 17:14:59 -05:00
2026-04-17 22:24:47 -05:00
currentItem . CompletedOn = DateTime . Now ;
currentItem . Status = InstallStatus . Complete ;
currentItem . Progress = 1 ;
currentItem . BytesDownloaded = currentItem . TotalBytes ;
2024-05-29 02:02:40 -05:00
2026-04-17 22:24:47 -05:00
try
{
await _gameService . UpdateAsync ( localGame ) ;
}
catch ( Exception ex )
{
Logger ? . LogError ( ex , "An unknown error occurred while trying to write changes to the database after install of game {GameTitle} ({GameId})" , localGame . Title , localGame . Id ) ;
}
2024-08-11 12:31:30 -05:00
2026-04-17 22:24:47 -05:00
OnQueueChanged ? . Invoke ( ) ;
2024-06-12 23:20:31 -05:00
2026-04-17 22:24:47 -05:00
Logger ? . LogTrace ( "Install of game {GameTitle} ({GameId}) complete!" , localGame . Title , localGame . Id ) ;
2024-06-01 22:47:22 -05:00
2026-04-17 22:24:47 -05:00
OnInstallComplete ? . Invoke ( localGame ) ;
2024-09-16 20:52:30 -05:00
2026-04-17 22:24:47 -05:00
operation . Complete ( ) ;
2024-05-25 15:40:31 -05:00
}
2024-05-29 19:25:41 -05:00
2024-10-26 15:48:47 -05:00
await Next ( ) ;
}
2025-05-15 03:04:48 +02:00
2026-06-08 19:00:56 -05:00
public async Task Update ( InstallQueueGame currentItem , Game localGame , SDK . Models . Game remoteGame )
{
using ( var operation = Logger . BeginOperation ( "Updating game {GameTitle} ({GameId})" , localGame . Title , localGame . Id ) )
{
Logger ? . LogInformation ( "[InstallQueue] Update(Game): Starting update of {Title} ({Id}) from version {InstalledVersion} to {LatestVersion}" ,
currentItem . Title , currentItem . Id , localGame . InstalledVersion , localGame . LatestVersion ) ;
currentItem . Status = InstallStatus . Downloading ;
OnQueueChanged ? . Invoke ( ) ;
try
{
// Get all archives newer than the installed version, ordered ascending by CreatedOn
var updates = await _gameClient . GetUpdatesAsync ( localGame . Id , localGame . InstalledVersion ) ;
var updateList = updates ? . ToList ( ) ? ? [ ] ;
2026-06-10 21:51:12 -05:00
if ( updateList . Count > 0 )
2026-06-08 19:00:56 -05:00
{
2026-06-10 21:51:12 -05:00
Logger ? . LogInformation ( "[InstallQueue] Update(Game): Found {Count} update(s) to apply sequentially: {Versions}" ,
updateList . Count , string . Join ( " → " , updateList . Select ( a = > a . Version ) ) ) ;
// Apply each archive sequentially
foreach ( var archive in updateList )
{
Logger ? . LogInformation ( "[InstallQueue] Update(Game): Applying archive {ArchiveId} version {Version} for {Title}" ,
archive . Id , archive . Version , currentItem . Title ) ;
2026-06-08 19:00:56 -05:00
2026-06-10 21:51:12 -05:00
var success = await _gameClient . ApplyUpdateArchiveAsync ( archive . Id , localGame . Id , localGame . InstallDirectory , currentItem . CancellationToken . Token ) ;
2026-06-08 19:00:56 -05:00
2026-06-10 21:51:12 -05:00
if ( ! success )
throw new InstallCanceledException ( "Game update was canceled" ) ;
2026-06-08 19:00:56 -05:00
2026-06-10 21:51:12 -05:00
// Update version in local DB after each archive
localGame . InstalledVersion = archive . Version ;
await _gameService . UpdateAsync ( localGame ) ;
2026-06-08 19:00:56 -05:00
2026-06-10 21:51:12 -05:00
Logger ? . LogInformation ( "[InstallQueue] Update(Game): Applied version {Version}, updated InstalledVersion in DB" , archive . Version ) ;
}
2026-06-08 19:00:56 -05:00
2026-06-10 21:51:12 -05:00
// Re-import game metadata (scripts, metadata changes)
Logger ? . LogInformation ( "[InstallQueue] Update(Game): Re-importing game metadata for {Title} ({Id})" , currentItem . Title , currentItem . Id ) ;
await _importService . ImportGameAsync ( localGame . Id ) ;
localGame = await _gameService . GetAsync ( localGame . Id ) ;
2026-06-08 19:00:56 -05:00
2026-06-10 21:51:12 -05:00
// Update manifest and scripts on disk
await _gameClient . UpdateGameInstallationAsync ( localGame . InstallDirectory , remoteGame ) ;
2026-06-27 11:51:55 -05:00
// Bug #1 convergence: after applying all updates and re-importing, the installed
// version may still trail the server's resolved latest version (the last applied
// archive's version string is not guaranteed to equal the canonical latest version).
// Converge explicitly so the game leaves the "update available" state.
if ( ! string . IsNullOrWhiteSpace ( localGame . LatestVersion ) )
{
localGame . InstalledVersion = localGame . LatestVersion ;
await _gameService . UpdateAsync ( localGame ) ;
Logger ? . LogInformation ( "[InstallQueue] Update(Game): Converged InstalledVersion to LatestVersion {LatestVersion} for {Title}" ,
localGame . LatestVersion , currentItem . Title ) ;
}
2026-06-10 21:51:12 -05:00
}
else
{
Logger ? . LogInformation ( "[InstallQueue] Update(Game): No game archive updates found for {Title} ({Id}), checking redistributables" , currentItem . Title , currentItem . Id ) ;
2026-06-08 19:00:56 -05:00
}
2026-06-10 21:51:12 -05:00
// Check for and apply redistributable updates
await UpdateRedistributablesForGameAsync ( localGame . InstallDirectory , remoteGame . Redistributables , currentItem . CancellationToken . Token ) ;
2026-06-08 19:00:56 -05:00
// Update the queue item version to match
currentItem . Version = localGame . InstalledVersion ;
}
catch ( InstallCanceledException )
{
Logger ? . LogError ( "Update canceled, removing from queue" ) ;
Queue . Remove ( currentItem ) ;
return ;
}
catch ( InstallException ex )
{
Logger ? . LogError ( ex , "An error occurred during update, removing from queue" ) ;
currentItem . Status = InstallStatus . Failed ;
OnQueueChanged ? . Invoke ( ) ;
OnInstallFail ? . Invoke ( localGame ) ;
return ;
}
catch ( Exception ex )
{
Logger ? . LogError ( ex , "An unknown error occurred during update" ) ;
currentItem . Status = InstallStatus . Failed ;
OnQueueChanged ? . Invoke ( ) ;
OnInstallFail ? . Invoke ( localGame ) ;
return ;
}
currentItem . CompletedOn = DateTime . Now ;
currentItem . Status = InstallStatus . Complete ;
currentItem . Progress = 1 ;
currentItem . BytesDownloaded = currentItem . TotalBytes ;
try
{
await _gameService . UpdateAsync ( localGame ) ;
}
catch ( Exception ex )
{
Logger ? . LogError ( ex , "An error occurred while trying to write changes to the database after update of game {GameTitle} ({GameId})" , localGame . Title , localGame . Id ) ;
}
OnQueueChanged ? . Invoke ( ) ;
Logger ? . LogTrace ( "Update of game {GameTitle} ({GameId}) complete!" , localGame . Title , localGame . Id ) ;
OnInstallComplete ? . Invoke ( localGame ) ;
2026-06-30 20:48:13 -05:00
operation . Complete ( ) ;
}
}
/// <summary>
2026-06-30 22:23:38 -05:00
/// Queues an explicit install/rollback of an already-installed game to a specific version.
/// The switch runs through the download queue (so it shows progress and supports cancel)
/// and is processed by <see cref="SwitchToVersion"/>.
2026-06-30 20:48:13 -05:00
/// </summary>
2026-06-30 22:23:38 -05:00
public async Task AddVersionSwitchAsync ( Game localGame , SDK . Models . GameVersion version )
2026-06-30 20:48:13 -05:00
{
ArgumentNullException . ThrowIfNull ( localGame ) ;
ArgumentNullException . ThrowIfNull ( version ) ;
if ( version . ArchiveId = = null | | version . ArchiveId = = Guid . Empty )
throw new InstallException ( "The selected version has no archive to install." ) ;
if ( string . IsNullOrWhiteSpace ( localGame . InstallDirectory ) )
throw new InstallException ( "The game is not installed." ) ;
2026-06-30 22:23:38 -05:00
var remoteGame = await _gameClient . GetAsync ( localGame . Id ) ;
if ( remoteGame = = null )
throw new InstallException ( $"Could not fetch game info for game {localGame.Id}" ) ;
// Drop any settled (non-active) history for this game so the switch shows as a fresh item.
var staleItems = Queue . Where ( i = > ! i . State & & i . Id = = localGame . Id ) . ToList ( ) ;
foreach ( var staleItem in staleItems )
Queue . Remove ( staleItem ) ;
// If a switch/install for this game is already in flight, don't queue a duplicate.
if ( Queue . Any ( i = > i . Id = = localGame . Id
& & i . Status . ValueIsIn ( InstallStatus . Queued , InstallStatus . Starting , InstallStatus . Downloading ) ) )
{
Logger ? . LogInformation ( "[InstallQueue] AddVersionSwitch: Game {GameId} already has an active queue item, skipping" , localGame . Id ) ;
return ;
}
var queueItem = new InstallQueueGame ( remoteGame )
{
InstallDirectory = localGame . InstallDirectory ,
Version = version . Version ,
TargetVersion = version ,
IsUpdate = ! string . IsNullOrWhiteSpace ( localGame . InstalledVersion )
& & version . Version ! = localGame . InstalledVersion ,
} ;
Queue . Add ( queueItem ) ;
_pendingNotificationRoots . Add ( localGame . Id ) ;
Logger ? . LogInformation ( "[InstallQueue] AddVersionSwitch: Queued switch of {Title} ({Id}) to version {Version}" ,
localGame . Title , localGame . Id , version . Version ) ;
if ( ! Queue . Any ( i = > i . State ) )
{
queueItem . Status = InstallStatus . Starting ;
await Next ( ) ;
}
OnQueueChanged ? . Invoke ( ) ;
}
/// <summary>
/// Applies an explicit version switch queue item. Downloads the target version's full
/// archive, extracts it over the install directory, then writes the version-scoped manifest
/// and scripts so on-disk config matches the chosen version. Updates the local
/// InstalledVersion. Cancellable via the queue item's cancellation token.
/// </summary>
private async Task SwitchToVersion ( InstallQueueGame currentItem , Game localGame , SDK . Models . Game remoteGame )
{
var version = currentItem . TargetVersion ;
2026-06-30 20:48:13 -05:00
using ( var operation = Logger . BeginOperation ( "Switching game {GameTitle} ({GameId}) to version {Version}" , localGame . Title , localGame . Id , version . Version ) )
{
Logger ? . LogInformation ( "[InstallQueue] SwitchToVersion: Switching {Title} ({Id}) from {InstalledVersion} to {TargetVersion} (archive {ArchiveId})" ,
localGame . Title , localGame . Id , localGame . InstalledVersion , version . Version , version . ArchiveId ) ;
2026-06-30 22:23:38 -05:00
currentItem . Status = InstallStatus . Downloading ;
OnQueueChanged ? . Invoke ( ) ;
try
{
var success = await _gameClient . ApplyUpdateArchiveAsync ( version . ArchiveId . Value , localGame . Id , localGame . InstallDirectory , currentItem . CancellationToken . Token ) ;
if ( ! success )
throw new InstallCanceledException ( "Version switch was canceled" ) ;
// Write the version-scoped manifest and scripts so on-disk config matches the chosen version.
await _gameClient . RefreshManifestAndScriptsAsync ( localGame . InstallDirectory , localGame . Id , version . Id ) ;
2026-06-30 20:48:13 -05:00
2026-06-30 22:23:38 -05:00
localGame . InstalledVersion = version . Version ;
await _gameService . UpdateAsync ( localGame ) ;
}
catch ( InstallCanceledException )
{
Logger ? . LogError ( "Version switch canceled, removing from queue" ) ;
Queue . Remove ( currentItem ) ;
return ;
}
catch ( InstallException ex )
{
Logger ? . LogError ( ex , "An error occurred during version switch" ) ;
currentItem . Status = InstallStatus . Failed ;
OnQueueChanged ? . Invoke ( ) ;
OnInstallFail ? . Invoke ( localGame ) ;
return ;
}
catch ( Exception ex )
{
Logger ? . LogError ( ex , "An unknown error occurred during version switch" ) ;
currentItem . Status = InstallStatus . Failed ;
OnQueueChanged ? . Invoke ( ) ;
OnInstallFail ? . Invoke ( localGame ) ;
return ;
}
2026-06-30 20:48:13 -05:00
2026-06-30 22:23:38 -05:00
currentItem . CompletedOn = DateTime . Now ;
currentItem . Status = InstallStatus . Complete ;
currentItem . Progress = 1 ;
currentItem . BytesDownloaded = currentItem . TotalBytes ;
2026-06-30 20:48:13 -05:00
2026-06-30 22:23:38 -05:00
OnQueueChanged ? . Invoke ( ) ;
2026-06-30 20:48:13 -05:00
Logger ? . LogInformation ( "[InstallQueue] SwitchToVersion: Completed switch of {Title} ({Id}) to version {Version}" ,
localGame . Title , localGame . Id , version . Version ) ;
OnInstallComplete ? . Invoke ( localGame ) ;
2026-06-08 19:00:56 -05:00
operation . Complete ( ) ;
}
}
2026-02-10 18:04:49 -06:00
public async Task Install ( InstallQueueTool currentItem , Tool localTool , SDK . Models . Tool remoteTool )
{
using ( var operation = Logger . BeginOperation ( "Installing tool {ToolName} ({ToolId})" , localTool . Name , localTool . Id ) )
{
currentItem . Status = InstallStatus . Downloading ;
OnQueueChanged ? . Invoke ( ) ;
2026-06-27 11:51:55 -05:00
string toolInstallDirectory = null ;
2026-02-10 18:04:49 -06:00
try
{
2026-04-17 22:24:47 -05:00
var planItem = new InstallPlanItem
{
EntityId = currentItem . Id ,
Title = currentItem . Title ,
Type = InstallPlanItemType . Tool ,
InstallDirectory = currentItem . InstallDirectory ,
Tasks = currentItem . Tasks ,
} ;
var result = await _toolClient . ExecuteInstallPlanItemAsync ( planItem , currentItem . CancellationToken . Token ) ;
2026-02-10 18:04:49 -06:00
2026-06-27 11:51:55 -05:00
toolInstallDirectory = result . InstallDirectory ;
2026-02-10 18:04:49 -06:00
}
catch ( InstallCanceledException ex )
{
Logger ? . LogError ( "Install canceled, removing from queue" ) ;
Queue . Remove ( currentItem ) ;
return ;
}
catch ( InstallException ex )
{
Logger ? . LogError ( ex , "An error occurred during install, removing from queue" ) ;
Queue . Remove ( currentItem ) ;
return ;
}
catch ( Exception ex )
{
Logger . LogError ( ex , "An unknown error occurred during install, removing from queue" ) ;
Queue . Remove ( currentItem ) ;
return ;
}
currentItem . CompletedOn = DateTime . Now ;
currentItem . Status = InstallStatus . Complete ;
currentItem . Progress = 1 ;
currentItem . BytesDownloaded = currentItem . TotalBytes ;
try
{
2026-06-27 11:51:55 -05:00
// Install state is tracked per game because a tool can be shared by several
// games and is installed into each game's own directory.
if ( currentItem . DependsOnId . HasValue )
await _toolService . SetToolInstalledAsync ( currentItem . DependsOnId . Value , localTool . Id , toolInstallDirectory , currentItem . Version ) ;
else
Logger ? . LogWarning ( "Tool {ToolName} ({ToolId}) was installed without an associated game; install state not recorded" , localTool . Name , localTool . Id ) ;
2026-02-10 18:04:49 -06:00
}
catch ( Exception ex )
{
Logger ? . LogError ( ex , "An unknown error occurred while trying to write changes to the database after install of tool {ToolName} ({ToolId})" , localTool . Name , localTool . Id ) ;
}
2026-04-17 22:24:47 -05:00
2026-02-10 18:04:49 -06:00
OnQueueChanged ? . Invoke ( ) ;
2026-04-17 22:24:47 -05:00
Logger ? . LogTrace ( "Install of tool {ToolName} ({ToolId}) complete!" , localTool . Name , localTool . Id ) ;
2026-02-10 18:04:49 -06:00
2026-07-02 20:34:22 -05:00
// Refresh the dependent game's action bar
if ( currentItem . DependsOnId . HasValue )
{
try
{
var dependentGame = await _gameService . GetAsync ( currentItem . DependsOnId . Value ) ;
if ( dependentGame ! = null )
OnToolInstallComplete ? . Invoke ( dependentGame ) ;
}
catch ( Exception ex )
{
Logger ? . LogError ( ex , "Failed to refresh actions for game {GameId} after install of tool {ToolId}" , currentItem . DependsOnId , localTool . Id ) ;
}
}
2026-02-10 18:04:49 -06:00
operation . Complete ( ) ;
}
await Next ( ) ;
}
2026-04-17 22:24:47 -05:00
private async Task InstallRedistributable ( DownloadQueueRedistributable currentItem )
{
currentItem . Status = InstallStatus . Downloading ;
OnQueueChanged ? . Invoke ( ) ;
try
{
var planItem = new InstallPlanItem
{
EntityId = currentItem . Id ,
Title = currentItem . Title ,
Type = InstallPlanItemType . Redistributable ,
InstallDirectory = currentItem . InstallDirectory ,
Tasks = currentItem . Tasks ,
DependsOnId = currentItem . DependsOnId ,
} ;
await _gameClient . ExecuteInstallPlanItemAsync ( planItem , currentItem . CancellationToken . Token ) ;
}
catch ( InstallCanceledException )
{
Logger ? . LogError ( "Redistributable install canceled" ) ;
currentItem . Status = InstallStatus . Canceled ;
OnQueueChanged ? . Invoke ( ) ;
await Next ( ) ;
return ;
}
catch ( Exception ex )
{
Logger ? . LogError ( ex , "Redistributable {Title} failed to install" , currentItem . Title ) ;
currentItem . Status = InstallStatus . Failed ;
OnQueueChanged ? . Invoke ( ) ;
await Next ( ) ;
return ;
}
currentItem . CompletedOn = DateTime . Now ;
currentItem . Status = InstallStatus . Complete ;
currentItem . Progress = 1 ;
OnQueueChanged ? . Invoke ( ) ;
Logger ? . LogTrace ( "Install of redistributable {Title} complete!" , currentItem . Title ) ;
await Next ( ) ;
}
2026-06-10 21:51:12 -05:00
private async Task UpdateRedistributable ( DownloadQueueRedistributable currentItem )
{
currentItem . Status = InstallStatus . Downloading ;
OnQueueChanged ? . Invoke ( ) ;
try
{
// Read the installed version from the on-disk manifest
var installedManifest = await ManifestHelper . ReadAsync < SDK . Models . Manifest . Redistributable > ( currentItem . InstallDirectory , currentItem . Id ) ;
var installedVersion = installedManifest ? . Version ;
Logger ? . LogInformation ( "[InstallQueue] UpdateRedistributable: Starting update of {Title} ({Id}) from version {InstalledVersion}" ,
currentItem . Title , currentItem . Id , installedVersion ) ;
var updates = await _redistributableClient . GetUpdatesAsync ( currentItem . Id , installedVersion ) ;
var updateList = updates ? . ToList ( ) ? ? [ ] ;
if ( updateList . Count = = 0 )
{
Logger ? . LogInformation ( "[InstallQueue] UpdateRedistributable: No updates found for {Title} ({Id})" , currentItem . Title , currentItem . Id ) ;
currentItem . Status = InstallStatus . Complete ;
OnQueueChanged ? . Invoke ( ) ;
await Next ( ) ;
return ;
}
Logger ? . LogInformation ( "[InstallQueue] UpdateRedistributable: Found {Count} update(s) to apply sequentially: {Versions}" ,
updateList . Count , string . Join ( " → " , updateList . Select ( a = > a . Version ) ) ) ;
var game = new SDK . Models . Game
{
Id = currentItem . DependsOnId ? ? Guid . Empty ,
InstallDirectory = currentItem . InstallDirectory
} ;
foreach ( var archive in updateList )
{
Logger ? . LogInformation ( "[InstallQueue] UpdateRedistributable: Applying archive {ArchiveId} version {Version} for {Title}" ,
archive . Id , archive . Version , currentItem . Title ) ;
await _redistributableClient . ApplyUpdateArchiveAsync ( archive . Id , currentItem . Id , game , currentItem . CancellationToken . Token ) ;
Logger ? . LogInformation ( "[InstallQueue] UpdateRedistributable: Applied version {Version}" , archive . Version ) ;
}
// Refresh manifest and scripts on disk
await _redistributableClient . RefreshManifestAndScriptsAsync ( currentItem . InstallDirectory , currentItem . Redistributable ) ;
}
catch ( InstallCanceledException )
{
Logger ? . LogError ( "Redistributable update canceled" ) ;
currentItem . Status = InstallStatus . Canceled ;
OnQueueChanged ? . Invoke ( ) ;
await Next ( ) ;
return ;
}
catch ( Exception ex )
{
Logger ? . LogError ( ex , "Redistributable {Title} failed to update" , currentItem . Title ) ;
currentItem . Status = InstallStatus . Failed ;
OnQueueChanged ? . Invoke ( ) ;
await Next ( ) ;
return ;
}
currentItem . CompletedOn = DateTime . Now ;
currentItem . Status = InstallStatus . Complete ;
currentItem . Progress = 1 ;
OnQueueChanged ? . Invoke ( ) ;
Logger ? . LogTrace ( "Update of redistributable {Title} complete!" , currentItem . Title ) ;
await Next ( ) ;
}
private async Task UpdateRedistributablesForGameAsync ( string installDirectory , IEnumerable < SDK . Models . Redistributable > redistributables , CancellationToken cancellationToken = default )
{
if ( redistributables = = null )
return ;
foreach ( var redistributable in redistributables )
{
try
{
var redistManifest = await ManifestHelper . ReadAsync < SDK . Models . Manifest . Redistributable > ( installDirectory , redistributable . Id ) ;
var redistInstalledVersion = redistManifest ? . Version ;
if ( string . IsNullOrWhiteSpace ( redistInstalledVersion ) )
continue ;
var hasUpdate = await _redistributableClient . CheckForUpdateAsync ( redistributable . Id , redistInstalledVersion ) ;
if ( ! hasUpdate )
{
// No archive update, but still refresh manifest and scripts
await _redistributableClient . RefreshManifestAndScriptsAsync ( installDirectory , redistributable ) ;
continue ;
}
Logger ? . LogInformation ( "Redistributable {RedistName} ({RedistId}) has an update available, applying..." ,
redistributable . Name , redistributable . Id ) ;
var redistUpdates = await _redistributableClient . GetUpdatesAsync ( redistributable . Id , redistInstalledVersion ) ;
var redistUpdateList = redistUpdates ? . ToList ( ) ? ? [ ] ;
var redistGame = new SDK . Models . Game
{
InstallDirectory = installDirectory
} ;
foreach ( var archive in redistUpdateList )
{
await _redistributableClient . ApplyUpdateArchiveAsync ( archive . Id , redistributable . Id , redistGame , cancellationToken ) ;
Logger ? . LogInformation ( "Applied redistributable {RedistName} version {Version}" , redistributable . Name , archive . Version ) ;
}
await _redistributableClient . RefreshManifestAndScriptsAsync ( installDirectory , redistributable ) ;
}
catch ( Exception ex )
{
Logger ? . LogError ( ex , "Failed to update redistributable {RedistName} ({RedistId})" ,
redistributable . Name , redistributable . Id ) ;
}
}
}
2026-02-10 18:04:49 -06:00
private static void UpdateGameState ( InstallQueueGame currentItem , Game localGame , string installDirectory )
2025-05-15 03:04:48 +02:00
{
localGame . InstallDirectory = installDirectory ;
localGame . Installed = true ;
localGame . InstalledVersion = currentItem . Version ;
localGame . InstalledOn ? ? = DateTime . Now ;
2026-04-22 22:43:21 -05:00
}
2025-05-15 03:04:48 +02:00
2026-04-22 22:43:21 -05:00
private static void UpdateAddonStates ( InstallQueueGame currentItem , Game localGame )
{
2025-05-16 01:04:03 +02:00
foreach ( var localAddon in ( localGame . DependentGames ? ? [ ] ) )
2025-05-15 03:04:48 +02:00
{
2025-05-16 01:04:03 +02:00
bool isInstalled = currentItem . AddonIds ? . Contains ( localAddon . Id ) ? ? false ;
2025-05-15 03:04:48 +02:00
2025-05-16 01:04:03 +02:00
if ( isInstalled )
{
2026-04-22 22:43:21 -05:00
localAddon . InstallDirectory = localGame . InstallDirectory ;
2025-05-16 01:04:03 +02:00
localAddon . Installed = true ;
localAddon . InstalledVersion = ( currentItem . AddonVersions ? ? [ ] ) . TryGetValue ( localAddon . Id , out var addonVersion ) ? addonVersion : null ;
localAddon . InstalledOn ? ? = DateTime . Now ;
}
else
{
localAddon . InstallDirectory = null ;
localAddon . Installed = false ;
localAddon . InstalledVersion = null ;
localAddon . InstalledOn = null ;
}
2025-05-15 03:04:48 +02:00
}
}
2024-10-26 15:48:47 -05:00
public async Task Move ( IInstallQueueItem currentItem , Game localGame , SDK . Models . Game remoteGame )
{
using ( var operation = Logger . BeginOperation ( "Moving game {GameTitle} ({GameId}) to {Destination}" , localGame . Title , localGame . Id , currentItem . InstallDirectory ) )
{
2025-02-16 15:42:00 -06:00
currentItem . Status = InstallStatus . Moving ;
OnQueueChanged ? . Invoke ( ) ;
2026-04-17 22:24:47 -05:00
2026-01-18 04:40:18 -06:00
var newInstallDirectory = await _gameClient . GetInstallDirectory ( remoteGame , currentItem . InstallDirectory ) ;
2024-10-26 15:48:47 -05:00
2026-01-18 04:40:18 -06:00
newInstallDirectory = await _gameClient . MoveAsync ( remoteGame , localGame . InstallDirectory , newInstallDirectory ) ;
2024-10-26 15:48:47 -05:00
localGame . InstallDirectory = newInstallDirectory ;
2026-01-18 04:40:18 -06:00
await _gameService . UpdateAsync ( localGame ) ;
2024-10-26 15:48:47 -05:00
2025-01-31 00:34:37 -06:00
currentItem . Status = InstallStatus . Complete ;
2024-10-26 15:48:47 -05:00
OnQueueChanged ? . Invoke ( ) ;
OnInstallComplete ? . Invoke ( localGame ) ;
operation . Complete ( ) ;
}
2024-05-29 19:25:41 -05:00
}
2024-05-25 15:40:31 -05:00
}
}