Refactor install process to use major/minor task queue

Tasks are broken down into major and minor tasks. Major tasks are like install game, redistributable, or tool. These are reported as individual tasks in the download queue. Minor tasks are running of a script, downloading saves, extracting/downloading, etc.
This commit is contained in:
Pat Hartl 2026-04-17 22:24:47 -05:00
parent 31dc9e4d3a
commit eaaaf39b8d
18 changed files with 1211 additions and 176 deletions

View file

@ -11,6 +11,7 @@ using LANCommander.Launcher.Avalonia.Services;
using LANCommander.Launcher.Models;
using LANCommander.Launcher.Services;
using LANCommander.SDK.Enums;
using LANCommander.SDK.Models;
using LANCommander.SDK.Services;
using Microsoft.Extensions.DependencyInjection;
using Microsoft.Extensions.Logging;
@ -92,6 +93,7 @@ public partial class DownloadQueueViewModel : ViewModelBase
_installService.OnQueueChanged += OnQueueChanged;
_installService.OnProgress += OnProgress;
_installService.OnTaskProgressUpdate += OnTaskProgressUpdate;
_installService.OnInstallComplete += OnInstallComplete;
_installService.OnInstallFail += OnInstallFail;
@ -104,6 +106,37 @@ public partial class DownloadQueueViewModel : ViewModelBase
return Task.CompletedTask;
}
private Task OnTaskProgressUpdate(InstallTaskProgress taskProgress)
{
Dispatcher.UIThread.Post(() =>
{
var item = QueueItems.FirstOrDefault(i => i.Id == taskProgress.QueueItemId);
if (item == null)
return;
var task = item.Tasks.FirstOrDefault(t => t.Id == taskProgress.TaskId);
if (task == null)
return;
task.Status = taskProgress.TaskStatus;
task.Progress = taskProgress.Progress;
task.BytesTransferred = taskProgress.BytesTransferred;
task.TotalBytes = taskProgress.TotalBytes;
task.TransferSpeed = taskProgress.TransferSpeed;
task.ErrorMessage = taskProgress.ErrorMessage;
item.CurrentTask = task;
// Update current status from task
if (item == CurrentItem)
{
CurrentStatus = taskProgress.TaskTitle;
}
});
return Task.CompletedTask;
}
private Task OnProgress(InstallProgress progress)
{
_taskbarProgressService.SetProgress(progress.Progress);
@ -364,9 +397,18 @@ public partial class InstallQueueItemViewModel : ViewModelBase
[ObservableProperty]
private bool _isUpdate;
public bool IsActive => Status != InstallStatus.Queued &&
Status != InstallStatus.Complete &&
Status != InstallStatus.Failed &&
[ObservableProperty]
private ObservableCollection<InstallTaskItemViewModel> _tasks = new();
[ObservableProperty]
private InstallTaskItemViewModel? _currentTask;
[ObservableProperty]
private bool _hasTasks;
public bool IsActive => Status != InstallStatus.Queued &&
Status != InstallStatus.Complete &&
Status != InstallStatus.Failed &&
Status != InstallStatus.Canceled;
public bool IsQueued => Status == InstallStatus.Queued;
@ -390,6 +432,15 @@ public partial class InstallQueueItemViewModel : ViewModelBase
CoverId = item.CoverId;
IconId = item.IconId;
IsUpdate = item.IsUpdate;
if (item.Tasks != null && item.Tasks.Count > 0)
{
foreach (var taskDef in item.Tasks.OrderBy(t => t.Order))
{
Tasks.Add(new InstallTaskItemViewModel(taskDef));
}
HasTasks = true;
}
}
private static string FormatBytes(long bytes)

View file

@ -0,0 +1,59 @@
using System;
using CommunityToolkit.Mvvm.ComponentModel;
using LANCommander.SDK.Enums;
using LANCommander.SDK.Models;
namespace LANCommander.Launcher.Avalonia.ViewModels;
public partial class InstallTaskItemViewModel : ViewModelBase
{
[ObservableProperty]
private Guid _id;
[ObservableProperty]
private string _title = string.Empty;
[ObservableProperty]
private InstallTaskType _type;
[ObservableProperty]
private InstallTaskStatus _status = InstallTaskStatus.Queued;
[ObservableProperty]
private float _progress;
[ObservableProperty]
private bool _reportsProgress;
[ObservableProperty]
private bool _isCritical;
[ObservableProperty]
private string? _errorMessage;
[ObservableProperty]
private long _bytesTransferred;
[ObservableProperty]
private long _totalBytes;
[ObservableProperty]
private long _transferSpeed;
public bool IsCompleted => Status == InstallTaskStatus.Completed;
public bool IsRunning => Status == InstallTaskStatus.Running;
public bool IsFailed => Status == InstallTaskStatus.Failed;
public bool IsQueued => Status == InstallTaskStatus.Queued;
public bool IsSkipped => Status == InstallTaskStatus.Skipped;
public InstallTaskItemViewModel() { }
public InstallTaskItemViewModel(InstallTaskDefinition taskDef)
{
Id = taskDef.Id;
Title = taskDef.Title;
Type = taskDef.Type;
ReportsProgress = taskDef.ReportsProgress;
IsCritical = taskDef.IsCritical;
}
}

View file

@ -114,6 +114,51 @@
IsVisible="{Binding TimeRemainingText, Converter={x:Static StringConverters.IsNotNullOrEmpty}}" />
</Grid>
<!-- Task list -->
<ItemsControl Grid.Row="3"
ItemsSource="{Binding CurrentItem.Tasks}"
IsVisible="{Binding CurrentItem.HasTasks}"
Margin="0,12,0,0">
<ItemsControl.ItemTemplate>
<DataTemplate DataType="vm:InstallTaskItemViewModel">
<Grid ColumnDefinitions="20,*,Auto"
Margin="0,2">
<!-- Status icon -->
<Panel Grid.Column="0" VerticalAlignment="Center" HorizontalAlignment="Center">
<TextBlock Text="&#x2713;" Foreground="#4CAF50" FontSize="11"
IsVisible="{Binding IsCompleted}" />
<TextBlock Text="&#x25CF;" Foreground="#2196F3" FontSize="8"
IsVisible="{Binding IsRunning}"
VerticalAlignment="Center" HorizontalAlignment="Center" />
<TextBlock Text="&#x2715;" Foreground="IndianRed" FontSize="11"
IsVisible="{Binding IsFailed}" />
<TextBlock Text="&#x2015;" Opacity="0.3" FontSize="11"
IsVisible="{Binding IsQueued}" />
</Panel>
<!-- Task title -->
<TextBlock Grid.Column="1"
Text="{Binding Title}"
FontSize="12"
Opacity="0.8"
VerticalAlignment="Center"
TextTrimming="CharacterEllipsis" />
<!-- Error message for failed tasks -->
<TextBlock Grid.Column="2"
Text="{Binding ErrorMessage}"
FontSize="10"
Foreground="IndianRed"
Opacity="0.8"
VerticalAlignment="Center"
IsVisible="{Binding IsFailed}"
MaxWidth="200"
TextTrimming="CharacterEllipsis" />
</Grid>
</DataTemplate>
</ItemsControl.ItemTemplate>
</ItemsControl>
</Grid>
</Border>
</Border>

