Files
UnrealEngine/Engine/Source/Programs/UnrealToolbox/ToolCatalog.cs
2025-05-18 13:04:45 +08:00

1131 lines
32 KiB
C#

// Copyright Epic Games, Inc. All Rights Reserved.
using System.Diagnostics;
using System.Runtime.InteropServices;
using System.Security.Authentication;
using System.Text.Json;
using EpicGames.Core;
using EpicGames.Horde;
using EpicGames.Horde.Storage.Nodes;
using EpicGames.Horde.Tools;
using Microsoft.Extensions.Logging;
using Microsoft.Win32;
#nullable enable
namespace UnrealToolbox
{
class ToolCatalog : IToolCatalog, IAsyncDisposable
{
record class ItemState(string Name, string Description, string? MsiProductId, CurrentToolDeploymentInfo? Current, PendingToolDeploymentInfo? Pending, IToolDeployment? Latest);
[DebuggerDisplay("{Id}")]
class Item : IToolCatalogItem
{
public ToolId Id { get; }
readonly ToolCatalog _catalog;
// Keep a separate copy of the state on the main thread; we will update it explicitly. This prevents tearing of updates on UI threads.
public ItemState _mainThreadState;
public ToolDeploymentInfo? _mainThreadLatest;
// Public properties only expose main thread data
string IToolCatalogItem.Name => _mainThreadState.Name;
string IToolCatalogItem.Description => _mainThreadState.Description;
ToolDeploymentInfo? IToolCatalogItem.Latest => _mainThreadLatest;
CurrentToolDeploymentInfo? IToolCatalogItem.Current => _mainThreadState.Current;
PendingToolDeploymentInfo? IToolCatalogItem.Pending => _mainThreadState.Pending;
public ItemState _state;
public DeploymentTask? _deploymentTask;
public event Action? OnItemChanged;
public Item(ToolCatalog catalog, ToolId id, ItemState state)
{
_catalog = catalog;
Id = id;
_state = state;
_mainThreadState = null!;
UpdateMainThreadState();
}
public void UpdateMainThreadState()
{
_mainThreadState = _state;
_mainThreadLatest = (_state.Latest == null) ? null : new ToolDeploymentInfo(_state.Latest.Id, _state.Latest.Version);
}
public void NotifyChanged()
{
OnItemChanged?.Invoke();
}
public void SetState(ItemState state)
{
_state = state;
OnItemChanged?.Invoke();
}
public void Cancel()
=> _catalog.CancelDeployment(this, _mainThreadState.Pending?.Deployment);
// Installing or uninstalling changes the pending task state immediately, and anyone copying the pending state will always see the value of that state.
// UI things may wait on that state, and will get notified from that state.
// So we should basically post a notice to the main thread to say we've queued up the next state.
public void Install()
=> _catalog.UpdateDeployment(this, _mainThreadState.Latest);
public void Uninstall()
=> _catalog.UpdateDeployment(this, null);
}
#pragma warning disable CA1001 // Cancellation source is disposed by the task itself
class DeploymentTask
#pragma warning restore CA1001
{
public ToolDeploymentInfo Deployment { get; }
CancellationTokenSource? _cancellationSource;
readonly Task _task;
public DeploymentTask(ToolDeploymentInfo deployment, Func<CancellationToken, Task> func, DeploymentTask? lastDeploymentTask)
{
Deployment = deployment;
CancellationTokenSource cancellationSource = new CancellationTokenSource();
_task = Task.Run(() => RunAsync(func, lastDeploymentTask, cancellationSource), CancellationToken.None);
_cancellationSource = cancellationSource;
}
async Task RunAsync(Func<CancellationToken, Task> func, DeploymentTask? lastDeploymentTask, CancellationTokenSource cancellationSource)
{
CancellationToken cancellationToken = cancellationSource!.Token;
try
{
if (lastDeploymentTask != null)
{
lastDeploymentTask.Cancel();
await lastDeploymentTask.WaitAsync(CancellationToken.None);
}
await func(cancellationToken);
}
catch (OperationCanceledException)
{
}
finally
{
lock (_task)
{
cancellationSource.Dispose(); // Note; the instance passed into this function, not the class member.
_cancellationSource = null;
}
}
}
public Task WaitAsync(CancellationToken cancellationToken)
=> _task.WaitAsync(cancellationToken);
public void Cancel()
{
lock (_task)
{
_cancellationSource?.Cancel();
_cancellationSource = null;
}
}
}
#region Json serialization
class JsonState
{
public bool AutoUpdate { get; set; }
public List<JsonItemState> Items { get; set; } = new List<JsonItemState>();
}
class JsonItemState
{
public ToolId Id { get; set; }
public string Name { get; set; }
public string Description { get; set; }
public string? MsiProductId { get; set; }
public JsonDeploymentState? Current { get; set; }
public JsonItemState()
{
Name = String.Empty;
Description = String.Empty;
}
public JsonItemState(ToolId id, ItemState state)
{
Id = id;
Name = state.Name;
Description = state.Description;
MsiProductId = state.MsiProductId;
Current = (state.Current == null) ? null : new JsonDeploymentState(state.Current);
}
}
class JsonDeploymentState
{
public ToolDeploymentId Id { get; set; }
public string Version { get; set; }
public JsonDeploymentState()
{
Version = String.Empty;
}
public JsonDeploymentState(ToolDeploymentInfo current)
{
Id = current.Id;
Version = current.Version;
}
public ToolDeploymentInfo ToToolDeploymentInfo()
=> new ToolDeploymentInfo(Id, Version);
}
#endregion
readonly IHordeClientProvider _hordeClientProvider;
readonly ILogger _logger;
readonly object _workerThreadLockObject = new object();
readonly AsyncEvent _updateEvent;
readonly BackgroundTask _updateTask;
readonly AsyncEvent _cleanupEvent;
readonly BackgroundTask _cleanupTask;
readonly SemaphoreSlim _msiSemaphore;
readonly SynchronizationContext? _mainThreadSynchronizationContext;
IReadOnlyDictionary<ToolId, Item> _mainThreadItems;
IReadOnlyList<IToolCatalogItem> _mainThreadItemList;
IReadOnlyList<IToolCatalogItem> IToolCatalog.Items => _mainThreadItemList;
IReadOnlyDictionary<ToolId, Item> _items;
Uri? _serverUri;
bool _autoUpdate = true;
public event Action? OnItemsChanged;
static readonly JsonSerializerOptions s_jsonSerializerOptions = new JsonSerializerOptions { AllowTrailingCommas = true, PropertyNameCaseInsensitive = true, PropertyNamingPolicy = JsonNamingPolicy.CamelCase, WriteIndented = true };
public bool AutoUpdate
{
get => _autoUpdate;
set
{
if (_autoUpdate != value)
{
_autoUpdate = value;
_ = SaveStateAsync(CancellationToken.None);
}
}
}
/// <inheritdoc/>
public void RequestUpdate()
{
_updateEvent.Pulse();
}
public ToolCatalog(IHordeClientProvider hordeClientProvider, ILogger<ToolCatalog> logger)
{
_hordeClientProvider = hordeClientProvider;
_logger = logger;
_updateEvent = new AsyncEvent();
_updateTask = new BackgroundTask(UpdateAsync);
_cleanupEvent = new AsyncEvent();
_cleanupTask = new BackgroundTask(CleanupAsync);
_mainThreadSynchronizationContext = SynchronizationContext.Current;
_msiSemaphore = new SemaphoreSlim(1);
_items = LoadState();
_mainThreadItems = null!;
_mainThreadItemList = null!;
UpdateMainThread();
}
public static DirectoryReference GetToolsDir() => DirectoryReference.Combine(Program.DataDir, "Tools");
public static FileReference GetConfigFile() => FileReference.Combine(Program.DataDir, "Tools.json");
void PostMainThreadUpdate()
{
if (_mainThreadSynchronizationContext == null)
{
UpdateMainThread();
}
else
{
_mainThreadSynchronizationContext.Post(_ => UpdateMainThread(), null);
}
}
void UpdateMainThread()
{
bool updatedCollection = false;
List<Item> updatedItems = new List<Item>();
lock (_workerThreadLockObject)
{
if (_items != _mainThreadItems)
{
UpdateMainThreadItemsState();
updatedCollection = true;
}
foreach (Item item in _items.Values)
{
if (item._state != item._mainThreadState)
{
item.UpdateMainThreadState();
updatedItems.Add(item);
}
}
}
if (updatedCollection)
{
OnItemsChanged?.Invoke();
}
foreach (Item updatedItem in updatedItems)
{
updatedItem.NotifyChanged();
}
}
void UpdateMainThreadItemsState()
{
_mainThreadItems = _items;
_mainThreadItemList = _items.Values.OrderBy(x => x._mainThreadState.Name).ToArray();
}
public void Start()
{
_updateTask.Start();
_cleanupTask.Start();
_hordeClientProvider.OnStateChanged += OnServerChange;
}
public async Task StopAsync(CancellationToken cancellationToken)
{
_hordeClientProvider.OnStateChanged -= OnServerChange;
foreach (Item item in _items.Values)
{
DeploymentTask? deploymentTask = item._deploymentTask;
if (deploymentTask != null)
{
deploymentTask.Cancel();
await deploymentTask.WaitAsync(cancellationToken);
}
}
await _updateTask.StopAsync(cancellationToken);
await _cleanupTask.StopAsync(cancellationToken);
}
public async ValueTask DisposeAsync()
{
await StopAsync(CancellationToken.None);
await _updateTask.DisposeAsync();
await _cleanupTask.DisposeAsync();
_msiSemaphore.Dispose();
}
void OnServerChange()
{
_updateEvent.Pulse();
}
static DirectoryReference GetToolDir(ToolId toolId, ToolDeploymentId deploymentId)
{
return DirectoryReference.Combine(GetToolsDir(), toolId.ToString(), deploymentId.ToString());
}
ToolConfig LoadToolConfig(DirectoryReference toolDir)
{
FileReference configFile = FileReference.Combine(toolDir, "Toolbox.json");
if (FileReference.Exists(configFile))
{
JsonSerializerOptions jsonSerializerOptions = new JsonSerializerOptions();
jsonSerializerOptions.PropertyNameCaseInsensitive = true;
jsonSerializerOptions.AllowTrailingCommas = true;
jsonSerializerOptions.ReadCommentHandling = JsonCommentHandling.Skip;
try
{
byte[] data = FileReference.ReadAllBytes(configFile);
return JsonSerializer.Deserialize<ToolConfig>(data, jsonSerializerOptions) ?? new ToolConfig();
}
catch (Exception ex)
{
_logger.LogError(ex, "Error reading {File}: {Message}", configFile, ex.Message);
}
}
return new ToolConfig();
}
IReadOnlyDictionary<ToolId, Item> LoadState()
{
Dictionary<ToolId, Item> items = new Dictionary<ToolId, Item>();
byte[]? data = FileTransaction.ReadAllBytes(GetConfigFile());
if (data != null)
{
JsonState? state = JsonSerializer.Deserialize<JsonState>(data, s_jsonSerializerOptions);
if (state != null)
{
_autoUpdate = state.AutoUpdate;
foreach (JsonItemState jsonItem in state.Items)
{
CurrentToolDeploymentInfo? current = null;
if (jsonItem.Current != null)
{
DirectoryReference toolDir = GetToolDir(jsonItem.Id, jsonItem.Current.Id);
ToolConfig toolConfig = LoadToolConfig(toolDir);
current = new CurrentToolDeploymentInfo(jsonItem.Current.Id, jsonItem.Current.Version, toolDir, toolConfig);
}
ItemState itemState = new ItemState(jsonItem.Name, jsonItem.Description, jsonItem.MsiProductId, current, null, null);
items.Add(jsonItem.Id, new Item(this, jsonItem.Id, itemState));
}
}
}
return items;
}
async Task SaveStateAsync(CancellationToken cancellationToken)
{
JsonState state;
lock (_workerThreadLockObject)
{
state = new JsonState();
state.AutoUpdate = _autoUpdate;
foreach (Item item in _items.Values)
{
if (item._state.Current != null)
{
state.Items.Add(new JsonItemState(item.Id, item._state));
}
}
}
byte[] data = JsonSerializer.SerializeToUtf8Bytes(state, s_jsonSerializerOptions);
DirectoryReference.CreateDirectory(GetConfigFile().Directory);
await FileTransaction.WriteAllBytesAsync(GetConfigFile(), data, cancellationToken);
}
void CancelDeployment(Item item, ToolDeploymentInfo? deployment)
{
if (deployment != null)
{
lock (_workerThreadLockObject)
{
if (item._deploymentTask != null && item._deploymentTask.Deployment == deployment)
{
item._deploymentTask.Cancel();
}
}
}
}
void UpdateDeployment(Item item, IToolDeployment? deployment)
{
lock (_workerThreadLockObject)
{
DeploymentTask? lastDeploymentTask = item._deploymentTask;
if (deployment != null)
{
ToolDeploymentInfo deploymentInfo = new ToolDeploymentInfo(deployment.Id, deployment.Version);
item._deploymentTask = new DeploymentTask(deploymentInfo, ctx => UpdateCurrentDeploymentAsync(item, deployment, deploymentInfo, ctx), lastDeploymentTask);
}
else if (item._state.Current != null)
{
ToolDeploymentInfo deploymentInfo = item._state.Current;
item._deploymentTask = new DeploymentTask(deploymentInfo, ctx => UpdateCurrentDeploymentAsync(item, deployment, deploymentInfo, ctx), lastDeploymentTask);
}
}
}
async Task UpdateCurrentDeploymentAsync(Item item, IToolDeployment? deployment, ToolDeploymentInfo deploymentInfo, CancellationToken cancellationToken)
{
try
{
if (deployment == null)
{
await UninstallAsync(item, cancellationToken);
_cleanupEvent.Set();
}
else
{
UpdateStateAndNotify(item, state => state with { Pending = new PendingToolDeploymentInfo(false, $"Updating to {deployment.Version}", deploymentInfo) });
DirectoryReference toolDir = GetToolDir(item.Id, deploymentInfo.Id);
DirectoryReference.CreateDirectory(toolDir);
await deployment.Content.ExtractAsync(toolDir.ToDirectoryInfo(), new ExtractOptions(), _logger, cancellationToken);
await InstallAsync(item, toolDir, deploymentInfo, cancellationToken);
}
await SaveStateAsync(cancellationToken);
}
catch (OperationCanceledException)
{
_logger.LogInformation("Deployment cancelled");
UpdateStateAndNotify(item, state => state with { Pending = new PendingToolDeploymentInfo(true, $"Cancelled.", deploymentInfo) });
}
catch (Exception ex)
{
_logger.LogError(ex, "Error updating deployment: {Message}", ex.Message);
UpdateStateAndNotify(item, state => state with { Pending = new PendingToolDeploymentInfo(true, $"Error updating. View Log for info.", deploymentInfo, ShowLogLink: true) });
}
}
async Task InstallAsync(Item item, DirectoryReference toolDir, ToolDeploymentInfo deploymentInfo, CancellationToken cancellationToken)
{
ToolConfig toolConfig = LoadToolConfig(toolDir);
if (!await UninstallInternalAsync(item, cancellationToken))
{
return;
}
if (toolConfig.InstallCommand != null)
{
PendingToolDeploymentInfo pending = new PendingToolDeploymentInfo(false, "Running install actions...", deploymentInfo);
UpdateStateAndNotify(item, state => state with { Pending = pending });
int exitCode = await RunCommandAsync(item.Id.ToString(), toolConfig.InstallCommand, toolDir.FullName,
toolConfig.RequiresElevation, toolConfig.Hidden, cancellationToken);
if (exitCode != 0)
{
pending = new PendingToolDeploymentInfo(true, $"Installation failed ({exitCode})", deploymentInfo, ShowLogLink: true);
UpdateStateAndNotify(item, state => state with { Pending = pending });
return;
}
}
else if (OperatingSystem.IsWindows() && !String.IsNullOrEmpty(item._state.MsiProductId))
{
// Wait to run the installer
if (!await _msiSemaphore.WaitAsync(0, cancellationToken))
{
PendingToolDeploymentInfo pending = new PendingToolDeploymentInfo(false, "Waiting to install...", deploymentInfo);
UpdateStateAndNotify(item, state => state with { Pending = pending });
await _msiSemaphore.WaitAsync(cancellationToken);
}
// Keep the lock until the install has finished
try
{
PendingToolDeploymentInfo pending = new PendingToolDeploymentInfo(false, "Running installer...", deploymentInfo);
UpdateStateAndNotify(item, state => state with { Pending = pending });
FileReference? msiFile = DirectoryReference.EnumerateFiles(toolDir, "*.msi").FirstOrDefault();
if (msiFile == null)
{
pending = new PendingToolDeploymentInfo(true, $"MSI file not found", deploymentInfo);
UpdateStateAndNotify(item, state => state with { Pending = pending });
return;
}
ToolCommand installCommand = new ToolCommand { FileName = "msiexec.exe" };
installCommand.Arguments = new List<string> { "/I", msiFile.FullName };
int exitCode = await RunCommandAsync(item.Id.ToString(), installCommand, toolDir.FullName,
toolConfig.RequiresElevation, toolConfig.Hidden, cancellationToken);
if (exitCode != 0 || !IsMsiInstalled(item._state.MsiProductId))
{
UpdateStateAndNotify(item, state => state with { Pending = null });
return;
}
}
finally
{
_msiSemaphore.Release();
}
}
CurrentToolDeploymentInfo current = new CurrentToolDeploymentInfo(deploymentInfo.Id, deploymentInfo.Version, toolDir, toolConfig);
UpdateStateAndNotify(item, state => state with { Pending = null, Current = current });
}
static bool IsMsiInstalled(string msiProductId)
{
if (!OperatingSystem.IsWindows())
{
return false;
}
string? value =
(Registry.GetValue($"HKEY_LOCAL_MACHINE\\SOFTWARE\\Microsoft\\Windows\\CurrentVersion\\Uninstall\\{{{msiProductId}}}", "UninstallString", null) as string) ??
(Registry.GetValue($"HKEY_LOCAL_MACHINE\\SOFTWARE\\WOW6432Node\\Microsoft\\Windows\\CurrentVersion\\Uninstall\\{{{msiProductId}}}", "UninstallString", null) as string);
return !String.IsNullOrEmpty(value);
}
async Task<bool> UninstallAsync(Item item, CancellationToken cancellationToken)
{
if (!await UninstallInternalAsync(item, cancellationToken))
{
return false;
}
UpdateStateAndNotify(item, state => state with { Pending = null, Current = null });
return true;
}
async Task<bool> UninstallInternalAsync(Item item, CancellationToken cancellationToken)
{
CurrentToolDeploymentInfo? current = item._state.Current;
if (current != null)
{
if (current.Config.UninstallCommand != null)
{
PendingToolDeploymentInfo? pending = new PendingToolDeploymentInfo(false, $"Removing {current.Version}", null);
UpdateStateAndNotify(item, state => state with { Pending = pending, Current = null });
await RunCommandAsync(item.Id.ToString(), current.Config.UninstallCommand, current.Dir.FullName,
current.Config.RequiresElevation, current.Config.Hidden, cancellationToken);
}
else if (OperatingSystem.IsWindows() && !String.IsNullOrEmpty(item._state.MsiProductId) && IsMsiInstalled(item._state.MsiProductId))
{
PendingToolDeploymentInfo pending;
// Wait to run the installer
if (!await _msiSemaphore.WaitAsync(0, cancellationToken))
{
pending = new PendingToolDeploymentInfo(false, "Waiting to run installer...", current);
UpdateStateAndNotify(item, state => state with { Pending = pending });
await _msiSemaphore.WaitAsync(cancellationToken);
}
// Run the installer
try
{
pending = new PendingToolDeploymentInfo(false, $"Running installer...", current);
UpdateStateAndNotify(item, state => state with { Pending = pending });
ToolCommand uninstallCommand = new ToolCommand { FileName = "msiexec.exe" };
uninstallCommand.Arguments = new List<string> { "/x", $"{{{item._state.MsiProductId}}}" };
int exitCode = await RunCommandAsync(item.Id.ToString(), uninstallCommand, Directory.GetCurrentDirectory(),
current.Config.RequiresElevation, current.Config.Hidden, cancellationToken);
if (exitCode == 1602)
{
// User cancelled (https://learn.microsoft.com/en-us/windows/win32/msi/error-codes)
UpdateStateAndNotify(item, state => state with { Pending = null });
return false;
}
else if (exitCode != 0 || IsMsiInstalled(item._state.MsiProductId))
{
pending = new PendingToolDeploymentInfo(true, $"Uninstall failed ({exitCode}).", current);
UpdateStateAndNotify(item, state => state with { Pending = pending });
return false;
}
}
finally
{
_msiSemaphore.Release();
}
}
}
return true;
}
async Task<int> RunCommandAsync(string toolName, ToolCommand command, string workingDir, bool elevateProcess, bool hidden, CancellationToken cancellationToken)
{
Dictionary<string, string> properties = new Dictionary<string, string>(StringComparer.OrdinalIgnoreCase);
properties.Add("ToolDir", workingDir);
string arguments = String.Empty;
if (command.Arguments != null)
{
List<string> argumentList = command.Arguments.ConvertAll(x => StringUtils.ExpandProperties(x, properties));
arguments = CommandLineArguments.Join(argumentList);
}
string fileName = Path.Combine(workingDir, StringUtils.ExpandProperties(command.FileName, properties));
if (!File.Exists(fileName))
{
fileName = command.FileName;
}
_logger.LogInformation("{ToolName}> Running {FileName} {Arguments}", toolName, CommandLineArguments.Quote(fileName), arguments);
using Process process = new Process();
process.StartInfo = new ProcessStartInfo
{
FileName = fileName,
Arguments = arguments,
WorkingDirectory = workingDir,
CreateNoWindow = true
};
if (hidden)
{
process.StartInfo.WindowStyle = ProcessWindowStyle.Hidden;
}
if (elevateProcess)
{
// When elevating the process, IO cannot be redirected in this process
process.StartInfo.UseShellExecute = true;
process.StartInfo.Verb = "runas"; // "runas" triggers the UAC prompt
_logger.LogInformation("Tool install runs as an elevated process. To read process output, check installer's own logging mechanism.");
}
else
{
process.StartInfo.RedirectStandardOutput = true;
process.StartInfo.RedirectStandardError = true;
process.StartInfo.UseShellExecute = false;
DataReceivedEventHandler onDataReceived = (sender, args) =>
{
if (args.Data != null)
{
_logger.LogInformation("{ToolName}> {Line}", toolName, args.Data);
}
};
process.OutputDataReceived += onDataReceived;
process.ErrorDataReceived += onDataReceived;
}
process.Start();
if (!elevateProcess)
{
process.BeginOutputReadLine();
process.BeginErrorReadLine();
}
await process.WaitForExitAsync(cancellationToken);
int exitCode = process.ExitCode;
_logger.LogInformation("{ToolName}> Exit code {ExitCode}", toolName, exitCode);
return exitCode;
}
void UpdateStateAndNotify(Item item, Func<ItemState, ItemState> update)
{
lock (_workerThreadLockObject)
{
string prevState = GetItemStateMessage(item._state);
item._state = update(item._state);
string nextState = GetItemStateMessage(item._state);
if (!String.Equals(prevState, nextState, StringComparison.Ordinal))
{
_logger.LogInformation("{ToolName}> {State}", item.Id, nextState);
}
}
PostMainThreadUpdate();
}
static string GetItemStateMessage(ItemState state)
{
List<string> messages = new List<string>();
if (state.Current != null)
{
messages.Add($"Current: {state.Current.Version} ({state.Current.Id})");
}
if (state.Pending != null)
{
if (state.Pending.Failed)
{
messages.Add($"Pending: {state.Pending.Message} (Failed)");
}
else if (state.Pending.Deployment != null)
{
messages.Add($"Pending: {state.Pending.Message} ({state.Pending.Deployment.Id}@{state.Pending.Deployment.Version})");
}
}
if (messages.Count == 0)
{
messages.Add("Not installed");
}
return String.Join(", ", messages);
}
#region Cleanup
async Task CleanupAsync(CancellationToken cancellationToken)
{
while (!cancellationToken.IsCancellationRequested)
{
Task cleanupTask = _cleanupEvent.Task;
bool success = false;
try
{
success = CleanupOnce();
}
catch (OperationCanceledException)
{
break;
}
catch (Exception ex)
{
_logger.LogError(ex, "Exception while running cleanup over installed tools: {Message}", ex.Message);
}
Task waitTask = cleanupTask.WaitAsync(cancellationToken);
if (!success)
{
waitTask = Task.WhenAny(waitTask, Task.Delay(TimeSpan.FromMinutes(1.0), cancellationToken));
}
await waitTask;
}
}
bool CleanupOnce()
{
bool result = true;
List<DirectoryInfo> deleteToolDirs = new List<DirectoryInfo>();
List<DirectoryInfo> deleteDeploymentDirs = new List<DirectoryInfo>();
DirectoryInfo baseDirInfo = GetToolsDir().ToDirectoryInfo();
if (baseDirInfo.Exists)
{
foreach (DirectoryInfo toolDir in baseDirInfo.EnumerateDirectories())
{
if (!toolDir.Name.StartsWith('.'))
{
DirectoryInfo[] deploymentDirs = toolDir.GetDirectories();
ToolId? toolId;
try
{
toolId = new ToolId(toolDir.Name);
}
catch
{
toolId = null;
}
lock (_workerThreadLockObject)
{
ItemState? itemState = null;
if (toolId != null && _items.TryGetValue(toolId.Value, out Item? item))
{
itemState = item._state;
}
bool deleteToolDir = true;
for (int idx = 0; idx < deploymentDirs.Length; idx++)
{
DirectoryInfo deploymentDir = deploymentDirs[idx];
if (!deploymentDir.Name.StartsWith('.'))
{
if (!BinaryId.TryParse(deploymentDir.Name, out BinaryId deploymentId))
{
deleteDeploymentDirs.Add(deploymentDir);
}
else if (itemState != null && itemState.Current != null && itemState.Current.Id.Id == deploymentId)
{
deleteToolDir = false;
}
else if (itemState != null && itemState.Pending?.Deployment != null && itemState.Pending.Deployment.Id.Id == deploymentId)
{
deleteToolDir = false;
}
else if (TryRenameDir(deploymentDir, deploymentDir.FullName + ".dead"))
{
deleteDeploymentDirs.Add(deploymentDir);
}
else
{
result = deleteToolDir = false;
}
}
}
if (deleteToolDir)
{
deleteToolDirs.Add(toolDir);
}
}
}
}
}
// Try to delete all the deployment directories
foreach (DirectoryInfo deleteDeploymentDir in deleteDeploymentDirs)
{
result &= ForceDeleteDir(deleteDeploymentDir);
}
// Try to delete all the tool directories if they're empty
foreach (DirectoryInfo deleteToolDir in deleteToolDirs)
{
result &= TryDeleteDir(deleteToolDir);
}
return result;
}
bool TryRenameDir(DirectoryInfo dirInfo, string newName)
{
try
{
dirInfo.MoveTo(newName);
return true;
}
catch (Exception ex)
{
_logger.LogWarning(ex, "Unable to rename directory {DirName}: {Message}", dirInfo.FullName, ex.Message);
return false;
}
}
bool ForceDeleteDir(DirectoryInfo dirInfo)
{
try
{
FileUtils.ForceDeleteDirectory(dirInfo);
return true;
}
catch (Exception ex)
{
_logger.LogWarning(ex, "Unable to delete directory {DirName}: {Message}", dirInfo.FullName, ex.Message);
return false;
}
}
bool TryDeleteDir(DirectoryInfo dirInfo)
{
try
{
dirInfo.Delete(false);
return true;
}
catch (Exception ex)
{
_logger.LogWarning(ex, "Unable to delete directory {DirName}: {Message}", dirInfo.FullName, ex.Message);
return false;
}
}
#endregion
#region Updates
async Task UpdateAsync(CancellationToken cancellationToken)
{
while (!cancellationToken.IsCancellationRequested)
{
Task updateTask = _updateEvent.Task;
using (CancellationTokenSource cancellationSource = CancellationTokenSource.CreateLinkedTokenSource(cancellationToken))
{
bool complete = false;
_ = updateTask.ContinueWith(_ =>
{
lock (cancellationSource)
{
if (!complete)
{
cancellationSource.Cancel();
}
}
}, TaskScheduler.Default);
try
{
await PollForUpdatesOnceAsync(cancellationSource.Token);
}
catch (OperationCanceledException)
{
}
catch (InvalidCredentialException)
{
// Briefly wait and try again as missing access token check is cheap and in-memory.
await Task.WhenAny(updateTask, Task.Delay(TimeSpan.FromSeconds(5), cancellationToken));
continue;
}
catch (Exception ex)
{
_logger.LogError(ex, "Exception while checking for tool updates: {Message}", ex.Message);
}
finally
{
lock (cancellationSource)
{
complete = true;
}
}
}
await Task.WhenAny(updateTask, Task.Delay(TimeSpan.FromMinutes(5.0), cancellationToken));
}
}
async Task PollForUpdatesOnceAsync(CancellationToken cancellationToken)
{
using IHordeClientRef? hordeClientRef = _hordeClientProvider.GetClientRef();
if (hordeClientRef == null)
{
return;
}
if (_serverUri != hordeClientRef.Client.ServerUrl)
{
SetTools(Enumerable.Empty<ITool>());
_serverUri = hordeClientRef.Client.ServerUrl;
}
if (!hordeClientRef.Client.HasValidAccessToken())
{
throw new InvalidCredentialException("No access token available. Auth via Toolbox UI is required.");
}
IEnumerable<ITool> tools = await hordeClientRef.Client.Tools.GetAllAsync(cancellationToken);
SetTools(tools);
}
void SetTools(IEnumerable<ITool> tools)
{
lock (_workerThreadLockObject)
{
Dictionary<ToolId, Item> newItems = new Dictionary<ToolId, Item>();
foreach (ITool tool in tools)
{
if (ShouldIncludeTool(tool))
{
string? msiProductId;
if (!tool.Metadata.TryGetValue("msi-product-id", out msiProductId))
{
msiProductId = null;
}
IToolDeployment? latestDeployment = (tool.Deployments.Count > 0) ? tool.Deployments[^1] : null;
if (_items.TryGetValue(tool.Id, out Item? item))
{
item._state = new ItemState(tool.Name, tool.Description, msiProductId, item._state.Current, item._state.Pending, latestDeployment);
}
else
{
item = new Item(this, tool.Id, new ItemState(tool.Name, tool.Description, msiProductId, null, null, latestDeployment));
}
if (OperatingSystem.IsWindows() && item._state.Current != null && msiProductId != null && !IsMsiInstalled(msiProductId))
{
item._state = item._state with { Current = null };
}
newItems[item.Id] = item;
}
}
foreach (Item existingItem in _items.Values)
{
if (existingItem._state.Current != null && !newItems.ContainsKey(existingItem.Id))
{
newItems.Add(existingItem.Id, existingItem);
}
}
_items = newItems;
if (_autoUpdate)
{
foreach (Item item in _items.Values)
{
if (item._state.Current != null && !item._state.Current.Config.ManualInstall && String.IsNullOrEmpty(item._state.MsiProductId))
{
if (item._state.Latest != null && item._state.Latest.Id != item._state.Current.Id)
{
UpdateDeployment(item, item._state.Latest);
}
}
}
}
}
PostMainThreadUpdate();
}
static IReadOnlySet<string> HostPlatformRids { get; } = GetHostPlatformRids();
static bool ShouldIncludeTool(ITool tool)
{
if (!tool.ShowInToolbox)
{
return false;
}
if (tool.Platforms != null && !tool.Platforms.Any(x => HostPlatformRids.Contains(x)))
{
return false;
}
return true;
}
static HashSet<string> GetHostPlatformRids()
{
HashSet<string> platforms = new HashSet<string>(StringComparer.OrdinalIgnoreCase);
platforms.Add("any");
if (OperatingSystem.IsWindows())
{
platforms.Add("win");
switch (RuntimeInformation.OSArchitecture)
{
case Architecture.X64:
platforms.Add("win-x64");
break;
case Architecture.Arm64:
platforms.Add("win-arm64");
break;
}
}
else if (OperatingSystem.IsMacOS())
{
platforms.Add("osx");
switch (RuntimeInformation.OSArchitecture)
{
case Architecture.X64:
platforms.Add("osx-x64");
break;
case Architecture.Arm64:
platforms.Add("osx-arm64");
break;
}
}
else if (OperatingSystem.IsLinux())
{
platforms.Add("linux");
platforms.Add("unix");
switch (RuntimeInformation.OSArchitecture)
{
case Architecture.X64:
platforms.Add("linux-x64");
platforms.Add("unix-x64");
break;
case Architecture.Arm64:
platforms.Add("linux-arm64");
platforms.Add("unix-arm64");
break;
}
}
return platforms;
}
#endregion
}
}