LANCommander/LANCommander.SDK/Clients/RedistributableClient.cs
2026-06-10 21:51:12 -05:00

529 lines
22 KiB
C#

using LANCommander.SDK.Enums;
using LANCommander.SDK.Helpers;
using LANCommander.SDK.Models;
using Microsoft.Extensions.Logging;
using SharpCompress.Common;
using SharpCompress.Readers;
using System;
using System.Collections.Generic;
using System.IO;
using System.Linq;
using System.Threading;
using System.Threading.Tasks;
using LANCommander.SDK.Abstractions;
using LANCommander.SDK.Exceptions;
using LANCommander.SDK.Factories;
namespace LANCommander.SDK.Services
{
public class RedistributableClient(
ILogger<RedistributableClient> logger,
ISettingsProvider settingsProvider,
ApiRequestFactory apiRequestFactory,
ScriptClient scriptClient,
ProfileClient profileClient)
{
public delegate void OnArchiveEntryExtractionProgressHandler(object sender, ArchiveEntryExtractionProgressArgs e);
public event OnArchiveEntryExtractionProgressHandler OnArchiveEntryExtractionProgress;
public delegate void OnArchiveExtractionProgressHandler(long position, long length);
public event OnArchiveExtractionProgressHandler OnArchiveExtractionProgress;
public delegate void OnInstallProgressUpdateHandler(InstallProgress e);
public event OnInstallProgressUpdateHandler OnInstallProgressUpdate;
private InstallProgress _installProgress;
public async Task<SDK.Models.Manifest.Redistributable> GetManifestAsync(Guid id)
{
return await apiRequestFactory
.Create()
.UseAuthenticationToken()
.UseVersioning()
.UseRoute($"/api/Redistributables/{id}")
.GetAsync<SDK.Models.Manifest.Redistributable>();
}
public async Task<IEnumerable<Script>> GetScriptsAsync(Guid id)
{
return await apiRequestFactory
.Create()
.UseAuthenticationToken()
.UseVersioning()
.UseRoute($"/api/Redistributables/{id}/Scripts")
.GetAsync<IEnumerable<Script>>();
}
public async Task WriteScriptsAsync(Game game, Redistributable redistributable)
{
var scripts = await GetScriptsAsync(redistributable.Id);
if (scripts != null && scripts.Any())
{
logger?.LogTrace($"Saving scripts for redistributable {redistributable.Name} ({redistributable.Id}) into {game.InstallDirectory}");
foreach (var script in scripts)
await ScriptHelper.SaveScriptAsync(game, redistributable, script);
}
}
public async Task<bool> CheckForUpdateAsync(Guid id, string currentVersion)
{
return await apiRequestFactory
.Create()
.UseAuthenticationToken()
.UseVersioning()
.UseRoute($"/api/Redistributables/{id}/CheckForUpdate?version={currentVersion}")
.GetAsync<bool>();
}
public async Task<IEnumerable<Archive>> GetUpdatesAsync(Guid redistributableId, string version)
{
return await apiRequestFactory
.Create()
.UseAuthenticationToken()
.UseVersioning()
.UseRoute($"/api/Redistributables/{redistributableId}/Updates?version={version}")
.GetAsync<IEnumerable<Archive>>();
}
public async Task<Stream> Stream(Guid id)
{
return await apiRequestFactory
.Create()
.UseAuthenticationToken()
.UseVersioning()
.UseRoute($"/api/Redistributables/{id}/Download")
.StreamAsync();
}
private async Task<TrackableStream> StreamArchiveAsync(Guid archiveId)
{
return await apiRequestFactory
.Create()
.UseAuthenticationToken()
.UseVersioning()
.UseRoute($"/Download/Archive/{archiveId}")
.StreamAsync();
}
public async Task InstallAsync(Game game)
{
foreach (var redistributable in game.Redistributables)
{
await InstallAsync(redistributable, game);
}
}
public async Task InstallAsync(Redistributable redistributable, Game game, int maxAttempts = 10)
{
_installProgress = new InstallProgress();
_installProgress.Status = InstallStatus.Downloading;
_installProgress.Title = redistributable.Name;
_installProgress.Progress = 0;
_installProgress.TransferSpeed = 0;
_installProgress.TotalBytes = 0;
_installProgress.BytesTransferred = 0;
OnInstallProgressUpdate?.Invoke(_installProgress);
try
{
logger?.LogTrace("Saving manifest");
var manifest = await GetManifestAsync(redistributable.Id);
await ManifestHelper.WriteAsync(manifest, game.InstallDirectory);
logger?.LogTrace("Saving scripts");
await WriteScriptsAsync(game, redistributable);
var hasDetectInstallScript = redistributable.Scripts != null &&
redistributable.Scripts.Any(s => s.Type == Enums.ScriptType.DetectInstall);
var installed = hasDetectInstallScript &&
await scriptClient.Redistributable_RunDetectInstallScriptAsync(game.InstallDirectory, game.Id, redistributable.Id);
logger?.LogTrace("Redistributable install detection returned {Result} (hasDetectScript={HasDetectScript})", installed, hasDetectInstallScript);
if (!installed)
{
logger?.LogTrace("Redistributable {RedistributableName} not installed", redistributable.Name);
using (var fileTracker = new InstallDirectoryFileTracker(game.InstallDirectory))
{
if (redistributable.Archives?.Any() ?? false)
{
logger?.LogTrace("Archives for redistributable {RedistributableName} exist. Attempting to download...", redistributable.Name);
var result = await RetryHelper.RetryOnExceptionAsync(maxAttempts,
TimeSpan.FromMilliseconds(500), new ExtractionResult(),
async () =>
{
logger?.LogTrace("Attempting to download and extract redistributable");
return await DownloadAndExtractAsync(redistributable, game, CancellationToken.None);
});
if (!result.Success && !result.Canceled)
throw new InstallException("Could not extract the redistributable. Retry the install or check your connection");
if (result.Canceled)
throw new InstallCanceledException("Redistributable install canceled");
logger?.LogTrace("Extraction of redistributable successful. Extracted path is {Path}", result.Directory);
logger?.LogTrace("Running install script for redistributable {RedistributableName}", redistributable.Name);
await RunPostInstallScripts(game, redistributable);
}
else
{
logger?.LogTrace("No archives exist for redistributable {RedistributableName}. Running install script anyway...", redistributable.Name);
await RunPostInstallScripts(game, redistributable);
}
SaveTrackedFiles(game.InstallDirectory, redistributable.Id, fileTracker);
}
}
}
catch (Exception ex)
{
logger?.LogError(ex, "Redistributable {Redistributable} failed to install", redistributable.Name);
}
}
public async Task<bool> ApplyUpdateArchiveAsync(Guid archiveId, Guid redistributableId, Game game, CancellationToken cancellationToken = default)
{
_installProgress = new InstallProgress();
_installProgress.Status = InstallStatus.Downloading;
_installProgress.Title = game.Title;
_installProgress.Progress = 0;
OnInstallProgressUpdate?.Invoke(_installProgress);
var destination = Path.Combine(GameClient.GetMetadataDirectoryPath(game.InstallDirectory, redistributableId), "Files");
logger?.LogTrace("Downloading archive {ArchiveId} and extracting redistributable {RedistributableId} to path {Destination}", archiveId, redistributableId, destination);
try
{
Directory.CreateDirectory(destination);
using (var stream = await StreamArchiveAsync(archiveId))
{
var monitor = new FileTransferMonitor(stream.Length);
var progress = new Progress<ProgressReport>(report =>
{
if (cancellationToken.IsCancellationRequested)
return;
if (monitor.CanUpdate())
{
monitor.Update(stream.Position);
_installProgress.BytesTransferred = monitor.GetBytesTransferred();
_installProgress.TotalBytes = stream.Length;
_installProgress.TransferSpeed = monitor.GetSpeed();
_installProgress.TimeRemaining = monitor.GetTimeRemaining();
OnInstallProgressUpdate?.Invoke(_installProgress);
}
OnArchiveEntryExtractionProgress?.Invoke(this, new ArchiveEntryExtractionProgressArgs
{
Progress = report,
});
});
await using var reader = await ReaderFactory.OpenAsyncReader(stream, new ReaderOptions { Progress = progress }, cancellationToken);
await reader.WriteAllToDirectoryAsync(destination, new ExtractionOptions()
{
ExtractFullPath = true,
Overwrite = true
}, cancellationToken);
}
logger?.LogTrace("Successfully applied update archive {ArchiveId} for redistributable {RedistributableId}", archiveId, redistributableId);
return true;
}
catch (Exception ex)
{
logger?.LogError(ex, "Could not apply update archive {ArchiveId} for redistributable {RedistributableId}", archiveId, redistributableId);
if (Directory.Exists(destination))
{
logger?.LogTrace("Cleaning up orphaned files after bad update");
Directory.Delete(destination, true);
}
throw new InstallException("The redistributable update archive could not be extracted. Please try again");
}
}
public async Task RefreshManifestAndScriptsAsync(string installDirectory, Redistributable redistributable)
{
logger?.LogTrace("Refreshing manifest and scripts for redistributable {RedistributableId} in {InstallDirectory}", redistributable.Id, installDirectory);
var manifest = await GetManifestAsync(redistributable.Id);
await ManifestHelper.WriteAsync(manifest, installDirectory);
var scripts = await GetScriptsAsync(redistributable.Id);
if (scripts != null && scripts.Any())
{
var game = new Game { InstallDirectory = installDirectory };
foreach (var script in scripts)
await ScriptHelper.SaveScriptAsync(game, redistributable, script);
}
}
private async Task RunPostInstallScripts(Game game, Redistributable redistributable)
{
if (redistributable.Scripts != null && redistributable.Scripts.Any())
{
//GameInstallProgress.Status = GameInstallStatus.RunningScripts;
// OnGameInstallProgressUpdate?.Invoke(GameInstallProgress);
try
{
await scriptClient.Redistributable_RunInstallScriptAsync(game.InstallDirectory, game.Id, redistributable.Id);
await scriptClient.Redistributable_RunNameChangeScriptAsync(game.InstallDirectory, game.Id, redistributable.Id, await profileClient.GetAliasAsync());
}
catch (Exception ex)
{
logger?.LogError(ex, "Scripts failed to execute for redistributable {RedistributableName} ({GameId})", redistributable.Name, redistributable.Id);
}
}
}
private async Task<ExtractionResult> DownloadAndExtractAsync(Redistributable redistributable, Game game, CancellationToken cancellationToken = default)
{
if (redistributable == null)
{
logger?.LogTrace("Redistributable failed to download! No redistributable was specified");
throw new ArgumentNullException(nameof(redistributable));
}
var destination = Path.Combine(GameClient.GetMetadataDirectoryPath(game.InstallDirectory, redistributable.Id), "Files");
var files = new List<ExtractionResult.FileEntry>();
logger?.LogTrace("Downloading and extracting {Redistributable} to path {Destination}", redistributable.Name, destination);
try
{
Directory.CreateDirectory(destination);
using (var redistributableStream = await Stream(redistributable.Id))
{
var monitor = new FileTransferMonitor(redistributableStream.Length);
var seenEntries = new System.Collections.Generic.HashSet<string>(StringComparer.OrdinalIgnoreCase);
var progress = new Progress<ProgressReport>(report =>
{
if (!string.IsNullOrEmpty(report.EntryPath) && seenEntries.Add(report.EntryPath))
{
files.Add(new ExtractionResult.FileEntry
{
EntryPath = report.EntryPath,
LocalPath = Path.Combine(destination, report.EntryPath),
});
}
if (monitor.CanUpdate())
{
monitor.Update(redistributableStream.Position);
_installProgress.BytesTransferred = monitor.GetBytesTransferred();
_installProgress.TotalBytes = redistributableStream.Length;
_installProgress.TransferSpeed = monitor.GetSpeed();
_installProgress.TimeRemaining = monitor.GetTimeRemaining();
OnInstallProgressUpdate?.Invoke(_installProgress);
}
OnArchiveEntryExtractionProgress?.Invoke(this, new ArchiveEntryExtractionProgressArgs
{
Progress = report,
});
});
await using var reader = await ReaderFactory.OpenAsyncReader(redistributableStream, new ReaderOptions { Progress = progress }, cancellationToken);
await reader.WriteAllToDirectoryAsync(destination, new ExtractionOptions()
{
ExtractFullPath = true,
Overwrite = true
}, cancellationToken);
}
}
catch (Exception ex)
{
logger?.LogError(ex, "Could not extract to path {Destination}", destination);
if (Directory.Exists(destination))
{
logger?.LogTrace("Cleaning up orphaned files after bad install");
Directory.Delete(destination, true);
}
throw new Exception("The redistributable archive could not be extracted, is it corrupted? Please try again");
}
var extractionResult = new ExtractionResult
{
Canceled = false
};
if (!extractionResult.Canceled)
{
extractionResult.Success = true;
extractionResult.Directory = destination;
extractionResult.Files = files;
logger?.LogTrace("Redistributable {Redistributable} successfully downloaded and extracted to {Destination}", redistributable.Name, destination);
}
return extractionResult;
}
public async Task ImportAsync(string archivePath)
{
using (var fs = new FileStream(archivePath, FileMode.Open, FileAccess.Read))
{
var objectKey = await apiRequestFactory
.Create()
.UseAuthenticationToken()
.UseVersioning()
.UploadInChunksAsync(settingsProvider.CurrentValue.Archives.UploadChunkSize, fs);
if (objectKey != Guid.Empty)
await apiRequestFactory
.Create()
.UseAuthenticationToken()
.UseVersioning()
.UseRoute($"/api/Redistributables/Import/{objectKey}")
.PostAsync();
}
}
[Obsolete("Exporter no longer provides \"full\" exports")]
public async Task ExportAsync(string destinationPath, Guid redistributableId)
{
await apiRequestFactory
.Create()
.UseAuthenticationToken()
.UseVersioning()
.UseRoute($"/Redistributables/{redistributableId}/Export/Full")
.DownloadAsync(destinationPath);
}
public async Task UploadArchiveAsync(string archivePath, Guid redistributableId, string version, string changelog = "")
{
using (var fs = new FileStream(archivePath, FileMode.Open, FileAccess.Read))
{
var objectKey = await apiRequestFactory
.Create()
.UseAuthenticationToken()
.UseVersioning()
.UploadInChunksAsync(settingsProvider.CurrentValue.Archives.UploadChunkSize, fs);
if (objectKey != Guid.Empty)
await apiRequestFactory
.Create()
.UseAuthenticationToken()
.UseVersioning()
.UseRoute("/api/Redistributables/UploadArchive")
.AddBody(new UploadArchiveRequest
{
Id = redistributableId,
ObjectKey = objectKey,
Version = version,
Changelog = changelog,
})
.PostAsync();
}
}
private void SaveTrackedFiles(string installDirectory, Guid redistributableId, InstallDirectoryFileTracker fileTracker)
{
try
{
var relativePaths = fileTracker.GetCreatedFiles()
.Select(f => Path.GetRelativePath(installDirectory, f))
.OrderBy(f => f)
.ToList();
var fileListPath = GameClient.GetMetadataFilePath(installDirectory, redistributableId, "FileList.txt");
var directory = Path.GetDirectoryName(fileListPath);
if (!Directory.Exists(directory))
Directory.CreateDirectory(directory);
File.WriteAllText(fileListPath, string.Join(Environment.NewLine, relativePaths));
logger?.LogTrace("Tracked {Count} files installed by redistributable {RedistributableId}", relativePaths.Count, redistributableId);
}
catch (Exception ex)
{
logger?.LogWarning(ex, "Could not track files for redistributable {RedistributableId}", redistributableId);
}
}
private class InstallDirectoryFileTracker : IDisposable
{
private readonly FileSystemWatcher _watcher;
private readonly HashSet<string> _createdFiles = new(StringComparer.OrdinalIgnoreCase);
private readonly string _metadataPath;
private readonly object _lock = new();
public InstallDirectoryFileTracker(string installDirectory)
{
_metadataPath = Path.Combine(installDirectory, ".lancommander");
_watcher = new FileSystemWatcher(installDirectory)
{
IncludeSubdirectories = true,
NotifyFilter = NotifyFilters.FileName,
EnableRaisingEvents = true,
};
_watcher.Created += OnFileCreated;
_watcher.Renamed += OnFileRenamed;
}
private void OnFileCreated(object sender, FileSystemEventArgs e)
{
if (e.FullPath.StartsWith(_metadataPath, StringComparison.OrdinalIgnoreCase))
return;
lock (_lock)
_createdFiles.Add(e.FullPath);
}
private void OnFileRenamed(object sender, RenamedEventArgs e)
{
if (e.FullPath.StartsWith(_metadataPath, StringComparison.OrdinalIgnoreCase))
return;
lock (_lock)
_createdFiles.Add(e.FullPath);
}
public IEnumerable<string> GetCreatedFiles()
{
lock (_lock)
return _createdFiles.ToList();
}
public void Dispose()
{
_watcher.EnableRaisingEvents = false;
_watcher.Created -= OnFileCreated;
_watcher.Renamed -= OnFileRenamed;
_watcher.Dispose();
}
}
}
}