View file

@ -71,7 +71,7 @@
<Border Padding="8" CornerRadius="6"
Background="{DynamicResource SystemControlBackgroundChromeMediumLowBrush}"
IsVisible="{Binding CurrentItem, Converter={x:Static ObjectConverters.IsNotNull}}">
<Grid RowDefinitions="Auto,Auto,Auto">
<Grid RowDefinitions="Auto,Auto,Auto,Auto">
<!-- Title and Cancel -->
<DockPanel Grid.Row="0">
<Button DockPanel.Dock="Right"
@ -96,11 +96,41 @@
</StackPanel>
<!-- Progress Bar -->
<ProgressBar Grid.Row="2"
Value="{Binding CurrentProgress}"
<ProgressBar Grid.Row="2"
Value="{Binding CurrentProgress}"
Minimum="0" Maximum="1"
Height="4"
Margin="0,4,0,0" />
<!-- Task list (compact) -->
<ItemsControl Grid.Row="3"
ItemsSource="{Binding CurrentItem.Tasks}"
IsVisible="{Binding CurrentItem.HasTasks}"
Margin="0,6,0,0">
<ItemsControl.ItemTemplate>
<DataTemplate DataType="vm:InstallTaskItemViewModel">
<Grid ColumnDefinitions="14,*" Margin="0,1">
<Panel Grid.Column="0" VerticalAlignment="Center" HorizontalAlignment="Center">
<TextBlock Text="&#x2713;" Foreground="#4CAF50" FontSize="9"
IsVisible="{Binding IsCompleted}" />
<TextBlock Text="&#x25CF;" Foreground="#2196F3" FontSize="7"
IsVisible="{Binding IsRunning}"
VerticalAlignment="Center" HorizontalAlignment="Center" />
<TextBlock Text="&#x2715;" Foreground="IndianRed" FontSize="9"
IsVisible="{Binding IsFailed}" />
<TextBlock Text="&#x2015;" Opacity="0.3" FontSize="9"
IsVisible="{Binding IsQueued}" />
</Panel>
<TextBlock Grid.Column="1"
Text="{Binding Title}"
FontSize="10"
Opacity="0.7"
VerticalAlignment="Center"
TextTrimming="CharacterEllipsis" />
</Grid>
</DataTemplate>
</ItemsControl.ItemTemplate>
</ItemsControl>
</Grid>
</Border>

View file

@ -1,17 +1,11 @@
using LANCommander.SDK.Enums;
using System;
using System.Collections.Generic;
using System.Linq;
using System.Text;
using System.Threading.Tasks;
using LANCommander.SDK.Models;
namespace LANCommander.Launcher.Models
{
public class DownloadQueueRedistributable : IInstallQueueItem
{
public Guid Id { get; set; }
public Guid[] AddonIds { get; set; }
public Dictionary<Guid, string?> AddonVersions { get; set; }
public string Title { get; set; }
public string Version { get; set; }
public string InstallDirectory { get; set; }
@ -26,12 +20,10 @@ namespace LANCommander.Launcher.Models
{
switch (Status)
{
case InstallStatus.Starting:
case InstallStatus.Downloading:
case InstallStatus.InstallingRedistributables:
case InstallStatus.InstallingMods:
case InstallStatus.InstallingExpansions:
case InstallStatus.RunningScripts:
case InstallStatus.DownloadingSaves:
return true;
default:
@ -41,10 +33,18 @@ namespace LANCommander.Launcher.Models
}
public InstallStatus Status { get; set; }
public SDK.Models.Redistributable Redistributable { get; set; }
public float Progress { get => throw new NotImplementedException(); set => throw new NotImplementedException(); }
public double TransferSpeed { get => throw new NotImplementedException(); set => throw new NotImplementedException(); }
public long BytesDownloaded { get => throw new NotImplementedException(); set => throw new NotImplementedException(); }
public long TotalBytes { get => throw new NotImplementedException(); set => throw new NotImplementedException(); }
public InstallPlanItemType ItemType => InstallPlanItemType.Redistributable;
public Guid? DependsOnId { get; set; }
public List<InstallTaskDefinition> Tasks { get; set; } = new();
public Guid? CurrentTaskId { get; set; }
public float Progress
{
get => BytesDownloaded / (float)Math.Max(TotalBytes, 1);
set { }
}
public double TransferSpeed { get; set; }
public long BytesDownloaded { get; set; }
public long TotalBytes { get; set; }
public CancellationTokenSource CancellationToken { get; set; } = new();
public DownloadQueueRedistributable(SDK.Models.Redistributable redistributable)
@ -52,7 +52,16 @@ namespace LANCommander.Launcher.Models
Redistributable = redistributable;
Id = redistributable.Id;
Title = redistributable.Name;
Version = redistributable.Archives.OrderByDescending(a => a.CreatedOn).FirstOrDefault()?.Version;
Version = redistributable.Archives?.OrderByDescending(a => a.CreatedOn).FirstOrDefault()?.Version;
QueuedOn = DateTime.Now;
Status = InstallStatus.Queued;
}
public DownloadQueueRedistributable(InstallPlanItem planItem, SDK.Models.Redistributable redistributable) : this(redistributable)
{
InstallDirectory = planItem.InstallDirectory;
DependsOnId = planItem.DependsOnId;
Tasks = planItem.Tasks;
}
}
}

View file

@ -1,9 +1,5 @@
using LANCommander.SDK.Enums;
using System;
using System.Collections.Generic;
using System.Linq;
using System.Text;
using System.Threading.Tasks;
using LANCommander.SDK.Models;
namespace LANCommander.Launcher.Models
{
@ -25,5 +21,9 @@ namespace LANCommander.Launcher.Models
long BytesDownloaded { get; set; }
long TotalBytes { get; set; }
CancellationTokenSource CancellationToken { get; set; }
InstallPlanItemType ItemType { get; }
Guid? DependsOnId { get; set; }
List<InstallTaskDefinition> Tasks { get; set; }
Guid? CurrentTaskId { get; set; }
}
}

View file

