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.Text; using System.Threading; using System.Threading.Tasks; using Force.Crc32; using LANCommander.SDK.Abstractions; using LANCommander.SDK.Exceptions; using LANCommander.SDK.Factories; using Action = System.Action; namespace LANCommander.SDK.Services { public class ToolClient( ILogger logger, ISettingsProvider settingsProvider, ApiRequestFactory apiRequestFactory, ScriptClient scriptClient) { public delegate void OnArchiveEntryExtractionProgressHandler(object sender, ArchiveEntryExtractionProgressArgs e); public event OnArchiveEntryExtractionProgressHandler OnArchiveEntryExtractionProgress; public delegate void OnArchiveExtractionProgressHandler(long position, long length, Tool tool); public event OnArchiveExtractionProgressHandler OnArchiveExtractionProgress; public delegate void OnInstallProgressUpdateHandler(InstallProgress e); public event OnInstallProgressUpdateHandler OnInstallProgressUpdate; private IAsyncReader _reader; private InstallProgress _installProgress; public async Task GetAsync(Guid id) { return await apiRequestFactory .Create() .UseAuthenticationToken() .UseVersioning() .UseRoute($"/api/Tools/{id}") .GetAsync(); } public async Task GetManifestAsync(Guid id) { return await apiRequestFactory .Create() .UseAuthenticationToken() .UseVersioning() .UseRoute($"/api/Tools/{id}/Manifest") .GetAsync(); } public async Task> GetScriptsAsync(Guid id) { return await apiRequestFactory .Create() .UseAuthenticationToken() .UseVersioning() .UseRoute($"/api/Tool/{id}/Scripts") .GetAsync>(); } public async Task WriteScriptsAsync(Tool tool, string installDirectory) { var scripts = await GetScriptsAsync(tool.Id); if (scripts != null && scripts.Any()) { logger?.LogTrace($"Saving scripts for tool {tool.Name} ({tool.Id}) into {installDirectory}"); foreach (var script in scripts) await ScriptHelper.SaveScriptAsync(tool, script, installDirectory); } } public async Task Stream(Guid id) { return await apiRequestFactory .Create() .UseAuthenticationToken() .UseVersioning() .UseRoute($"/api/Tool/{id}/Download") .StreamAsync(); } public delegate void OnTaskProgressHandler(InstallTaskProgress progress); public event OnTaskProgressHandler OnTaskProgress; public async Task 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, }); toolItem.Tasks.Add(new InstallTaskDefinition { Type = InstallTaskType.WriteManifest, Title = "Write manifest", Order = taskOrder++, TargetId = tool.Id, TargetName = tool.Name, IsCritical = 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 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.WriteManifest: var manifest = await GetManifestAsync(tool.Id); await ManifestHelper.WriteAsync(manifest, planItem.InstallDirectory); 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 UninstallAsync(string installDirectory, Guid toolId) { logger?.LogTrace("Uninstalling tool {ToolId} from {InstallDirectory}", toolId, installDirectory); try { await scriptClient.Tool_RunUninstallScriptAsync(installDirectory, toolId); } catch (Exception ex) { logger?.LogWarning(ex, "Could not run uninstall script for tool {ToolId}", toolId); } var fileListPath = GameClient.GetMetadataFilePath(installDirectory, toolId, "FileList.txt"); if (File.Exists(fileListPath)) { var fileList = await File.ReadAllLinesAsync(fileListPath); var files = fileList.Select(l => l.Split('|').FirstOrDefault()?.Trim()); foreach (var file in files.Where(f => !string.IsNullOrWhiteSpace(f) && !f.EndsWith("/"))) { var localPath = Path.Combine(installDirectory, file); try { if (File.Exists(localPath)) File.Delete(localPath); logger?.LogTrace("Deleted tool file {LocalPath}", localPath); } catch (Exception ex) { logger?.LogWarning(ex, "Could not remove tool file {LocalPath}", localPath); } } } var metadataPath = GameClient.GetMetadataDirectoryPath(installDirectory, toolId); if (Directory.Exists(metadataPath)) { try { Directory.Delete(metadataPath, true); } catch (Exception ex) { logger?.LogWarning(ex, "Could not remove tool metadata directory {MetadataPath}", metadataPath); } } } public async Task InstallAsync(Game game) { foreach (var tool in game.Tools) { await InstallAsync(tool, game.InstallDirectory); } } public async Task InstallAsync(Tool tool, string installDirectory, int maxAttempts = 10) { string extractTempPath = null; var installResult = new InstallResult(); _installProgress = new InstallProgress { Status = InstallStatus.Downloading, Title = tool.Name, Progress = 0, TransferSpeed = 0, TotalBytes = 0, BytesTransferred = 0 }; OnInstallProgressUpdate?.Invoke(_installProgress); try { logger?.LogTrace("Saving manifest"); var manifest = await GetManifestAsync(tool.Id); await ManifestHelper.WriteAsync(manifest, installDirectory); logger?.LogTrace("Saving scripts"); await WriteScriptsAsync(tool, installDirectory); if (tool.Archives?.Any() ?? false) { logger?.LogTrace("Archives for tool {ToolName} exist. Attempting to download...", tool.Name); var result = await RetryHelper.RetryOnExceptionAsync(maxAttempts, TimeSpan.FromMilliseconds(500), new ExtractionResult(), async () => { logger?.LogTrace("Attempting to download and extract tool"); return await Task.Run(async () => await DownloadAndExtractAsync(tool, installDirectory)); }); 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 canceled"); extractTempPath = result.Directory; logger?.LogTrace("Extraction of tool successful. Extracted path is {Path}", extractTempPath); logger?.LogTrace("Running install script for tool {ToolName}", tool.Name); await RunPostInstallScripts(installDirectory, tool); } if (tool.Archives?.Any() ?? false) { logger?.LogTrace("Archives for tool {ToolName} exist. Attempting to download...", tool.Name); var result = await RetryHelper.RetryOnExceptionAsync(maxAttempts, TimeSpan.FromMilliseconds(500), new ExtractionResult(), async () => { logger?.LogTrace("Attempting to download and extract tool"); return await Task.Run(async () => await DownloadAndExtractAsync(tool, installDirectory)); }); 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 canceled"); extractTempPath = result.Directory; installResult.InstallDirectory = result.Directory; // TODO: Verification for tool files? var toolFiles = result?.Files?.Where(x => !x.EntryPath.EndsWith("/")).Select(x => new GameInstallationFileListEntry.FileEntry { EntryPath = x.EntryPath, LocalPath = x.LocalPath, }); logger?.LogTrace("Extraction of tool successful. Extracted path is {Path}", extractTempPath); logger?.LogTrace("Running install script for tool {ToolName}", tool.Name); await RunPostInstallScripts(installDirectory, tool); } else { logger?.LogTrace("No archives exist for tool {ToolName}. Running install script anyway...", tool.Name); await RunPostInstallScripts(installDirectory, tool); } } catch (Exception ex) { logger?.LogError(ex, "Tool {Tool} failed to install", tool.Name); } finally { if (Directory.Exists(extractTempPath)) Directory.Delete(extractTempPath, true); } return installResult; } private async Task RunPostInstallScripts(string installDirectory, Tool tool) { if (tool.Scripts != null && tool.Scripts.Any()) { //GameInstallProgress.Status = GameInstallStatus.RunningScripts; // OnGameInstallProgressUpdate?.Invoke(GameInstallProgress); try { await scriptClient.Tool_RunInstallScriptAsync(installDirectory, tool.Id); } catch (Exception ex) { logger?.LogError(ex, "Scripts failed to execute for tool {ToolName} ({GameId})", tool.Name, tool.Id); } } } private async Task DownloadAndExtractAsync(Tool tool, string destination, CancellationToken cancellationToken = default) { if (tool == null) { logger?.LogTrace("Tool failed to download! No tool was specified"); throw new ArgumentNullException(nameof(tool)); } logger?.LogTrace("Downloading and extracting {Tool} to path {Destination}", tool.Name, destination); var extractionResult = new ExtractionResult { Canceled = false, }; if (!await CanStreamLatestArchiveAsync(tool.Id)) { extractionResult.Success = false; extractionResult.Canceled = true; return extractionResult; } var fileManifest = new StringBuilder(); var files = new List(); try { Directory.CreateDirectory(destination); var stream = await StreamLatestArchiveAsync(tool.Id); var monitor = new FileTransferMonitor(stream.Length); var progress = new Progress(report => { if (cancellationToken.IsCancellationRequested) { _reader?.Cancel(); _installProgress.Status = InstallStatus.Canceled; OnInstallProgressUpdate?.Invoke(_installProgress); 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, Game = null, }); }); _reader = await ReaderFactory.OpenAsyncReader(stream, new ReaderOptions { Progress = progress }, cancellationToken); while (await _reader.MoveToNextEntryAsync(cancellationToken)) { if (_reader.Cancelled) break; try { var localFile = Path.Combine(destination, _reader.Entry.Key); uint crc = 0; if (File.Exists(localFile)) { await using FileStream fs = File.Open(localFile, FileMode.Open); var buffer = new byte[65536]; while (true) { var count = await fs.ReadAsync(buffer, 0, buffer.Length, cancellationToken); if (count == 0) break; crc = Crc32Algorithm.Append(crc, buffer, 0, count); } } fileManifest.AppendLine($"{_reader.Entry.Key} | {_reader.Entry.Crc.ToString("X")}"); files.Add(new ExtractionResult.FileEntry { EntryPath = _reader.Entry.Key, LocalPath = localFile, }); if (crc == 0 || crc != _reader.Entry.Crc) await _reader.WriteEntryToDirectoryAsync(destination, new ExtractionOptions() { ExtractFullPath = true, Overwrite = true, PreserveFileTime = true }, cancellationToken); else // Skip to next entry try { await using var es = await _reader.OpenEntryStreamAsync(cancellationToken); } catch { logger?.LogError("Could not skip to next entry in archive: {EntryKey}", _reader.Entry.Key); } } catch (IOException ex) { var errorCode = ex.HResult & 0xFFFF; if (errorCode == 87) throw ex; else logger?.LogTrace("Not replacing existing file/folder on disk: {EntryKey} - {Message}", _reader.Entry.Key, ex.Message); // Skip to next entry await using var es = await _reader.OpenEntryStreamAsync(cancellationToken); } } await _reader.DisposeAsync(); await stream.DisposeAsync(); } catch (ReaderCancelledException ex) { logger?.LogTrace(ex, "User cancelled the download"); extractionResult.Canceled = true; if (Directory.Exists(destination)) { logger?.LogTrace("Cleaning up orphaned files after cancelled install"); Directory.Delete(destination, true); } } catch (Exception ex) { logger?.LogError(ex, "Could not extract to path {Destination}", destination); if (Directory.Exists(destination)) { logger?.LogTrace("Cleaning up orphaned install files after bad install"); Directory.Delete(destination, true); } throw new Exception("The game archive could not be extracted, is it corrupted? Please try again"); } if (!extractionResult.Canceled) { extractionResult.Success = true; extractionResult.Directory = destination; extractionResult.Files = files; var fileListDestination = Path.Combine(destination, ".lancommander", tool.Id.ToString(), "FileList.txt"); if (!Directory.Exists(Path.GetDirectoryName(fileListDestination))) Directory.CreateDirectory(Path.GetDirectoryName(fileListDestination)); await File.WriteAllTextAsync(fileListDestination, fileManifest.ToString(), cancellationToken); logger?.LogTrace("Tool {Tool} successfully downloaded and extracted to {Destination}", tool.Name, destination); } return extractionResult; } private async Task CanStreamLatestArchiveAsync(Guid id) { try { await apiRequestFactory .Create() .UseAuthenticationToken() .UseVersioning() .UseRoute($"/api/Tools/{id}/Download") .HeadAsync(); return true; } catch { return false; } } private async Task StreamLatestArchiveAsync(Guid id) { return await apiRequestFactory .Create() .UseAuthenticationToken() .UseVersioning() .UseRoute($"/api/Tools/{id}/Download") .StreamAsync(); } public async Task ImportAsync(string archivePath) { await 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/Tools/Import/{objectKey}") .PostAsync(); } [Obsolete("Exporter no longer provides \"full\" exports")] public async Task ExportAsync(string destinationPath, Guid toolId) { await apiRequestFactory .Create() .UseAuthenticationToken() .UseVersioning() .UseRoute($"/Tools/{toolId}/Export/Full") .DownloadAsync(destinationPath); } public async Task UploadArchiveAsync(string archivePath, Guid toolId, string version, string changelog = "") { await 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/Tools/UploadArchive") .AddBody(new UploadArchiveRequest { Id = toolId, ObjectKey = objectKey, Version = version, Changelog = changelog, }) .PostAsync(); } } }