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 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 GetManifestAsync(Guid id) { return await apiRequestFactory .Create() .UseAuthenticationToken() .UseVersioning() .UseRoute($"/api/Redistributables/{id}") .GetAsync(); } public async Task> GetScriptsAsync(Guid id) { return await apiRequestFactory .Create() .UseAuthenticationToken() .UseVersioning() .UseRoute($"/api/Redistributables/{id}/Scripts") .GetAsync>(); } 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 CheckForUpdateAsync(Guid id, string currentVersion) { return await apiRequestFactory .Create() .UseAuthenticationToken() .UseVersioning() .UseRoute($"/api/Redistributables/{id}/CheckForUpdate?version={currentVersion}") .GetAsync(); } public async Task> GetUpdatesAsync(Guid redistributableId, string version) { return await apiRequestFactory .Create() .UseAuthenticationToken() .UseVersioning() .UseRoute($"/api/Redistributables/{redistributableId}/Updates?version={version}") .GetAsync>(); } public async Task Stream(Guid id) { return await apiRequestFactory .Create() .UseAuthenticationToken() .UseVersioning() .UseRoute($"/api/Redistributables/{id}/Download") .StreamAsync(); } private async Task 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 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(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 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(); 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(StringComparer.OrdinalIgnoreCase); var progress = new Progress(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 _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 GetCreatedFiles() { lock (_lock) return _createdFiles.ToList(); } public void Dispose() { _watcher.EnableRaisingEvents = false; _watcher.Created -= OnFileCreated; _watcher.Renamed -= OnFileRenamed; _watcher.Dispose(); } } } }