@ -1,9 +1,5 @@
using LANCommander.SDK.Enums;
using System;
using System.Collections.Generic;
using System.Linq;
using System.Text;
using System.Threading.Tasks;
using LANCommander.SDK.Models;
namespace LANCommander.Launcher.Models
{
@ -44,10 +40,17 @@ namespace LANCommander.Launcher.Models
}
public InstallStatus Status { get; set; }
public SDK.Models.Game Game { get; set; }
public InstallPlanItemType ItemType => InstallPlanItemType.Game;
public Guid? DependsOnId { get; set; }
public List<InstallTaskDefinition> Tasks { get; set; } = new();
public Guid? CurrentTaskId { get; set; }
public float Progress {
get
{
return BytesDownloaded / (float)TotalBytes;
if (Tasks != null && Tasks.Count > 0)
return BytesDownloaded / (float)Math.Max(TotalBytes, 1);
return BytesDownloaded / (float)Math.Max(TotalBytes, 1);
}
set { }
}
@ -61,19 +64,26 @@ namespace LANCommander.Launcher.Models
Game = game;
Id = game.Id;
Title = game.Title;
Version = game.Archives.OrderByDescending(a => a.CreatedOn).FirstOrDefault()?.Version;
Version = game.Archives?.OrderByDescending(a => a.CreatedOn).FirstOrDefault()?.Version;
QueuedOn = DateTime.Now;
Status = InstallStatus.Queued;
var cover = game.Media.FirstOrDefault(m => m.Type == SDK.Enums.MediaType.Cover);
var cover = game.Media?.FirstOrDefault(m => m.Type == SDK.Enums.MediaType.Cover);
if (cover != null)
CoverId = cover.Id;
var icon = game.Media.FirstOrDefault(m => m.Type == SDK.Enums.MediaType.Icon);
var icon = game.Media?.FirstOrDefault(m => m.Type == SDK.Enums.MediaType.Icon);
if (icon != null)
IconId = icon.Id;
}
public InstallQueueGame(InstallPlanItem planItem, SDK.Models.Game game) : this(game)
{
InstallDirectory = planItem.InstallDirectory;
DependsOnId = planItem.DependsOnId;
Tasks = planItem.Tasks;
}
}
}

View file

@ -1,12 +1,11 @@
using LANCommander.SDK.Enums;
using LANCommander.SDK.Models;
namespace LANCommander.Launcher.Models;
public class InstallQueueTool : IInstallQueueItem
{
public Guid Id { get; set; }
public Guid[] AddonIds { get; set; }
public Dictionary<Guid, string?> AddonVersions { get; set; }
public string Title { get; set; }
public string Version { get; set; }
public string InstallDirectory { get; set; }
@ -38,14 +37,18 @@ public class InstallQueueTool : IInstallQueueItem
}
}
}
public InstallStatus Status { get; set; }
public SDK.Models.Tool Tool { get; set; }
public InstallPlanItemType ItemType => InstallPlanItemType.Tool;
public Guid? DependsOnId { get; set; }
public List<InstallTaskDefinition> Tasks { get; set; } = new();
public Guid? CurrentTaskId { get; set; }
public float Progress {
get
{
return BytesDownloaded / (float)TotalBytes;
return BytesDownloaded / (float)Math.Max(TotalBytes, 1);
}
set { }
}
@ -59,8 +62,15 @@ public class InstallQueueTool : IInstallQueueItem
Tool = tool;
Id = tool.Id;
Title = tool.Name;
Version = tool.Archives.OrderByDescending(a => a.CreatedOn).FirstOrDefault()?.Version ?? "";
Version = tool.Archives?.OrderByDescending(a => a.CreatedOn).FirstOrDefault()?.Version ?? "";
QueuedOn = DateTime.Now;
Status = InstallStatus.Queued;
}
public InstallQueueTool(InstallPlanItem planItem, SDK.Models.Tool tool) : this(tool)
{
InstallDirectory = planItem.InstallDirectory;
DependsOnId = planItem.DependsOnId;
Tasks = planItem.Tasks;
}
}

View file

