diff --git a/src/c#/ExtensionTest/DTOs/HttpResponseDTOTests.cs b/src/c#/ExtensionTest/DTOs/HttpResponseDTOTests.cs new file mode 100644 index 00000000..40976e58 --- /dev/null +++ b/src/c#/ExtensionTest/DTOs/HttpResponseDTOTests.cs @@ -0,0 +1,71 @@ +using GeneralUpdate.Extension.DTOs; +using Xunit; + +namespace ExtensionTest.DTOs +{ + /// + /// Contains test cases for HttpResponseDTO classes + /// + public class HttpResponseDTOTests + { + [Fact] + public void HttpResponseDTO_Success_ShouldCreateSuccessResponse() + { + // Act + var response = HttpResponseDTO.Success("Operation successful"); + + // Assert + Assert.Equal(200, response.Code); + Assert.Equal("Operation successful", response.Message); + } + + [Fact] + public void HttpResponseDTO_InnerException_ShouldCreateErrorResponse() + { + // Act + var response = HttpResponseDTO.InnerException("An error occurred"); + + // Assert + Assert.Equal(500, response.Code); + Assert.Equal("An error occurred", response.Message); + } + + [Fact] + public void HttpResponseDTOGeneric_Success_ShouldCreateSuccessResponseWithData() + { + // Arrange + var testData = new { Id = 1, Name = "Test" }; + + // Act + var response = HttpResponseDTO.Success(testData, "Data retrieved"); + + // Assert + Assert.Equal(200, response.Code); + Assert.Equal("Data retrieved", response.Message); + Assert.NotNull(response.Body); + Assert.Equal(testData, response.Body); + } + + [Fact] + public void HttpResponseDTOGeneric_Failure_ShouldCreateFailureResponse() + { + // Act + var response = HttpResponseDTO.Failure("Validation failed"); + + // Assert + Assert.Equal(400, response.Code); + Assert.Equal("Validation failed", response.Message); + } + + [Fact] + public void HttpResponseDTOGeneric_InnerException_ShouldCreateErrorResponse() + { + // Act + var response = HttpResponseDTO.InnerException("Internal error"); + + // Assert + Assert.Equal(500, response.Code); + Assert.Equal("Internal error", response.Message); + } + } +} diff --git a/src/c#/ExtensionTest/DTOs/PagedResultDTOTests.cs b/src/c#/ExtensionTest/DTOs/PagedResultDTOTests.cs new file mode 100644 index 00000000..910d6d07 --- /dev/null +++ b/src/c#/ExtensionTest/DTOs/PagedResultDTOTests.cs @@ -0,0 +1,92 @@ +using System.Collections.Generic; +using System.Linq; +using GeneralUpdate.Extension.DTOs; +using Xunit; + +namespace ExtensionTest.DTOs +{ + /// + /// Contains test cases for PagedResultDTO + /// + public class PagedResultDTOTests + { + [Fact] + public void PagedResultDTO_HasPrevious_ShouldBeTrueWhenPageNumberGreaterThanOne() + { + // Arrange + var result = new PagedResultDTO + { + PageNumber = 2, + PageSize = 10, + TotalCount = 30, + TotalPages = 3, + Items = new List { "item1", "item2" } + }; + + // Act & Assert + Assert.True(result.HasPrevious); + } + + [Fact] + public void PagedResultDTO_HasPrevious_ShouldBeFalseWhenPageNumberIsOne() + { + // Arrange + var result = new PagedResultDTO + { + PageNumber = 1, + PageSize = 10, + TotalCount = 30, + TotalPages = 3, + Items = new List { "item1", "item2" } + }; + + // Act & Assert + Assert.False(result.HasPrevious); + } + + [Fact] + public void PagedResultDTO_HasNext_ShouldBeTrueWhenPageNumberLessThanTotalPages() + { + // Arrange + var result = new PagedResultDTO + { + PageNumber = 2, + PageSize = 10, + TotalCount = 30, + TotalPages = 3, + Items = new List { "item1", "item2" } + }; + + // Act & Assert + Assert.True(result.HasNext); + } + + [Fact] + public void PagedResultDTO_HasNext_ShouldBeFalseWhenPageNumberEqualsToTotalPages() + { + // Arrange + var result = new PagedResultDTO + { + PageNumber = 3, + PageSize = 10, + TotalCount = 30, + TotalPages = 3, + Items = new List { "item1", "item2" } + }; + + // Act & Assert + Assert.False(result.HasNext); + } + + [Fact] + public void PagedResultDTO_Items_ShouldDefaultToEmptyEnumerable() + { + // Arrange & Act + var result = new PagedResultDTO(); + + // Assert + Assert.NotNull(result.Items); + Assert.Empty(result.Items); + } + } +} diff --git a/src/c#/ExtensionTest/Services/ExtensionServiceTests.cs b/src/c#/ExtensionTest/Services/ExtensionServiceTests.cs new file mode 100644 index 00000000..87073cb5 --- /dev/null +++ b/src/c#/ExtensionTest/Services/ExtensionServiceTests.cs @@ -0,0 +1,407 @@ +using System; +using System.Collections.Generic; +using System.Linq; +using System.Threading.Tasks; +using GeneralUpdate.Extension.DTOs; +using GeneralUpdate.Extension.Metadata; +using GeneralUpdate.Extension.Services; +using Moq; +using Xunit; + +namespace ExtensionTest.Services +{ + /// + /// Contains test cases for ExtensionService + /// + public class ExtensionServiceTests + { + private List CreateTestExtensions() + { + return new List + { + new AvailableExtension + { + Descriptor = new ExtensionDescriptor + { + Name = "test-extension-1", + DisplayName = "Test Extension 1", + Version = "1.0.0", + Description = "First test extension", + Publisher = "TestPublisher", + Categories = new List { "Testing", "Development" }, + SupportedPlatforms = TargetPlatform.All, + PackageSize = 1024, + PackageHash = "hash1", + DownloadUrl = "https://example.com/ext1.zip", + Compatibility = new VersionCompatibility + { + MinHostVersion = new Version(1, 0, 0), + MaxHostVersion = new Version(2, 0, 0) + } + }, + IsPreRelease = false + }, + new AvailableExtension + { + Descriptor = new ExtensionDescriptor + { + Name = "test-extension-2", + DisplayName = "Test Extension 2", + Version = "2.0.0", + Description = "Second test extension", + Publisher = "AnotherPublisher", + Categories = new List { "Utilities" }, + SupportedPlatforms = TargetPlatform.Windows | TargetPlatform.Linux, + PackageSize = 2048, + PackageHash = "hash2", + DownloadUrl = "https://example.com/ext2.zip" + }, + IsPreRelease = true + }, + new AvailableExtension + { + Descriptor = new ExtensionDescriptor + { + Name = "test-extension-3", + DisplayName = "Test Extension 3", + Version = "1.5.0", + Description = "Third test extension", + Publisher = "TestPublisher", + Categories = new List { "Testing" }, + SupportedPlatforms = TargetPlatform.MacOS, + PackageSize = 512, + PackageHash = "hash3", + DownloadUrl = "https://example.com/ext3.zip" + }, + IsPreRelease = false + } + }; + } + + private ExtensionService CreateExtensionService(List extensions) + { + var updateQueue = new GeneralUpdate.Extension.Download.UpdateQueue(); + return new ExtensionService(extensions, "/tmp/test-downloads", updateQueue); + } + + [Fact] + public async Task Query_WithValidQuery_ShouldReturnPagedResults() + { + // Arrange + var extensions = CreateTestExtensions(); + var service = CreateExtensionService(extensions); + var query = new ExtensionQueryDTO + { + PageNumber = 1, + PageSize = 10, + IncludePreRelease = true // Include pre-release to get all 3 extensions + }; + + // Act + var result = await service.Query(query); + + // Assert + Assert.NotNull(result); + Assert.Equal(200, result.Code); + Assert.NotNull(result.Body); + Assert.Equal(3, result.Body.TotalCount); + Assert.Equal(1, result.Body.TotalPages); + Assert.Equal(3, result.Body.Items.Count()); + } + + [Fact] + public async Task Query_WithPagination_ShouldReturnCorrectPage() + { + // Arrange + var extensions = CreateTestExtensions(); + var service = CreateExtensionService(extensions); + var query = new ExtensionQueryDTO + { + PageNumber = 1, + PageSize = 2, + IncludePreRelease = true // Include pre-release to get all 3 extensions + }; + + // Act + var result = await service.Query(query); + + // Assert + Assert.NotNull(result); + Assert.Equal(200, result.Code); + Assert.NotNull(result.Body); + Assert.Equal(3, result.Body.TotalCount); + Assert.Equal(2, result.Body.TotalPages); + Assert.Equal(2, result.Body.Items.Count()); + Assert.True(result.Body.HasNext); + Assert.False(result.Body.HasPrevious); + } + + [Fact] + public async Task Query_WithNameFilter_ShouldReturnMatchingExtensions() + { + // Arrange + var extensions = CreateTestExtensions(); + var service = CreateExtensionService(extensions); + var query = new ExtensionQueryDTO + { + PageNumber = 1, + PageSize = 10, + Name = "extension-1" + }; + + // Act + var result = await service.Query(query); + + // Assert + Assert.NotNull(result); + Assert.Equal(200, result.Code); + Assert.NotNull(result.Body); + Assert.Equal(1, result.Body.TotalCount); + Assert.Single(result.Body.Items); + Assert.Equal("test-extension-1", result.Body.Items.First().Name); + } + + [Fact] + public async Task Query_WithPublisherFilter_ShouldReturnMatchingExtensions() + { + // Arrange + var extensions = CreateTestExtensions(); + var service = CreateExtensionService(extensions); + var query = new ExtensionQueryDTO + { + PageNumber = 1, + PageSize = 10, + Publisher = "TestPublisher" + }; + + // Act + var result = await service.Query(query); + + // Assert + Assert.NotNull(result); + Assert.Equal(200, result.Code); + Assert.NotNull(result.Body); + Assert.Equal(2, result.Body.TotalCount); + } + + [Fact] + public async Task Query_WithCategoryFilter_ShouldReturnMatchingExtensions() + { + // Arrange + var extensions = CreateTestExtensions(); + var service = CreateExtensionService(extensions); + var query = new ExtensionQueryDTO + { + PageNumber = 1, + PageSize = 10, + Category = "Testing" + }; + + // Act + var result = await service.Query(query); + + // Assert + Assert.NotNull(result); + Assert.Equal(200, result.Code); + Assert.NotNull(result.Body); + Assert.Equal(2, result.Body.TotalCount); + } + + [Fact] + public async Task Query_WithPlatformFilter_ShouldReturnMatchingExtensions() + { + // Arrange + var extensions = CreateTestExtensions(); + var service = CreateExtensionService(extensions); + var query = new ExtensionQueryDTO + { + PageNumber = 1, + PageSize = 10, + TargetPlatform = TargetPlatform.Windows, + IncludePreRelease = true // Include pre-release to get all matching extensions + }; + + // Act + var result = await service.Query(query); + + // Assert + Assert.NotNull(result); + Assert.Equal(200, result.Code); + Assert.NotNull(result.Body); + // Should return extensions that support Windows (extension-1 with All, and extension-2 with Windows|Linux) + Assert.Equal(2, result.Body.TotalCount); + } + + [Fact] + public async Task Query_ExcludePreRelease_ShouldNotReturnPreReleaseExtensions() + { + // Arrange + var extensions = CreateTestExtensions(); + var service = CreateExtensionService(extensions); + var query = new ExtensionQueryDTO + { + PageNumber = 1, + PageSize = 10, + IncludePreRelease = false + }; + + // Act + var result = await service.Query(query); + + // Assert + Assert.NotNull(result); + Assert.Equal(200, result.Code); + Assert.NotNull(result.Body); + Assert.Equal(2, result.Body.TotalCount); + Assert.All(result.Body.Items, item => Assert.False(item.IsPreRelease)); + } + + [Fact] + public async Task Query_WithSearchTerm_ShouldReturnMatchingExtensions() + { + // Arrange + var extensions = CreateTestExtensions(); + var service = CreateExtensionService(extensions); + var query = new ExtensionQueryDTO + { + PageNumber = 1, + PageSize = 10, + SearchTerm = "Second", + IncludePreRelease = true // Include pre-release to find the matching extension + }; + + // Act + var result = await service.Query(query); + + // Assert + Assert.NotNull(result); + Assert.Equal(200, result.Code); + Assert.NotNull(result.Body); + Assert.Equal(1, result.Body.TotalCount); + Assert.Equal("test-extension-2", result.Body.Items.First().Name); + } + + [Fact] + public async Task Query_WithInvalidPageNumber_ShouldReturnFailure() + { + // Arrange + var extensions = CreateTestExtensions(); + var service = CreateExtensionService(extensions); + var query = new ExtensionQueryDTO + { + PageNumber = 0, + PageSize = 10 + }; + + // Act + var result = await service.Query(query); + + // Assert + Assert.NotNull(result); + Assert.Equal(400, result.Code); + Assert.Contains("PageNumber", result.Message); + } + + [Fact] + public async Task Query_WithInvalidPageSize_ShouldReturnFailure() + { + // Arrange + var extensions = CreateTestExtensions(); + var service = CreateExtensionService(extensions); + var query = new ExtensionQueryDTO + { + PageNumber = 1, + PageSize = 0 + }; + + // Act + var result = await service.Query(query); + + // Assert + Assert.NotNull(result); + Assert.Equal(400, result.Code); + Assert.Contains("PageSize", result.Message); + } + + [Fact] + public async Task Query_WithNullQuery_ShouldReturnFailure() + { + // Arrange + var extensions = CreateTestExtensions(); + var service = CreateExtensionService(extensions); + + // Act + var result = await service.Query(null!); + + // Assert + Assert.NotNull(result); + Assert.Equal(400, result.Code); + Assert.Contains("null", result.Message); + } + + [Fact] + public void UpdateAvailableExtensions_ShouldUpdateExtensionsList() + { + // Arrange + var initialExtensions = CreateTestExtensions(); + var service = CreateExtensionService(initialExtensions); + var newExtensions = new List + { + new AvailableExtension + { + Descriptor = new ExtensionDescriptor + { + Name = "new-extension", + DisplayName = "New Extension", + Version = "1.0.0" + } + } + }; + + // Act + service.UpdateAvailableExtensions(newExtensions); + + // Assert + var query = new ExtensionQueryDTO { PageNumber = 1, PageSize = 10 }; + var result = service.Query(query).Result; + Assert.Equal(1, result.Body.TotalCount); + Assert.Equal("new-extension", result.Body.Items.First().Name); + } + + [Fact] + public async Task Download_WithInvalidId_ShouldReturnFailure() + { + // Arrange + var extensions = CreateTestExtensions(); + var service = CreateExtensionService(extensions); + + // Act + var result = await service.Download("non-existent-extension"); + + // Assert + Assert.NotNull(result); + Assert.Equal(400, result.Code); + // Should fail because extension is not found + Assert.Contains("not found", result.Message); + } + + [Fact] + public async Task Download_WithNullOrEmptyId_ShouldReturnFailure() + { + // Arrange + var extensions = CreateTestExtensions(); + var service = CreateExtensionService(extensions); + + // Act + var result1 = await service.Download(null!); + var result2 = await service.Download(string.Empty); + + // Assert + Assert.Equal(400, result1.Code); + Assert.Equal(400, result2.Code); + // Should fail because ID is null or empty + Assert.Contains("null or empty", result1.Message); + Assert.Contains("null or empty", result2.Message); + } + } +} diff --git a/src/c#/GeneralUpdate.Common/Download/DownloadTask.cs b/src/c#/GeneralUpdate.Common/Download/DownloadTask.cs index bd50a7e2..40de3d61 100644 --- a/src/c#/GeneralUpdate.Common/Download/DownloadTask.cs +++ b/src/c#/GeneralUpdate.Common/Download/DownloadTask.cs @@ -30,6 +30,14 @@ public DownloadTask(DownloadManager manager, VersionInfo version) _manager = manager; _version = version; _httpClient = new HttpClient { Timeout = TimeSpan.FromSeconds(_manager.TimeOut) }; + + // Set authentication headers if provided + if (!string.IsNullOrEmpty(version?.AuthScheme) && !string.IsNullOrEmpty(version?.AuthToken)) + { + _httpClient.DefaultRequestHeaders.Authorization = + new AuthenticationHeaderValue(version.AuthScheme, version.AuthToken); + } + _timer = new Timer(_=> Statistics(), null, 0, 1000); } diff --git a/src/c#/GeneralUpdate.Common/Shared/Object/VersionInfo.cs b/src/c#/GeneralUpdate.Common/Shared/Object/VersionInfo.cs index 47b8659e..27741084 100644 --- a/src/c#/GeneralUpdate.Common/Shared/Object/VersionInfo.cs +++ b/src/c#/GeneralUpdate.Common/Shared/Object/VersionInfo.cs @@ -40,4 +40,16 @@ public class VersionInfo [JsonPropertyName("size")] public long? Size { get; set; } + + /// + /// HTTP authentication scheme (e.g., "Bearer", "Basic") for download requests. + /// + [JsonPropertyName("authScheme")] + public string? AuthScheme { get; set; } + + /// + /// HTTP authentication token for download requests. + /// + [JsonPropertyName("authToken")] + public string? AuthToken { get; set; } } \ No newline at end of file diff --git a/src/c#/GeneralUpdate.Extension/DTOs/DownloadExtensionDTO.cs b/src/c#/GeneralUpdate.Extension/DTOs/DownloadExtensionDTO.cs new file mode 100644 index 00000000..66c2d811 --- /dev/null +++ b/src/c#/GeneralUpdate.Extension/DTOs/DownloadExtensionDTO.cs @@ -0,0 +1,21 @@ +using System.IO; + +namespace GeneralUpdate.Extension.DTOs +{ + /// + /// Download extension file data transfer object. + /// Note: The caller is responsible for disposing the Stream property when done. + /// + public class DownloadExtensionDTO + { + /// + /// File name with extension + /// + public string FileName { get; set; } = null!; + + /// + /// File stream. The caller is responsible for disposing this stream. + /// + public Stream Stream { get; set; } = null!; + } +} diff --git a/src/c#/GeneralUpdate.Extension/DTOs/ExtensionDTO.cs b/src/c#/GeneralUpdate.Extension/DTOs/ExtensionDTO.cs new file mode 100644 index 00000000..f064f8cc --- /dev/null +++ b/src/c#/GeneralUpdate.Extension/DTOs/ExtensionDTO.cs @@ -0,0 +1,123 @@ +using System; +using System.Collections.Generic; + +namespace GeneralUpdate.Extension.DTOs +{ + /// + /// Extension data transfer object + /// + public class ExtensionDTO + { + /// + /// Extension unique identifier + /// + public string Id { get; set; } = null!; + + /// + /// Extension name (unique identifier, lowercase, no spaces) + /// + public string? Name { get; set; } + + /// + /// Human-readable display name of the extension + /// + public string? DisplayName { get; set; } + + /// + /// Extension version + /// + public string? Version { get; set; } + + /// + /// File size in bytes + /// + public long? FileSize { get; set; } + + /// + /// Upload timestamp + /// + public DateTime? UploadTime { get; set; } + + /// + /// Extension status (false-Disabled, true-Enabled) + /// + public bool? Status { get; set; } + + /// + /// Extension description + /// + public string? Description { get; set; } + + /// + /// File format/extension + /// + public string? Format { get; set; } + + /// + /// File hash (SHA256) + /// + public string? Hash { get; set; } + + /// + /// Publisher identifier + /// + public string? Publisher { get; set; } + + /// + /// License identifier (e.g., "MIT", "Apache-2.0") + /// + public string? License { get; set; } + + /// + /// Extension categories + /// + public List? Categories { get; set; } + + /// + /// Supported platforms + /// + public Metadata.TargetPlatform SupportedPlatforms { get; set; } = Metadata.TargetPlatform.All; + + /// + /// Minimum host application version required + /// + public string? MinHostVersion { get; set; } + + /// + /// Maximum host application version supported + /// + public string? MaxHostVersion { get; set; } + + /// + /// Release date and time for this version + /// + public DateTime? ReleaseDate { get; set; } + + /// + /// List of extension names (identifiers) that this extension depends on + /// + public List? Dependencies { get; set; } + + /// + /// Pre-release flag + /// + public bool IsPreRelease { get; set; } + + /// + /// Download URL for the extension package + /// + public string? DownloadUrl { get; set; } + + /// + /// Custom properties for extension-specific metadata + /// + public Dictionary? CustomProperties { get; set; } + + /// + /// Indicates whether the extension is compatible with the requested host version. + /// Null if no host version was specified in the query. + /// True if compatible, False if incompatible. + /// + public bool? IsCompatible { get; set; } + } +} diff --git a/src/c#/GeneralUpdate.Extension/DTOs/ExtensionQueryDTO.cs b/src/c#/GeneralUpdate.Extension/DTOs/ExtensionQueryDTO.cs new file mode 100644 index 00000000..7f6e38a0 --- /dev/null +++ b/src/c#/GeneralUpdate.Extension/DTOs/ExtensionQueryDTO.cs @@ -0,0 +1,53 @@ +namespace GeneralUpdate.Extension.DTOs +{ + /// + /// Extension query data transfer object + /// + public class ExtensionQueryDTO + { + /// + /// Page number for pagination (default: 1) + /// + public int PageNumber { get; set; } = 1; + + /// + /// Page size for pagination (default: 10) + /// + public int PageSize { get; set; } = 10; + + /// + /// Filter by extension name (optional) + /// + public string? Name { get; set; } + + /// + /// Filter by publisher (optional) + /// + public string? Publisher { get; set; } + + /// + /// Filter by category (optional) + /// + public string? Category { get; set; } + + /// + /// Filter by target platform (optional) + /// + public Metadata.TargetPlatform? TargetPlatform { get; set; } + + /// + /// Host version for compatibility checking (optional) + /// + public string? HostVersion { get; set; } + + /// + /// Include pre-release versions (default: false) + /// + public bool IncludePreRelease { get; set; } = false; + + /// + /// Search term for general search (optional) + /// + public string? SearchTerm { get; set; } + } +} diff --git a/src/c#/GeneralUpdate.Extension/DTOs/HttpResponseDTO.cs b/src/c#/GeneralUpdate.Extension/DTOs/HttpResponseDTO.cs new file mode 100644 index 00000000..adf5f337 --- /dev/null +++ b/src/c#/GeneralUpdate.Extension/DTOs/HttpResponseDTO.cs @@ -0,0 +1,111 @@ +namespace GeneralUpdate.Extension.DTOs +{ + /// + /// Base HTTP response data transfer object + /// + public class HttpResponseDTO + { + /// + /// HTTP status code + /// + public int Code { get; set; } + + /// + /// Response message + /// + public string Message { get; set; } = string.Empty; + + /// + /// Creates a success response + /// + /// Success message + /// Success response + public static HttpResponseDTO Success(string message = "Success") + { + return new HttpResponseDTO + { + Code = 200, + Message = message + }; + } + + /// + /// Creates an internal server error response + /// + /// Error message + /// Error response + public static HttpResponseDTO InnerException(string message) + { + return new HttpResponseDTO + { + Code = 500, + Message = message + }; + } + } + + /// + /// Generic HTTP response data transfer object with body + /// + /// Body type + public sealed class HttpResponseDTO : HttpResponseDTO + { + /// + /// Response body + /// +#nullable disable + public T Body { get; set; } +#nullable restore + + private HttpResponseDTO() + { } + + /// + /// Creates a success response with data + /// + /// Response data + /// Success message + /// Success response + public static HttpResponseDTO Success(T data, string message = "Success") + { + return new HttpResponseDTO + { + Code = 200, + Body = data, + Message = message + }; + } + + /// + /// Creates a failure response + /// + /// Failure message + /// Optional response data + /// Failure response + public static HttpResponseDTO Failure(string message, T data = default) + { + return new HttpResponseDTO + { + Code = 400, + Body = data, + Message = message + }; + } + + /// + /// Creates an internal server error response + /// + /// Error message + /// Optional response data + /// Error response + public static HttpResponseDTO InnerException(string message, T data = default) + { + return new HttpResponseDTO + { + Code = 500, + Body = data, + Message = message + }; + } + } +} diff --git a/src/c#/GeneralUpdate.Extension/DTOs/PagedResultDTO.cs b/src/c#/GeneralUpdate.Extension/DTOs/PagedResultDTO.cs new file mode 100644 index 00000000..2893c1cb --- /dev/null +++ b/src/c#/GeneralUpdate.Extension/DTOs/PagedResultDTO.cs @@ -0,0 +1,47 @@ +using System.Collections.Generic; +using System.Linq; + +namespace GeneralUpdate.Extension.DTOs +{ + /// + /// Paginated result data transfer object + /// + /// Item type + public class PagedResultDTO + { + /// + /// Current page number + /// + public int PageNumber { get; set; } + + /// + /// Page size + /// + public int PageSize { get; set; } + + /// + /// Total number of items + /// + public int TotalCount { get; set; } + + /// + /// Total number of pages + /// + public int TotalPages { get; set; } + + /// + /// Items in current page + /// + public IEnumerable Items { get; set; } = Enumerable.Empty(); + + /// + /// Whether there is a previous page + /// + public bool HasPrevious => PageNumber > 1; + + /// + /// Whether there is a next page + /// + public bool HasNext => PageNumber < TotalPages; + } +} diff --git a/src/c#/GeneralUpdate.Extension/Download/ExtensionDownloadService.cs b/src/c#/GeneralUpdate.Extension/Download/ExtensionDownloadService.cs deleted file mode 100644 index 1d4f4727..00000000 --- a/src/c#/GeneralUpdate.Extension/Download/ExtensionDownloadService.cs +++ /dev/null @@ -1,198 +0,0 @@ -using System; -using System.IO; -using System.Threading.Tasks; -using GeneralUpdate.Common.Download; -using GeneralUpdate.Common.Shared.Object; - -namespace GeneralUpdate.Extension.Download -{ - /// - /// Handles downloading of extension packages using the GeneralUpdate download infrastructure. - /// Provides progress tracking and error handling during download operations. - /// - public class ExtensionDownloadService - { - private readonly string _downloadPath; - private readonly int _downloadTimeout; - private readonly IUpdateQueue _updateQueue; - - /// - /// Occurs when download progress updates during package retrieval. - /// - public event EventHandler? ProgressUpdated; - - /// - /// Occurs when a download completes successfully. - /// - public event EventHandler? DownloadCompleted; - - /// - /// Occurs when a download fails due to an error. - /// - public event EventHandler? DownloadFailed; - - /// - /// Initializes a new instance of the class. - /// - /// Directory path where extension packages will be downloaded. - /// The update queue for managing operation state. - /// Timeout in seconds for download operations (default: 300). - /// Thrown when required parameters are null. - public ExtensionDownloadService(string downloadPath, IUpdateQueue updateQueue, int downloadTimeout = 300) - { - if (string.IsNullOrWhiteSpace(downloadPath)) - throw new ArgumentNullException(nameof(downloadPath)); - - _downloadPath = downloadPath; - _updateQueue = updateQueue ?? throw new ArgumentNullException(nameof(updateQueue)); - _downloadTimeout = downloadTimeout; - - if (!Directory.Exists(_downloadPath)) - { - Directory.CreateDirectory(_downloadPath); - } - } - - /// - /// Downloads an extension package asynchronously with progress tracking. - /// Updates the operation state in the queue throughout the download process. - /// - /// The update operation containing extension details. - /// The local file path of the downloaded package, or null if download failed. - /// Thrown when is null. - public async Task DownloadAsync(UpdateOperation operation) - { - if (operation == null) - throw new ArgumentNullException(nameof(operation)); - - var descriptor = operation.Extension.Descriptor; - - if (string.IsNullOrWhiteSpace(descriptor.DownloadUrl)) - { - _updateQueue.ChangeState(operation.OperationId, UpdateState.UpdateFailed, "Download URL is missing"); - OnDownloadFailed(descriptor.Name, descriptor.DisplayName); - return null; - } - - try - { - _updateQueue.ChangeState(operation.OperationId, UpdateState.Updating); - - // Determine file format from URL or default to .zip - var format = !string.IsNullOrWhiteSpace(descriptor.DownloadUrl) && descriptor.DownloadUrl!.Contains(".") - ? Path.GetExtension(descriptor.DownloadUrl) - : ".zip"; - - // Create version info for the download manager - var versionInfo = new VersionInfo - { - Name = $"{descriptor.Name}_{descriptor.Version}", - Url = descriptor.DownloadUrl, - Hash = descriptor.PackageHash, - Version = descriptor.Version, - Size = descriptor.PackageSize, - Format = format - }; - - // Initialize download manager with configured settings - var downloadManager = new DownloadManager(_downloadPath, format, _downloadTimeout); - - // Wire up event handlers for progress tracking - downloadManager.MultiDownloadStatistics += (sender, args) => OnDownloadProgress(operation, args); - downloadManager.MultiDownloadCompleted += (sender, args) => OnDownloadCompleted(operation, args); - downloadManager.MultiDownloadError += (sender, args) => OnDownloadError(operation, args); - - // Create and enqueue the download task - var downloadTask = new DownloadTask(downloadManager, versionInfo); - downloadManager.Add(downloadTask); - - // Execute the download - await downloadManager.LaunchTasksAsync(); - - var downloadedFilePath = Path.Combine(_downloadPath, $"{versionInfo.Name}{format}"); - - if (File.Exists(downloadedFilePath)) - { - OnDownloadSuccess(descriptor.Name, descriptor.DisplayName); - return downloadedFilePath; - } - else - { - _updateQueue.ChangeState(operation.OperationId, UpdateState.UpdateFailed, "Downloaded file not found"); - OnDownloadFailed(descriptor.Name, descriptor.DisplayName); - return null; - } - } - catch (Exception ex) - { - _updateQueue.ChangeState(operation.OperationId, UpdateState.UpdateFailed, ex.Message); - OnDownloadFailed(descriptor.Name, descriptor.DisplayName); - GeneralUpdate.Common.Shared.GeneralTracer.Error($"Download failed for extension {descriptor.Name}", ex); - return null; - } - } - - /// - /// Handles download statistics events and updates progress tracking. - /// - private void OnDownloadProgress(UpdateOperation operation, MultiDownloadStatisticsEventArgs args) - { - var progressPercentage = args.ProgressPercentage; - _updateQueue.UpdateProgress(operation.OperationId, progressPercentage); - - ProgressUpdated?.Invoke(this, new EventHandlers.DownloadProgressEventArgs - { - Name = operation.Extension.Descriptor.Name, - ExtensionName = operation.Extension.Descriptor.DisplayName, - ProgressPercentage = progressPercentage, - TotalBytes = args.TotalBytesToReceive, - ReceivedBytes = args.BytesReceived, - Speed = args.Speed, - RemainingTime = args.Remaining - }); - } - - /// - /// Handles download completion and validates the result. - /// - private void OnDownloadCompleted(UpdateOperation operation, MultiDownloadCompletedEventArgs args) - { - if (!args.IsComplated) - { - _updateQueue.ChangeState(operation.OperationId, UpdateState.UpdateFailed, "Download completed with errors"); - } - } - - /// - /// Handles download errors and updates the operation state. - /// - private void OnDownloadError(UpdateOperation operation, MultiDownloadErrorEventArgs args) - { - _updateQueue.ChangeState(operation.OperationId, UpdateState.UpdateFailed, args.Exception?.Message); - } - - /// - /// Raises the DownloadCompleted event when a download succeeds. - /// - private void OnDownloadSuccess(string extensionName, string displayName) - { - DownloadCompleted?.Invoke(this, new EventHandlers.ExtensionEventArgs - { - Name = extensionName, - ExtensionName = displayName - }); - } - - /// - /// Raises the DownloadFailed event when a download fails. - /// - private void OnDownloadFailed(string extensionName, string displayName) - { - DownloadFailed?.Invoke(this, new EventHandlers.ExtensionEventArgs - { - Name = extensionName, - ExtensionName = displayName - }); - } - } -} diff --git a/src/c#/GeneralUpdate.Extension/Examples/ExtensionSystemExample.cs b/src/c#/GeneralUpdate.Extension/Examples/ExtensionSystemExample.cs index af4c125d..25562db7 100644 --- a/src/c#/GeneralUpdate.Extension/Examples/ExtensionSystemExample.cs +++ b/src/c#/GeneralUpdate.Extension/Examples/ExtensionSystemExample.cs @@ -25,14 +25,17 @@ public void Initialize() // Detect current platform var currentPlatform = DetectCurrentPlatform(); - // Create the extension host - _host = new GeneralExtensionHost( - hostVersion, - installPath, - downloadPath, - currentPlatform, - downloadTimeout: 300 // 5 minutes - ); + // Create the extension host using the new config-based approach + var config = new ExtensionHostConfig + { + HostVersion = hostVersion, + InstallBasePath = installPath, + DownloadPath = downloadPath, + TargetPlatform = currentPlatform, + DownloadTimeout = 300 // 5 minutes + }; + + _host = new GeneralExtensionHost(config); // Subscribe to events for monitoring SubscribeToEvents(); diff --git a/src/c#/GeneralUpdate.Extension/ExtensionHostConfig.cs b/src/c#/GeneralUpdate.Extension/ExtensionHostConfig.cs new file mode 100644 index 00000000..6c4bd0e1 --- /dev/null +++ b/src/c#/GeneralUpdate.Extension/ExtensionHostConfig.cs @@ -0,0 +1,67 @@ +using System; + +namespace GeneralUpdate.Extension +{ + /// + /// Configuration settings for initializing a GeneralExtensionHost instance. + /// Encapsulates all parameters required to set up the extension host environment. + /// + public class ExtensionHostConfig + { + /// + /// Gets or sets the current host application version. + /// This is required and used for compatibility checking. + /// + public Version HostVersion { get; set; } = null!; + + /// + /// Gets or sets the base directory for extension installations. + /// This is the root directory where extensions will be installed. + /// + public string InstallBasePath { get; set; } = null!; + + /// + /// Gets or sets the directory for downloading extension packages. + /// Downloaded packages are temporarily stored here before installation. + /// + public string DownloadPath { get; set; } = null!; + + /// + /// Gets or sets the target platform (Windows/Linux/macOS). + /// Defaults to Windows if not specified. + /// + public Metadata.TargetPlatform TargetPlatform { get; set; } = Metadata.TargetPlatform.Windows; + + /// + /// Gets or sets the download timeout in seconds. + /// Defaults to 300 seconds (5 minutes) if not specified. + /// + public int DownloadTimeout { get; set; } = 300; + + /// + /// Gets or sets the optional HTTP authentication scheme (e.g., "Bearer", "Basic"). + /// When set along with AuthToken, enables authenticated downloads. + /// + public string? AuthScheme { get; set; } + + /// + /// Gets or sets the optional HTTP authentication token. + /// When set along with AuthScheme, enables authenticated downloads. + /// + public string? AuthToken { get; set; } + + /// + /// Validates that all required properties are set. + /// + /// Thrown when required properties are null or empty. + public void Validate() + { + if (HostVersion == null) + throw new ArgumentNullException(nameof(HostVersion)); + if (string.IsNullOrWhiteSpace(InstallBasePath)) + throw new ArgumentNullException(nameof(InstallBasePath)); + if (string.IsNullOrWhiteSpace(DownloadPath)) + throw new ArgumentNullException(nameof(DownloadPath)); + } + } +} diff --git a/src/c#/GeneralUpdate.Extension/GeneralExtensionHost.cs b/src/c#/GeneralUpdate.Extension/GeneralExtensionHost.cs index 846dfbb8..557930ec 100644 --- a/src/c#/GeneralUpdate.Extension/GeneralExtensionHost.cs +++ b/src/c#/GeneralUpdate.Extension/GeneralExtensionHost.cs @@ -16,8 +16,8 @@ public class GeneralExtensionHost : IExtensionHost private readonly Core.IExtensionCatalog _catalog; private readonly Compatibility.ICompatibilityValidator _validator; private readonly Download.IUpdateQueue _updateQueue; - private readonly Download.ExtensionDownloadService _downloadService; private readonly Installation.ExtensionInstallService _installService; + private readonly Services.IExtensionService _extensionService; private bool _globalAutoUpdateEnabled = true; #region Properties @@ -42,6 +42,11 @@ public bool GlobalAutoUpdateEnabled set => _globalAutoUpdateEnabled = value; } + /// + /// Gets the extension service for query and download operations. + /// + public Services.IExtensionService ExtensionService => _extensionService; + #endregion #region Events @@ -78,6 +83,47 @@ public bool GlobalAutoUpdateEnabled #endregion + /// + /// Initializes a new instance of the class using a configuration object. + /// + /// Configuration settings for the extension host. + /// Thrown when config is null or required properties are missing. + public GeneralExtensionHost(ExtensionHostConfig config) + { + if (config == null) + throw new ArgumentNullException(nameof(config)); + + config.Validate(); + + _hostVersion = config.HostVersion; + _targetPlatform = config.TargetPlatform; + + // Initialize core services + _catalog = new Core.ExtensionCatalog(config.InstallBasePath); + _validator = new Compatibility.CompatibilityValidator(config.HostVersion); + _updateQueue = new Download.UpdateQueue(); + _installService = new Installation.ExtensionInstallService(config.InstallBasePath); + + // Initialize extension service with empty list (will be updated via ParseAvailableExtensions) + _extensionService = new Services.ExtensionService( + new List(), + config.DownloadPath, + _updateQueue, + config.HostVersion, + _validator, + config.DownloadTimeout, + config.AuthScheme, + config.AuthToken); + + // Wire up event handlers + _updateQueue.StateChanged += (sender, args) => UpdateStateChanged?.Invoke(sender, args); + _extensionService.ProgressUpdated += (sender, args) => DownloadProgress?.Invoke(sender, args); + _extensionService.DownloadCompleted += (sender, args) => DownloadCompleted?.Invoke(sender, args); + _extensionService.DownloadFailed += (sender, args) => DownloadFailed?.Invoke(sender, args); + _installService.InstallationCompleted += (sender, args) => InstallationCompleted?.Invoke(sender, args); + _installService.RollbackCompleted += (sender, args) => RollbackCompleted?.Invoke(sender, args); + } + /// /// Initializes a new instance of the class. /// @@ -86,36 +132,29 @@ public bool GlobalAutoUpdateEnabled /// Directory for downloading extension packages. /// The current platform (Windows/Linux/macOS). /// Download timeout in seconds (default: 300). + /// Optional HTTP authentication scheme (e.g., "Bearer", "Basic"). + /// Optional HTTP authentication token. /// Thrown when required parameters are null. + [Obsolete("Use the constructor that accepts ExtensionHostConfig for better maintainability.")] public GeneralExtensionHost( Version hostVersion, string installBasePath, string downloadPath, Metadata.TargetPlatform targetPlatform = Metadata.TargetPlatform.Windows, - int downloadTimeout = 300) + int downloadTimeout = 300, + string? authScheme = null, + string? authToken = null) + : this(new ExtensionHostConfig + { + HostVersion = hostVersion, + InstallBasePath = installBasePath, + DownloadPath = downloadPath, + TargetPlatform = targetPlatform, + DownloadTimeout = downloadTimeout, + AuthScheme = authScheme, + AuthToken = authToken + }) { - _hostVersion = hostVersion ?? throw new ArgumentNullException(nameof(hostVersion)); - if (string.IsNullOrWhiteSpace(installBasePath)) - throw new ArgumentNullException(nameof(installBasePath)); - if (string.IsNullOrWhiteSpace(downloadPath)) - throw new ArgumentNullException(nameof(downloadPath)); - - _targetPlatform = targetPlatform; - - // Initialize core services - _catalog = new Core.ExtensionCatalog(installBasePath); - _validator = new Compatibility.CompatibilityValidator(hostVersion); - _updateQueue = new Download.UpdateQueue(); - _downloadService = new Download.ExtensionDownloadService(downloadPath, _updateQueue, downloadTimeout); - _installService = new Installation.ExtensionInstallService(installBasePath); - - // Wire up event handlers - _updateQueue.StateChanged += (sender, args) => UpdateStateChanged?.Invoke(sender, args); - _downloadService.ProgressUpdated += (sender, args) => DownloadProgress?.Invoke(sender, args); - _downloadService.DownloadCompleted += (sender, args) => DownloadCompleted?.Invoke(sender, args); - _downloadService.DownloadFailed += (sender, args) => DownloadFailed?.Invoke(sender, args); - _installService.InstallationCompleted += (sender, args) => InstallationCompleted?.Invoke(sender, args); - _installService.RollbackCompleted += (sender, args) => RollbackCompleted?.Invoke(sender, args); } #region Extension Catalog @@ -164,7 +203,9 @@ public void LoadInstalledExtensions() /// A list of parsed available extensions. public List ParseAvailableExtensions(string json) { - return _catalog.ParseAvailableExtensions(json); + var extensions = _catalog.ParseAvailableExtensions(json); + _extensionService.UpdateAvailableExtensions(extensions); + return extensions; } /// @@ -325,7 +366,7 @@ public async Task ProcessNextUpdateAsync() try { // Download the extension package - var downloadedPath = await _downloadService.DownloadAsync(operation); + var downloadedPath = await _extensionService.DownloadAsync(operation); if (downloadedPath == null) return false; diff --git a/src/c#/GeneralUpdate.Extension/IExtensionHost.cs b/src/c#/GeneralUpdate.Extension/IExtensionHost.cs index 90fed8a3..d3f2859b 100644 --- a/src/c#/GeneralUpdate.Extension/IExtensionHost.cs +++ b/src/c#/GeneralUpdate.Extension/IExtensionHost.cs @@ -27,6 +27,11 @@ public interface IExtensionHost /// bool GlobalAutoUpdateEnabled { get; set; } + /// + /// Gets the extension service for query and download operations. + /// + Services.IExtensionService ExtensionService { get; } + #endregion #region Events diff --git a/src/c#/GeneralUpdate.Extension/Services/ExtensionService.cs b/src/c#/GeneralUpdate.Extension/Services/ExtensionService.cs new file mode 100644 index 00000000..21466afc --- /dev/null +++ b/src/c#/GeneralUpdate.Extension/Services/ExtensionService.cs @@ -0,0 +1,495 @@ +using System; +using System.Collections.Generic; +using System.IO; +using System.Linq; +using System.Threading.Tasks; +using GeneralUpdate.Common.Download; +using GeneralUpdate.Common.Shared.Object; +using GeneralUpdate.Extension.DTOs; +using GeneralUpdate.Extension.Metadata; + +namespace GeneralUpdate.Extension.Services +{ + /// + /// Implementation of extension query and download operations. + /// Handles downloading of extension packages using the GeneralUpdate download infrastructure. + /// Provides progress tracking and error handling during download operations. + /// + public class ExtensionService : IExtensionService + { + private List _availableExtensions; + private readonly Version? _hostVersion; + private readonly Compatibility.ICompatibilityValidator? _validator; + private readonly string _downloadPath; + private readonly int _downloadTimeout; + private readonly Download.IUpdateQueue _updateQueue; + private readonly string? _authScheme; + private readonly string? _authToken; + + /// + /// Occurs when download progress updates during package retrieval. + /// + public event EventHandler? ProgressUpdated; + + /// + /// Occurs when a download completes successfully. + /// + public event EventHandler? DownloadCompleted; + + /// + /// Occurs when a download fails due to an error. + /// + public event EventHandler? DownloadFailed; + + /// + /// Initializes a new instance of the class. + /// + /// List of available extensions + /// Directory path where extension packages will be downloaded + /// The update queue for managing operation state + /// Optional host version for compatibility checking + /// Optional compatibility validator + /// Timeout in seconds for download operations (default: 300) + /// Optional HTTP authentication scheme (e.g., "Bearer", "Basic") + /// Optional HTTP authentication token + public ExtensionService( + List availableExtensions, + string downloadPath, + Download.IUpdateQueue updateQueue, + Version? hostVersion = null, + Compatibility.ICompatibilityValidator? validator = null, + int downloadTimeout = 300, + string? authScheme = null, + string? authToken = null) + { + _availableExtensions = availableExtensions ?? throw new ArgumentNullException(nameof(availableExtensions)); + + if (string.IsNullOrWhiteSpace(downloadPath)) + throw new ArgumentNullException(nameof(downloadPath)); + + _downloadPath = downloadPath; + _updateQueue = updateQueue ?? throw new ArgumentNullException(nameof(updateQueue)); + _downloadTimeout = downloadTimeout; + _hostVersion = hostVersion; + _validator = validator; + _authScheme = authScheme; + _authToken = authToken; + + if (!Directory.Exists(_downloadPath)) + { + Directory.CreateDirectory(_downloadPath); + } + } + + /// + /// Updates the list of available extensions + /// + /// New list of available extensions + public void UpdateAvailableExtensions(List availableExtensions) + { + _availableExtensions = availableExtensions ?? throw new ArgumentNullException(nameof(availableExtensions)); + } + + /// + /// Queries available extensions based on filter criteria + /// + /// Query parameters including pagination and filters + /// Paginated result of extensions matching the query + public Task>> Query(ExtensionQueryDTO query) + { + try + { + if (query == null) + { + return Task.FromResult(HttpResponseDTO>.Failure( + "Query parameter cannot be null")); + } + + // Validate pagination parameters + if (query.PageNumber < 1) + { + return Task.FromResult(HttpResponseDTO>.Failure( + "PageNumber must be greater than 0")); + } + + if (query.PageSize < 1) + { + return Task.FromResult(HttpResponseDTO>.Failure( + "PageSize must be greater than 0")); + } + + // Parse host version if provided + Version? queryHostVersion = null; + if (!string.IsNullOrWhiteSpace(query.HostVersion)) + { + if (!Version.TryParse(query.HostVersion, out queryHostVersion)) + { + return Task.FromResult(HttpResponseDTO>.Failure( + $"Invalid host version format: {query.HostVersion}")); + } + } + + // Use query host version if provided, otherwise use service host version + var effectiveHostVersion = queryHostVersion ?? _hostVersion; + + // Start with all available extensions + IEnumerable filtered = _availableExtensions; + + // Apply filters + if (!string.IsNullOrWhiteSpace(query.Name)) + { + filtered = filtered.Where(e => + e.Descriptor.Name?.IndexOf(query.Name, StringComparison.OrdinalIgnoreCase) >= 0); + } + + if (!string.IsNullOrWhiteSpace(query.Publisher)) + { + filtered = filtered.Where(e => + e.Descriptor.Publisher?.IndexOf(query.Publisher, StringComparison.OrdinalIgnoreCase) >= 0); + } + + if (!string.IsNullOrWhiteSpace(query.Category)) + { + filtered = filtered.Where(e => + e.Descriptor.Categories?.Any(c => + c.IndexOf(query.Category, StringComparison.OrdinalIgnoreCase) >= 0) == true); + } + + if (query.TargetPlatform.HasValue && query.TargetPlatform.Value != TargetPlatform.None) + { + filtered = filtered.Where(e => + (e.Descriptor.SupportedPlatforms & query.TargetPlatform.Value) != 0); + } + + if (!query.IncludePreRelease) + { + filtered = filtered.Where(e => !e.IsPreRelease); + } + + if (!string.IsNullOrWhiteSpace(query.SearchTerm)) + { + filtered = filtered.Where(e => + (e.Descriptor.Name?.IndexOf(query.SearchTerm, StringComparison.OrdinalIgnoreCase) >= 0) || + (e.Descriptor.DisplayName?.IndexOf(query.SearchTerm, StringComparison.OrdinalIgnoreCase) >= 0) || + (e.Descriptor.Description?.IndexOf(query.SearchTerm, StringComparison.OrdinalIgnoreCase) >= 0)); + } + + // Convert to list for pagination + var filteredList = filtered.ToList(); + + // Calculate pagination + var totalCount = filteredList.Count; + var totalPages = (int)Math.Ceiling(totalCount / (double)query.PageSize); + + // Apply pagination + var items = filteredList + .Skip((query.PageNumber - 1) * query.PageSize) + .Take(query.PageSize) + .Select(e => MapToExtensionDTO(e, effectiveHostVersion)) + .ToList(); + + var result = new PagedResultDTO + { + PageNumber = query.PageNumber, + PageSize = query.PageSize, + TotalCount = totalCount, + TotalPages = totalPages, + Items = items + }; + + return Task.FromResult(HttpResponseDTO>.Success(result)); + } + catch (Exception ex) + { + return Task.FromResult(HttpResponseDTO>.InnerException( + $"Error querying extensions: {ex.Message}")); + } + } + + /// + /// Downloads an extension and its dependencies by ID. + /// Note: The caller is responsible for disposing the Stream in the returned DownloadExtensionDTO. + /// + /// Extension ID (Name) + /// Download result containing file name and stream. The caller must dispose the stream. + public async Task> Download(string id) + { + try + { + if (string.IsNullOrWhiteSpace(id)) + { + return HttpResponseDTO.Failure("Extension ID cannot be null or empty"); + } + + if (_availableExtensions == null || _availableExtensions.Count == 0) + { + return HttpResponseDTO.Failure("Available extensions list is empty"); + } + + // Find the extension by ID (using Name as ID) + var extension = _availableExtensions.FirstOrDefault(e => + e.Descriptor.Name?.Equals(id, StringComparison.OrdinalIgnoreCase) == true); + + if (extension == null) + { + return HttpResponseDTO.Failure( + $"Extension with ID '{id}' not found"); + } + + // Collect all extensions to download (main extension + dependencies) + var extensionsToDownload = new List { extension }; + + // Resolve dependencies + if (extension.Descriptor.Dependencies != null && extension.Descriptor.Dependencies.Count > 0) + { + foreach (var depId in extension.Descriptor.Dependencies) + { + var dependency = _availableExtensions.FirstOrDefault(e => + e.Descriptor.Name?.Equals(depId, StringComparison.OrdinalIgnoreCase) == true); + + if (dependency != null) + { + extensionsToDownload.Add(dependency); + } + } + } + + // For now, we'll download only the main extension + // In a real implementation, you might want to download all dependencies + // and package them together or return multiple files + + // Use the shared update queue + var operation = _updateQueue.Enqueue(extension, false); + + var downloadedPath = await DownloadAsync(operation); + + if (downloadedPath == null || !File.Exists(downloadedPath)) + { + return HttpResponseDTO.Failure( + $"Failed to download extension '{extension.Descriptor.DisplayName}'"); + } + + // Read the file into a memory stream + var fileBytes = File.ReadAllBytes(downloadedPath); + var stream = new MemoryStream(fileBytes); + + var result = new DownloadExtensionDTO + { + FileName = Path.GetFileName(downloadedPath), + Stream = stream + }; + + return HttpResponseDTO.Success(result); + } + catch (Exception ex) + { + return HttpResponseDTO.InnerException( + $"Error downloading extension: {ex.Message}"); + } + } + + /// + /// Downloads an extension package asynchronously with progress tracking. + /// Updates the operation state in the queue throughout the download process. + /// + /// The update operation containing extension details. + /// The local file path of the downloaded package, or null if download failed. + /// Thrown when is null. + public async Task DownloadAsync(Download.UpdateOperation operation) + { + if (operation == null) + throw new ArgumentNullException(nameof(operation)); + + var descriptor = operation.Extension.Descriptor; + + if (string.IsNullOrWhiteSpace(descriptor.DownloadUrl)) + { + _updateQueue.ChangeState(operation.OperationId, GeneralUpdate.Extension.Download.UpdateState.UpdateFailed, "Download URL is missing"); + OnDownloadFailed(descriptor.Name, descriptor.DisplayName); + return null; + } + + try + { + _updateQueue.ChangeState(operation.OperationId, GeneralUpdate.Extension.Download.UpdateState.Updating); + + // Determine file format from URL or default to .zip + var format = !string.IsNullOrWhiteSpace(descriptor.DownloadUrl) && descriptor.DownloadUrl!.Contains(".") + ? Path.GetExtension(descriptor.DownloadUrl) + : ".zip"; + + // Create version info for the download manager + var versionInfo = new VersionInfo + { + Name = $"{descriptor.Name}_{descriptor.Version}", + Url = descriptor.DownloadUrl, + Hash = descriptor.PackageHash, + Version = descriptor.Version, + Size = descriptor.PackageSize, + Format = format, + AuthScheme = _authScheme, + AuthToken = _authToken + }; + + // Initialize download manager with configured settings + var downloadManager = new DownloadManager(_downloadPath, format, _downloadTimeout); + + // Wire up event handlers for progress tracking + downloadManager.MultiDownloadStatistics += (sender, args) => OnDownloadProgress(operation, args); + downloadManager.MultiDownloadCompleted += (sender, args) => OnDownloadCompleted(operation, args); + downloadManager.MultiDownloadError += (sender, args) => OnDownloadError(operation, args); + + // Create and enqueue the download task + var downloadTask = new DownloadTask(downloadManager, versionInfo); + downloadManager.Add(downloadTask); + + // Execute the download + await downloadManager.LaunchTasksAsync(); + + var downloadedFilePath = Path.Combine(_downloadPath, $"{versionInfo.Name}{format}"); + + if (File.Exists(downloadedFilePath)) + { + OnDownloadSuccess(descriptor.Name, descriptor.DisplayName); + return downloadedFilePath; + } + else + { + _updateQueue.ChangeState(operation.OperationId, GeneralUpdate.Extension.Download.UpdateState.UpdateFailed, "Downloaded file not found"); + OnDownloadFailed(descriptor.Name, descriptor.DisplayName); + return null; + } + } + catch (Exception ex) + { + _updateQueue.ChangeState(operation.OperationId, GeneralUpdate.Extension.Download.UpdateState.UpdateFailed, ex.Message); + OnDownloadFailed(descriptor.Name, descriptor.DisplayName); + GeneralUpdate.Common.Shared.GeneralTracer.Error($"Download failed for extension {descriptor.Name}", ex); + return null; + } + } + + /// + /// Handles download statistics events and updates progress tracking. + /// + private void OnDownloadProgress(Download.UpdateOperation operation, MultiDownloadStatisticsEventArgs args) + { + var progressPercentage = args.ProgressPercentage; + _updateQueue.UpdateProgress(operation.OperationId, progressPercentage); + + ProgressUpdated?.Invoke(this, new EventHandlers.DownloadProgressEventArgs + { + Name = operation.Extension.Descriptor.Name, + ExtensionName = operation.Extension.Descriptor.DisplayName, + ProgressPercentage = progressPercentage, + TotalBytes = args.TotalBytesToReceive, + ReceivedBytes = args.BytesReceived, + Speed = args.Speed, + RemainingTime = args.Remaining + }); + } + + /// + /// Handles download completion and validates the result. + /// + private void OnDownloadCompleted(Download.UpdateOperation operation, MultiDownloadCompletedEventArgs args) + { + if (!args.IsComplated) + { + _updateQueue.ChangeState(operation.OperationId, GeneralUpdate.Extension.Download.UpdateState.UpdateFailed, "Download completed with errors"); + } + } + + /// + /// Handles download errors and updates the operation state. + /// + private void OnDownloadError(Download.UpdateOperation operation, MultiDownloadErrorEventArgs args) + { + _updateQueue.ChangeState(operation.OperationId, GeneralUpdate.Extension.Download.UpdateState.UpdateFailed, args.Exception?.Message); + } + + /// + /// Raises the DownloadCompleted event when a download succeeds. + /// + private void OnDownloadSuccess(string extensionName, string displayName) + { + DownloadCompleted?.Invoke(this, new EventHandlers.ExtensionEventArgs + { + Name = extensionName, + ExtensionName = displayName + }); + } + + /// + /// Raises the DownloadFailed event when a download fails. + /// + private void OnDownloadFailed(string extensionName, string displayName) + { + DownloadFailed?.Invoke(this, new EventHandlers.ExtensionEventArgs + { + Name = extensionName, + ExtensionName = displayName + }); + } + + /// + /// Maps an AvailableExtension to an ExtensionDTO + /// + private ExtensionDTO MapToExtensionDTO(AvailableExtension extension, Version? hostVersion) + { + var descriptor = extension.Descriptor; + + // Determine compatibility if host version is provided + bool? isCompatible = null; + if (hostVersion != null && _validator != null) + { + isCompatible = _validator.IsCompatible(descriptor); + } + + return new ExtensionDTO + { + Id = descriptor.Name ?? string.Empty, + Name = descriptor.Name, + DisplayName = descriptor.DisplayName, + Version = descriptor.Version, + FileSize = descriptor.PackageSize > 0 ? descriptor.PackageSize : (long?)null, + UploadTime = descriptor.ReleaseDate, + Status = true, // Assume enabled if it's in the available list + Description = descriptor.Description, + Format = GetFileFormat(descriptor.DownloadUrl), + Hash = descriptor.PackageHash, + Publisher = descriptor.Publisher, + License = descriptor.License, + Categories = descriptor.Categories, + SupportedPlatforms = descriptor.SupportedPlatforms, + MinHostVersion = descriptor.Compatibility?.MinHostVersion?.ToString(), + MaxHostVersion = descriptor.Compatibility?.MaxHostVersion?.ToString(), + ReleaseDate = descriptor.ReleaseDate, + Dependencies = descriptor.Dependencies, + IsPreRelease = extension.IsPreRelease, + DownloadUrl = descriptor.DownloadUrl, + CustomProperties = descriptor.CustomProperties, + IsCompatible = isCompatible + }; + } + + /// + /// Extracts file format from download URL + /// + private string? GetFileFormat(string? downloadUrl) + { + if (string.IsNullOrWhiteSpace(downloadUrl)) + return null; + + try + { + var extension = Path.GetExtension(downloadUrl); + return string.IsNullOrWhiteSpace(extension) ? null : extension; + } + catch + { + return null; + } + } + } +} diff --git a/src/c#/GeneralUpdate.Extension/Services/IExtensionService.cs b/src/c#/GeneralUpdate.Extension/Services/IExtensionService.cs new file mode 100644 index 00000000..c78bf2ed --- /dev/null +++ b/src/c#/GeneralUpdate.Extension/Services/IExtensionService.cs @@ -0,0 +1,57 @@ +using System; +using System.Collections.Generic; +using System.Threading.Tasks; +using GeneralUpdate.Extension.DTOs; + +namespace GeneralUpdate.Extension.Services +{ + /// + /// Interface for extension query and download operations + /// + public interface IExtensionService + { + /// + /// Occurs when download progress updates during package retrieval. + /// + event EventHandler? ProgressUpdated; + + /// + /// Occurs when a download completes successfully. + /// + event EventHandler? DownloadCompleted; + + /// + /// Occurs when a download fails due to an error. + /// + event EventHandler? DownloadFailed; + + /// + /// Updates the list of available extensions + /// + /// New list of available extensions + void UpdateAvailableExtensions(List availableExtensions); + + /// + /// Queries available extensions based on filter criteria + /// + /// Query parameters including pagination and filters + /// Paginated result of extensions matching the query + Task>> Query(ExtensionQueryDTO query); + + /// + /// Downloads an extension and its dependencies by ID. + /// Note: The caller is responsible for disposing the Stream in the returned DownloadExtensionDTO. + /// + /// Extension ID + /// Download result containing file name and stream. The caller must dispose the stream. + Task> Download(string id); + + /// + /// Downloads an extension package asynchronously with progress tracking. + /// Updates the operation state in the queue throughout the download process. + /// + /// The update operation containing extension details. + /// The local file path of the downloaded package, or null if download failed. + Task DownloadAsync(Download.UpdateOperation operation); + } +}