@ -1,4 +1,4 @@
using LANCommander.Launcher.Data.Models;
using LANCommander.Launcher.Data.Models;
using LANCommander.Launcher.Models;
using LANCommander.SDK.Enums;
using LANCommander.SDK.Exceptions;
@ -6,7 +6,10 @@ using LANCommander.SDK.Extensions;
using Microsoft.Extensions.Logging;
using System.Collections.ObjectModel;
using System.Diagnostics;
using LANCommander.SDK.Models;
using LANCommander.SDK.Services;
using Game = LANCommander.Launcher.Data.Models.Game;
using Tool = LANCommander.Launcher.Data.Models.Tool;
namespace LANCommander.Launcher.Services
{
@ -23,11 +26,14 @@ namespace LANCommander.Launcher.Services
private Stopwatch Stopwatch { get; set; }
public ObservableCollection<IInstallQueueItem> Queue { get; set; }
public delegate Task OnProgressHandler(InstallProgress progress);
public event OnProgressHandler OnProgress;
public delegate Task OnTaskProgressUpdateHandler(InstallTaskProgress progress);
public event OnTaskProgressUpdateHandler OnTaskProgressUpdate;
public delegate Task OnQueueChangedHandler();
public event OnQueueChangedHandler OnQueueChanged;
@ -54,7 +60,7 @@ namespace LANCommander.Launcher.Services
_redistributableClient = redistributableClient;
_toolClient = toolClient;
_mediaClient = mediaClient;
Stopwatch = new Stopwatch();
Queue = new ObservableCollection<IInstallQueueItem>();
@ -62,8 +68,9 @@ namespace LANCommander.Launcher.Services
Queue.CollectionChanged += (sender, e) =>
{
OnQueueChanged?.Invoke();
};
};
// Legacy progress forwarding for backward compatibility
_gameClient.OnInstallProgressUpdate += (e) =>
{
OnProgress?.Invoke(e);
@ -74,17 +81,29 @@ namespace LANCommander.Launcher.Services
OnProgress?.Invoke(e);
};
// _gameClient.OnArchiveExtractionProgress += Games_OnArchiveExtractionProgress;
// _gameClient.OnArchiveEntryExtractionProgress += Games_OnArchiveEntryExtractionProgress;
// New task-level progress forwarding
_gameClient.OnTaskProgress += OnSdkTaskProgress;
_toolClient.OnTaskProgress += OnSdkTaskProgress;
}
private void Games_OnArchiveExtractionProgress(long position, long length, SDK.Models.Game game)
private void OnSdkTaskProgress(InstallTaskProgress taskProgress)
{
OnQueueChanged?.Invoke();
}
// Update the matching queue item's current task and progress
var queueItem = Queue.FirstOrDefault(i => i.Id == taskProgress.QueueItemId);
private void Games_OnArchiveEntryExtractionProgress(object sender, SDK.ArchiveEntryExtractionProgressArgs e)
{
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);
OnQueueChanged?.Invoke();
}
@ -99,7 +118,7 @@ namespace LANCommander.Launcher.Services
public async Task Add(Game game, string installDirectory = "", SDK.Models.Game[]? addons = null)
{
var gameInfo = await _gameClient.GetAsync(game.Id);
// TODO: Throw exception (and gracefully handle) when gameInfo == null
// Game probably couldn't be found or deserialized from server
@ -116,6 +135,7 @@ namespace LANCommander.Launcher.Services
}
}
// Clear completed items for this game
try
{
var gameCompletedQueueItems = Queue.Where(i => i.Status == InstallStatus.Complete && i.Id == game.Id).ToList();
@ -129,49 +149,81 @@ namespace LANCommander.Launcher.Services
}
catch (Exception ex)
{
}
if (!Queue.Any(i => i.Id == game.Id && i.Status == InstallStatus.Queued))
if (Queue.Any(i => i.Id == game.Id && i.Status == InstallStatus.Queued))
return;
// Generate install plan from SDK
var addonIds = addons?.Select(x => x.Id).ToArray();
var plan = await _gameClient.GenerateInstallPlanAsync(game.Id, installDirectory, addonIds);
// Add each plan item to the queue
foreach (var planItem in plan.Items.OrderBy(i => i.Order))
{
var queueItem = new InstallQueueGame(gameInfo);
// Skip if already queued
if (Queue.Any(i => i.Id == planItem.EntityId && i.Status.ValueIsIn(InstallStatus.Queued, InstallStatus.Starting, InstallStatus.Downloading)))
continue;
queueItem.InstallDirectory = installDirectory;
IInstallQueueItem queueItem;
if (addons != null && addons.Length > 0)
switch (planItem.Type)
{
var versions = addons.ToLookup(addon => addon.Id, x => x.Archives.OrderByDescending(a => a.CreatedOn).FirstOrDefault()?.Version);
var addonIds = addons.Select(x => x.Id).ToArray() ?? [];
case InstallPlanItemType.Game:
case InstallPlanItemType.Addon:
var addonGame = planItem.Type == InstallPlanItemType.Addon
? await _gameClient.GetAsync(planItem.EntityId)
: gameInfo;
queueItem = new InstallQueueGame(planItem, addonGame);
queueItem.AddonIds = addonIds;
queueItem.AddonVersions = addonIds.ToDictionary(x => x, y => versions[y]?.FirstOrDefault());
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);
}
break;
case InstallPlanItemType.Redistributable:
var redist = gameInfo.Redistributables?.FirstOrDefault(r => r.Id == planItem.EntityId);
if (redist == null)
continue;
queueItem = new DownloadQueueRedistributable(planItem, redist);
break;
case InstallPlanItemType.Tool:
var tool = await _toolClient.GetAsync(planItem.EntityId);
queueItem = new InstallQueueTool(planItem, tool);
break;
default:
continue;
}
if (Queue.Any(i => i.State))
Queue.Add(queueItem);
else
Queue.Add(queueItem);
}
// Start processing if nothing active
if (!Queue.Any(i => i.State))
{
var firstItem = Queue.FirstOrDefault(i => i.Status == InstallStatus.Queued);
if (firstItem != null)
{
Logger?.LogTrace("Download queue is empty, starting the game download immediately");
queueItem.Status = InstallStatus.Starting;
Queue.Add(queueItem);
firstItem.Status = InstallStatus.Starting;
await Next();
}
OnQueueChanged?.Invoke();
}
OnQueueChanged?.Invoke();
}
public async Task Add(SDK.Models.Tool tool, string installDirectory = "")
{
var toolInfo = await _toolClient.GetAsync(tool.Id);
// TODO: Throw exception (and gracefully handle) when gameInfo == null
// Game probably couldn't be found or deserialized from server
Logger?.LogTrace("Adding game {ToolName} to the queue", toolInfo.Name);
Logger?.LogTrace("Adding tool {ToolName} to the queue", toolInfo.Name);
try
{
@ -186,30 +238,34 @@ namespace LANCommander.Launcher.Services
}
catch (Exception ex)
{
}
if (!Queue.Any(i => i.Id == tool.Id && i.Status == InstallStatus.Queued))
if (Queue.Any(i => i.Id == tool.Id && i.Status == InstallStatus.Queued))
return;
// Generate install plan from SDK
var plan = await _toolClient.GenerateInstallPlanAsync(toolInfo, installDirectory);
foreach (var planItem in plan.Items.OrderBy(i => i.Order))
{
var queueItem = new InstallQueueTool(toolInfo);
if (Queue.Any(i => i.Id == planItem.EntityId && i.Status.ValueIsIn(InstallStatus.Queued, InstallStatus.Starting, InstallStatus.Downloading)))
continue;
queueItem.InstallDirectory = installDirectory;
var queueItem = new InstallQueueTool(planItem, toolInfo);
Queue.Add(queueItem);
}
if (Queue.Any(i => i.State))
Queue.Add(queueItem);
else
if (!Queue.Any(i => i.State))
{
var firstItem = Queue.FirstOrDefault(i => i.Status == InstallStatus.Queued);
if (firstItem != null)
{
Logger?.LogTrace("Download queue is empty, starting the tool download immediately");
queueItem.Status = InstallStatus.Starting;
Queue.Add(queueItem);
firstItem.Status = InstallStatus.Starting;
await Next();
}
OnQueueChanged?.Invoke();
}
OnQueueChanged?.Invoke();
}
public void Remove(Guid id)
@ -218,7 +274,7 @@ namespace LANCommander.Launcher.Services
if (queueItem != null)
{
Logger?.LogTrace("Removing the game {GameTitle} from the queue", queueItem.Title);
Logger?.LogTrace("Removing the item {Title} from the queue", queueItem.Title);
Remove(queueItem);
}
@ -228,7 +284,7 @@ namespace LANCommander.Launcher.Services
{
if (queueItem != null)
{
Logger?.LogTrace("Removing the game {GameTitle} from the queue", queueItem.Title);
Logger?.LogTrace("Removing the item {Title} from the queue", queueItem.Title);
Queue.Remove(queueItem);
}
@ -237,28 +293,50 @@ namespace LANCommander.Launcher.Services
public async Task CancelInstallAsync(Guid queueItemId)
{
var queueItem = Queue.FirstOrDefault(i => i.Id == queueItemId);
if (queueItem == null)
return;
await queueItem.CancellationToken.CancelAsync();
queueItem.Status = InstallStatus.Canceled;
OnQueueChanged?.Invoke();
Logger?.LogTrace("Canceling install queue item {QueueItem}", queueItem.Title);
}
public async Task Next()
{
var currentItem = Queue.FirstOrDefault(i => i.Status.ValueIsIn(InstallStatus.Queued, InstallStatus.Starting));
var pendingItems = Queue.Where(i => i.Status.ValueIsIn(InstallStatus.Queued, InstallStatus.Starting));
if (currentItem == null)
return;
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 (currentItem is InstallQueueGame gameQueueItem)
await Next(gameQueueItem);
if (dependency != null && dependency.Status != InstallStatus.Complete)
continue;
}
if (currentItem is InstallQueueTool toolQueueItem)
await Next(toolQueueItem);
// Found an eligible item — process it
switch (candidate)
{
case InstallQueueGame gameQueueItem:
await Next(gameQueueItem);
return;
case InstallQueueTool toolQueueItem:
await Next(toolQueueItem);
return;
case DownloadQueueRedistributable redistQueueItem:
await Next(redistQueueItem);
return;
}
}
}
private async Task Next(InstallQueueGame queueItem)
@ -338,7 +416,7 @@ namespace LANCommander.Launcher.Services
{
localTool = await _toolService.GetAsync(queueItem.Id);
remoteTool = await _toolClient.GetAsync(queueItem.Id);
if (remoteTool == null)
{
Logger?.LogError("Tool info could not be retrieved from the server");
@ -351,7 +429,7 @@ namespace LANCommander.Launcher.Services
if (localTool == null)
{
Logger?.LogError("Tool does not exist in local database, importing");
await _importService.ImportToolAsync(queueItem.Id);
await Next(queueItem);
@ -361,7 +439,7 @@ namespace LANCommander.Launcher.Services
if (localTool.Installed)
{
// Modify
// Modify — currently no-op
}
else
{
@ -373,20 +451,40 @@ namespace LANCommander.Launcher.Services
}
}
private async Task Next(DownloadQueueRedistributable queueItem)
{
try
{
await InstallRedistributable(queueItem);
}
catch (Exception ex)
{
Logger?.LogError(ex, "An error occurred while installing redistributable {Title}", queueItem.Title);
}
}
public async Task Install(InstallQueueGame currentItem, Game localGame, SDK.Models.Game remoteGame)
{
using (var operation = Logger.BeginOperation("Installing game {GameTitle} ({GameId})", localGame.Title, localGame.Id))
{
string installDirectory;
currentItem.Status = InstallStatus.Downloading;
OnQueueChanged?.Invoke();
try
{
var gameFileList = await _gameClient.InstallAsync(remoteGame.Id, currentItem.InstallDirectory, currentItem.AddonIds, cancellationToken: currentItem.CancellationToken.Token);
installDirectory = gameFileList.InstallDirectory;
UpdateGameState(currentItem, localGame, installDirectory);
// 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,
};
var result = await _gameClient.ExecuteInstallPlanItemAsync(planItem, currentItem.CancellationToken.Token);
UpdateGameState(currentItem, localGame, result.InstallDirectory);
}
catch (InstallCanceledException ex)
{
@ -397,13 +495,19 @@ namespace LANCommander.Launcher.Services
catch (InstallException ex)
{
Logger?.LogError(ex, "An error occurred during install, removing from queue");
Queue.Remove(currentItem);
currentItem.Status = InstallStatus.Failed;
OnQueueChanged?.Invoke();
OnInstallFail?.Invoke(localGame);
await Next();
return;
}
catch (Exception ex)
{
Logger?.LogError(ex, "An unknown error occurred during install, removing from queue");
Queue.Remove(currentItem);
currentItem.Status = InstallStatus.Failed;
OnQueueChanged?.Invoke();
OnInstallFail?.Invoke(localGame);
await Next();
return;
}
@ -433,32 +537,27 @@ namespace LANCommander.Launcher.Services
}
#endregion
if (currentItem is InstallQueueGame)
currentItem.CompletedOn = DateTime.Now;
currentItem.Status = InstallStatus.Complete;
currentItem.Progress = 1;
currentItem.BytesDownloaded = currentItem.TotalBytes;
try
{
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 unknown error occurred while trying to write changes to the database after install of game {GameTitle} ({GameId})", localGame.Title, localGame.Id);
}
OnQueueChanged?.Invoke();
Logger?.LogTrace("Install of game {GameTitle} ({GameId}) complete!", localGame.Title, localGame.Id);
// ShowCompletedNotification(currentItem);
OnInstallComplete?.Invoke(localGame);
operation.Complete();
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);
}
OnQueueChanged?.Invoke();
Logger?.LogTrace("Install of game {GameTitle} ({GameId}) complete!", localGame.Title, localGame.Id);
OnInstallComplete?.Invoke(localGame);
operation.Complete();
}
await Next();
@ -473,7 +572,16 @@ namespace LANCommander.Launcher.Services
try
{
var result = await _toolClient.InstallAsync(remoteTool, currentItem.InstallDirectory);
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);
UpdateToolState(currentItem, localTool, result.InstallDirectory);
}
@ -509,12 +617,10 @@ namespace LANCommander.Launcher.Services
{
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);
}
OnQueueChanged?.Invoke();
Logger?.LogTrace("Install of tool {ToolName} ({ToolId}) complete!", localTool.Name, localTool.Id);
// OnInstallComplete?.Invoke(localTool);
OnQueueChanged?.Invoke();
Logger?.LogTrace("Install of tool {ToolName} ({ToolId}) complete!", localTool.Name, localTool.Id);
operation.Complete();
}
@ -522,6 +628,53 @@ namespace LANCommander.Launcher.Services
await Next();
}
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();
}
private static void UpdateGameState(InstallQueueGame currentItem, Game localGame, string installDirectory)
{
localGame.InstallDirectory = installDirectory;
@ -550,7 +703,7 @@ namespace LANCommander.Launcher.Services
}
}
}
private static void UpdateToolState(InstallQueueTool currentItem, Tool localTool, string installDirectory)
{
localTool.InstallDirectory = installDirectory;
@ -566,7 +719,7 @@ namespace LANCommander.Launcher.Services
currentItem.Status = InstallStatus.Moving;
OnQueueChanged?.Invoke();
var newInstallDirectory = await _gameClient.GetInstallDirectory(remoteGame, currentItem.InstallDirectory);
newInstallDirectory = await _gameClient.MoveAsync(remoteGame, localGame.InstallDirectory, newInstallDirectory);
@ -582,34 +735,6 @@ namespace LANCommander.Launcher.Services
operation.Complete();
}
}
/*private void ShowCompletedNotification(IDownloadQueueItem queueItem)
{
var builder = new ToastContentBuilder();
if (queueItem.IsUpdate)
builder.AddText("Game Updated")
.AddText($"{queueItem.Title} has finished updating!");
else
builder.AddText("Game Installed")
.AddText($"{queueItem.Title} has finished installing!");
builder.AddArgument("gameId", queueItem.Id.ToString())
.AddButton(
new ToastButton()
.SetContent("Play")
.AddArgument("action", "play")
)
.AddButton(
new ToastButton()
.SetContent("View in Library")
.AddArgument("action", "viewInLibrary")
);
//.Show
// .AddAppLogoOverride()
//.Show();
}*/
}
}

View file

@ -85,6 +85,9 @@ namespace LANCommander.SDK.Services
public delegate void OnInstallProgressUpdateHandler(InstallProgress e);
public event OnInstallProgressUpdateHandler OnInstallProgressUpdate;
public delegate void OnTaskProgressHandler(InstallTaskProgress progress);
public event OnTaskProgressHandler OnTaskProgress;
private const string PlayerAliasFilename = "PlayerAlias";
private const string KeyFilename = "Key";
@ -669,6 +672,482 @@ namespace LANCommander.SDK.Services
return installResult;
}
/// <summary>
/// Generates an install plan for a game, producing a list of queue items and their tasks
/// without executing anything.
/// </summary>
public async Task<InstallPlan> GenerateInstallPlanAsync(Guid gameId, string installDirectory, Guid[] addonIds = null)
{
var plan = new InstallPlan();
var game = await GetAsync(gameId);
if (string.IsNullOrWhiteSpace(installDirectory))
installDirectory = settingsProvider.CurrentValue.Games.InstallDirectories.First();
var destination = await GetInstallDirectory(game, installDirectory);
// Handle standalone mods — need base game first
if (game.Type == GameType.StandaloneMod && game.BaseGameId != Guid.Empty)
{
var baseGame = await GetAsync(game.BaseGameId);
var baseDestination = await GetInstallDirectory(baseGame, installDirectory);
if (!Directory.Exists(baseDestination))
{
var basePlan = await GenerateInstallPlanAsync(game.BaseGameId, installDirectory);
plan.Items.AddRange(basePlan.Items);
}
destination = baseDestination;
}
// Base game item
var gameItem = new InstallPlanItem
{
EntityId = game.Id,
Title = game.Title,
Type = InstallPlanItemType.Game,
InstallDirectory = destination,
Order = plan.Items.Count,
};
int taskOrder = 0;
gameItem.Tasks.Add(new InstallTaskDefinition
{
Type = InstallTaskType.DownloadAndExtract,
Title = $"Download {game.Title}",
Order = taskOrder++,
TargetId = game.Id,
TargetName = game.Title,
IsCritical = true,
ReportsProgress = true,
});
gameItem.Tasks.Add(new InstallTaskDefinition
{
Type = InstallTaskType.WriteManifest,
Title = "Write manifest",
Order = taskOrder++,
TargetId = game.Id,
TargetName = game.Title,
IsCritical = true,
});
gameItem.Tasks.Add(new InstallTaskDefinition
{
Type = InstallTaskType.WriteScripts,
Title = "Save scripts",
Order = taskOrder++,
TargetId = game.Id,
TargetName = game.Title,
IsCritical = false,
});
gameItem.Tasks.Add(new InstallTaskDefinition
{
Type = InstallTaskType.DownloadSaves,
Title = "Download saves",
Order = taskOrder++,
TargetId = game.Id,
TargetName = game.Title,
IsCritical = false,
ReportsProgress = true,
});
if (game.Scripts != null && game.Scripts.Any())
{
gameItem.Tasks.Add(new InstallTaskDefinition
{
Type = InstallTaskType.RunInstallScript,
Title = "Run install script",
Order = taskOrder++,
TargetId = game.Id,
TargetName = game.Title,
IsCritical = false,
});
gameItem.Tasks.Add(new InstallTaskDefinition
{
Type = InstallTaskType.RunKeyChangeScript,
Title = "Apply key",
Order = taskOrder++,
TargetId = game.Id,
TargetName = game.Title,
IsCritical = false,
});
gameItem.Tasks.Add(new InstallTaskDefinition
{
Type = InstallTaskType.RunNameChangeScript,
Title = "Apply player name",
Order = taskOrder++,
TargetId = game.Id,
TargetName = game.Title,
IsCritical = false,
});
}
if (game.Media != null)
{
foreach (var manual in game.Media.Where(m => m.Type == MediaType.Manual))
{
gameItem.Tasks.Add(new InstallTaskDefinition
{
Type = InstallTaskType.DownloadManual,
Title = $"Download manual",
Order = taskOrder++,
TargetId = manual.Id,
TargetName = game.Title,
IsCritical = false,
});
}
}
plan.Items.Add(gameItem);
// Redistributable items
if (game.Redistributables != null)
{
foreach (var redist in game.Redistributables)
{
var redistItem = new InstallPlanItem
{
EntityId = redist.Id,
Title = redist.Name,
Type = InstallPlanItemType.Redistributable,
InstallDirectory = destination,
Order = plan.Items.Count,
DependsOnId = game.Id,
};
redistItem.Tasks.Add(new InstallTaskDefinition
{
Type = InstallTaskType.DownloadAndExtract,
Title = $"Download {redist.Name}",
Order = 0,
TargetId = redist.Id,
TargetName = redist.Name,
IsCritical = true,
ReportsProgress = true,
Parameters = new Dictionary<string, string>
{
["ParentGameId"] = game.Id.ToString(),
},
});
redistItem.Tasks.Add(new InstallTaskDefinition
{
Type = InstallTaskType.RunRedistributableInstallScript,
Title = $"Install {redist.Name}",
Order = 1,
TargetId = redist.Id,
TargetName = redist.Name,
IsCritical = false,
Parameters = new Dictionary<string, string>
{
["ParentGameId"] = game.Id.ToString(),
},
});
plan.Items.Add(redistItem);
}
}
// Addon items
if (addonIds != null)
{
foreach (var addonId in addonIds)
{
var addon = await GetAsync(addonId);
var addonItem = new InstallPlanItem
{
EntityId = addon.Id,
Title = addon.Title,
Type = InstallPlanItemType.Addon,
InstallDirectory = destination,
Order = plan.Items.Count,
DependsOnId = game.Id,
};
int addonTaskOrder = 0;
addonItem.Tasks.Add(new InstallTaskDefinition
{
Type = InstallTaskType.DownloadAndExtract,
Title = $"Download {addon.Title}",
Order = addonTaskOrder++,
TargetId = addon.Id,
TargetName = addon.Title,
IsCritical = true,
ReportsProgress = true,
});
addonItem.Tasks.Add(new InstallTaskDefinition
{
Type = InstallTaskType.WriteManifest,
Title = "Write manifest",
Order = addonTaskOrder++,
TargetId = addon.Id,
TargetName = addon.Title,
IsCritical = true,
});
addonItem.Tasks.Add(new InstallTaskDefinition
{
Type = InstallTaskType.WriteScripts,
Title = "Save scripts",
Order = addonTaskOrder++,
TargetId = addon.Id,
TargetName = addon.Title,
IsCritical = false,
});
if (addon.Scripts != null && addon.Scripts.Any())
{
addonItem.Tasks.Add(new InstallTaskDefinition
{
Type = InstallTaskType.RunInstallScript,
Title = "Run install script",
Order = addonTaskOrder++,
TargetId = addon.Id,
TargetName = addon.Title,
IsCritical = false,
});
addonItem.Tasks.Add(new InstallTaskDefinition
{
Type = InstallTaskType.RunKeyChangeScript,
Title = "Apply key",
Order = addonTaskOrder++,
TargetId = addon.Id,
TargetName = addon.Title,
IsCritical = false,
});
addonItem.Tasks.Add(new InstallTaskDefinition
{
Type = InstallTaskType.RunNameChangeScript,
Title = "Apply player name",
Order = addonTaskOrder++,
TargetId = addon.Id,
TargetName = addon.Title,
IsCritical = false,
});
}
plan.Items.Add(addonItem);
}
}
return plan;
}
/// <summary>
/// Executes a single install plan item's tasks in order, firing OnTaskProgress events for each.
/// </summary>
public async Task<InstallResult> ExecuteInstallPlanItemAsync(InstallPlanItem planItem, CancellationToken cancellationToken = default)
{
var installResult = new InstallResult(planItem.InstallDirectory, planItem.EntityId);
switch (planItem.Type)
{
case InstallPlanItemType.Game:
case InstallPlanItemType.Addon:
await ExecuteGamePlanItemAsync(planItem, installResult, cancellationToken);
break;
case InstallPlanItemType.Redistributable:
await ExecuteRedistributablePlanItemAsync(planItem, installResult, cancellationToken);
break;
case InstallPlanItemType.Tool:
var toolResult = await toolClient.ExecuteInstallPlanItemAsync(planItem, cancellationToken);
installResult.InstallDirectory = toolResult.InstallDirectory;
break;
}
return installResult;
}
private async Task ExecuteGamePlanItemAsync(InstallPlanItem planItem, InstallResult installResult, CancellationToken cancellationToken)
{
var game = await GetAsync(planItem.EntityId);
var gameFileList = installResult.FileList;
SDK.Models.Manifest.Game manifest = null;
foreach (var taskDef in planItem.Tasks.OrderBy(t => t.Order))
{
cancellationToken.ThrowIfCancellationRequested();
var taskProgress = new InstallTaskProgress
{
QueueItemId = planItem.EntityId,
TaskId = taskDef.Id,
TaskType = taskDef.Type,
TaskTitle = taskDef.Title,
TaskStatus = InstallTaskStatus.Running,
};
OnTaskProgress?.Invoke(taskProgress);
try
{
switch (taskDef.Type)
{
case InstallTaskType.DownloadAndExtract:
var result = await RetryHelper.RetryOnExceptionAsync(10, TimeSpan.FromMilliseconds(500), new ExtractionResult(), async () =>
{
return await Task.Run(async () => await DownloadAndExtractAsync(game, planItem.InstallDirectory, cancellationToken));
});
if (!result.Success && !result.Canceled)
throw new InstallException("Could not extract the installer. Retry the install or check your connection");
else if (result.Canceled)
throw new InstallCanceledException("Game install was canceled");
game.InstallDirectory = result.Directory;
installResult.InstallDirectory = result.Directory;
planItem.InstallDirectory = result.Directory;
gameFileList.BaseGame.AddFiles(result.Files?
.Where(x => !x.EntryPath.EndsWith("/"))
.Select(x => new GameInstallationFileListEntry.FileEntry
{
EntryPath = x.EntryPath,
LocalPath = x.LocalPath,
}) ?? []);
break;
case InstallTaskType.WriteManifest:
manifest = await RetryHelper.RetryOnExceptionAsync(10, TimeSpan.FromSeconds(1), (SDK.Models.Manifest.Game)null, async () =>
{
return await WriteManifestAsync(planItem.InstallDirectory, game);
});
if (manifest == null)
throw new InstallException("Could not grab the manifest file. Retry the install or check your connection");
gameFileList.BaseGame.Manifest = manifest;
break;
case InstallTaskType.WriteScripts:
await WriteScriptsAsync(planItem.InstallDirectory, game);
break;
case InstallTaskType.DownloadSaves:
await saveClient.DownloadAsync(planItem.InstallDirectory, game.Id);
break;
case InstallTaskType.RunInstallScript:
await scriptClient.Game_RunInstallScriptAsync(planItem.InstallDirectory, game.Id);
break;
case InstallTaskType.RunKeyChangeScript:
var allocatedKey = await GetAllocatedKeyAsync(game.Id);
await scriptClient.Game_RunKeyChangeScriptAsync(planItem.InstallDirectory, game.Id, allocatedKey);
break;
case InstallTaskType.RunNameChangeScript:
var alias = await profileClient.GetAliasAsync();
await scriptClient.Game_RunNameChangeScriptAsync(planItem.InstallDirectory, game.Id, alias);
break;
case InstallTaskType.DownloadManual:
// Manual download handled by caller (InstallService) since it needs MediaClient
break;
}
taskProgress.TaskStatus = InstallTaskStatus.Completed;
taskProgress.Progress = 1.0f;
OnTaskProgress?.Invoke(taskProgress);
}
catch (InstallCanceledException)
{
taskProgress.TaskStatus = InstallTaskStatus.Canceled;
OnTaskProgress?.Invoke(taskProgress);
throw;
}
catch (Exception ex) when (!taskDef.IsCritical)
{
logger?.LogError(ex, "Non-critical task {TaskTitle} failed for {GameTitle} ({GameId})", taskDef.Title, game.Title, game.Id);
taskProgress.TaskStatus = InstallTaskStatus.Failed;
taskProgress.ErrorMessage = ex.Message;
OnTaskProgress?.Invoke(taskProgress);
}
}
}
private async Task ExecuteRedistributablePlanItemAsync(InstallPlanItem planItem, InstallResult installResult, CancellationToken cancellationToken)
{
// RedistributableClient.InstallAsync bundles download + install into one operation.
// We fire task progress for both tasks but execute them as one call.
var firstTask = planItem.Tasks.OrderBy(t => t.Order).FirstOrDefault();
if (firstTask == null)
return;
// Get parent game context from task parameters
Guid parentGameId = Guid.Empty;
if (firstTask.Parameters.TryGetValue("ParentGameId", out var parentGameIdStr))
Guid.TryParse(parentGameIdStr, out parentGameId);
var taskProgress = new InstallTaskProgress
{
QueueItemId = planItem.EntityId,
TaskId = firstTask.Id,
TaskType = firstTask.Type,
TaskTitle = firstTask.Title,
TaskStatus = InstallTaskStatus.Running,
};
OnTaskProgress?.Invoke(taskProgress);
try
{
cancellationToken.ThrowIfCancellationRequested();
var game = parentGameId != Guid.Empty ? await GetAsync(parentGameId) : null;
if (game != null)
{
game.InstallDirectory = planItem.InstallDirectory;
var redist = game.Redistributables?.FirstOrDefault(r => r.Id == planItem.EntityId);
if (redist != null)
await redistributableClient.InstallAsync(redist, game);
}
// Mark all tasks as completed
foreach (var taskDef in planItem.Tasks.OrderBy(t => t.Order))
{
OnTaskProgress?.Invoke(new InstallTaskProgress
{
QueueItemId = planItem.EntityId,
TaskId = taskDef.Id,
TaskType = taskDef.Type,
TaskTitle = taskDef.Title,
TaskStatus = InstallTaskStatus.Completed,
Progress = 1.0f,
});
}
}
catch (InstallCanceledException)
{
taskProgress.TaskStatus = InstallTaskStatus.Canceled;
OnTaskProgress?.Invoke(taskProgress);
throw;
}
catch (Exception ex)
{
logger?.LogError(ex, "Redistributable {RedistName} failed to install", planItem.Title);
taskProgress.TaskStatus = InstallTaskStatus.Failed;
taskProgress.ErrorMessage = ex.Message;
OnTaskProgress?.Invoke(taskProgress);
}
}
public async Task<InstallResult> UninstallAsync(string installDirectory, Guid gameId)
{
var installResult = new InstallResult(installDirectory, gameId);

View file

@ -15,6 +15,7 @@ using Force.Crc32;
using LANCommander.SDK.Abstractions;
using LANCommander.SDK.Exceptions;
using LANCommander.SDK.Factories;
using Action = System.Action;
namespace LANCommander.SDK.Services
{
@ -90,6 +91,117 @@ namespace LANCommander.SDK.Services
.StreamAsync();
}
public delegate void OnTaskProgressHandler(InstallTaskProgress progress);
public event OnTaskProgressHandler OnTaskProgress;
public async Task<InstallPlan> GenerateInstallPlanAsync(Tool tool, string installDirectory)
{
var plan = new InstallPlan();
var toolItem = new InstallPlanItem
{
EntityId = tool.Id,
Title = tool.Name,
Type = InstallPlanItemType.Tool,
InstallDirectory = installDirectory,
Order = 0,
};
int taskOrder = 0;
toolItem.Tasks.Add(new InstallTaskDefinition
{
Type = InstallTaskType.DownloadAndExtract,
Title = $"Download {tool.Name}",
Order = taskOrder++,
TargetId = tool.Id,
TargetName = tool.Name,
IsCritical = true,
ReportsProgress = true,
});
if (tool.Scripts != null && tool.Scripts.Any())
{
toolItem.Tasks.Add(new InstallTaskDefinition
{
Type = InstallTaskType.RunInstallScript,
Title = "Run install script",
Order = taskOrder++,
TargetId = tool.Id,
TargetName = tool.Name,
IsCritical = false,
});
}
plan.Items.Add(toolItem);
return plan;
}
public async Task<InstallResult> ExecuteInstallPlanItemAsync(InstallPlanItem planItem, CancellationToken cancellationToken = default)
{
var tool = await GetAsync(planItem.EntityId);
var installResult = new InstallResult();
foreach (var taskDef in planItem.Tasks.OrderBy(t => t.Order))
{
cancellationToken.ThrowIfCancellationRequested();
var taskProgress = new InstallTaskProgress
{
QueueItemId = planItem.EntityId,
TaskId = taskDef.Id,
TaskType = taskDef.Type,
TaskTitle = taskDef.Title,
TaskStatus = InstallTaskStatus.Running,
};
OnTaskProgress?.Invoke(taskProgress);
try
{
switch (taskDef.Type)
{
case InstallTaskType.DownloadAndExtract:
var result = await RetryHelper.RetryOnExceptionAsync(10,
TimeSpan.FromMilliseconds(500), new ExtractionResult(),
async () => await Task.Run(async () => await DownloadAndExtractAsync(tool, planItem.InstallDirectory, cancellationToken)));
if (!result.Success && !result.Canceled)
throw new InstallException("Could not extract the tool. Retry the install or check your connection");
else if (result.Canceled)
throw new InstallCanceledException("Tool install was canceled");
installResult.InstallDirectory = result.Directory;
break;
case InstallTaskType.RunInstallScript:
await scriptClient.Tool_RunInstallScriptAsync(planItem.InstallDirectory, tool.Id);
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 tool {ToolName}", taskDef.Title, tool.Name);
taskProgress.TaskStatus = InstallTaskStatus.Failed;
taskProgress.ErrorMessage = ex.Message;
OnTaskProgress?.Invoke(taskProgress);
}
}
return installResult;
}
public async Task InstallAsync(Game game)
{
foreach (var tool in game.Tools)

View file

@ -0,0 +1,10 @@
namespace LANCommander.SDK.Enums
{
public enum InstallPlanItemType
{
Game,
Addon,
Redistributable,
Tool
}
}

View file

@ -0,0 +1,12 @@
namespace LANCommander.SDK.Enums
{
public enum InstallTaskStatus
{
Queued,
Running,
Completed,
Failed,
Skipped,
Canceled
}
}

View file

@ -0,0 +1,17 @@
namespace LANCommander.SDK.Enums
{
public enum InstallTaskType
{
DownloadAndExtract,
WriteManifest,
WriteScripts,
VerifyFiles,
DownloadSaves,
RunInstallScript,
RunKeyChangeScript,
RunNameChangeScript,
RunRedistributableInstallScript,
DownloadManual,
MoveFiles
}
}

View file

@ -0,0 +1,9 @@
using System.Collections.Generic;
namespace LANCommander.SDK.Models
{
public class InstallPlan
{
public List<InstallPlanItem> Items { get; set; } = new();
}
}

View file

@ -0,0 +1,17 @@
using System;
using System.Collections.Generic;
using LANCommander.SDK.Enums;
namespace LANCommander.SDK.Models
{
public class InstallPlanItem
{
public Guid EntityId { get; set; }
public string Title { get; set; }
public InstallPlanItemType Type { get; set; }
public string InstallDirectory { get; set; }
public int Order { get; set; }
public List<InstallTaskDefinition> Tasks { get; set; } = new();
public Guid? DependsOnId { get; set; }
}
}

View file

@ -0,0 +1,19 @@
using System;
using System.Collections.Generic;
using LANCommander.SDK.Enums;
namespace LANCommander.SDK.Models
{
public class InstallTaskDefinition
{
public Guid Id { get; set; } = Guid.NewGuid();
public InstallTaskType Type { get; set; }
public string Title { get; set; }
public int Order { get; set; }
public Guid TargetId { get; set; }
public string TargetName { get; set; }
public bool IsCritical { get; set; }
public bool ReportsProgress { get; set; }
public Dictionary<string, string> Parameters { get; set; } = new();
}
}

View file

@ -0,0 +1,21 @@
using System;
using LANCommander.SDK.Enums;
namespace LANCommander.SDK.Models
{
public class InstallTaskProgress
{
public Guid QueueItemId { get; set; }
public Guid TaskId { get; set; }
public InstallTaskType TaskType { get; set; }
public string TaskTitle { get; set; }
public InstallTaskStatus TaskStatus { get; set; }
public float Progress { get; set; }
public long BytesTransferred { get; set; }
public long TotalBytes { get; set; }
public long TransferSpeed { get; set; }
public TimeSpan TimeRemaining { get; set; }
public bool Indeterminate { get; set; }
public string ErrorMessage { get; set; }
}
}