diff --git a/src/c#/ExtensionTest/Services/ExtensionServiceTests.cs b/src/c#/ExtensionTest/Services/ExtensionServiceTests.cs index 87073cb5..d6e55000 100644 --- a/src/c#/ExtensionTest/Services/ExtensionServiceTests.cs +++ b/src/c#/ExtensionTest/Services/ExtensionServiceTests.cs @@ -81,7 +81,7 @@ private List CreateTestExtensions() private ExtensionService CreateExtensionService(List extensions) { var updateQueue = new GeneralUpdate.Extension.Download.UpdateQueue(); - return new ExtensionService(extensions, "/tmp/test-downloads", updateQueue); + return new ExtensionService(extensions, "/tmp/test-downloads", updateQueue, "https://test-server.com/api/extensions"); } [Fact] diff --git a/src/c#/GeneralUpdate.Extension/Examples/ExtensionSystemExample.cs b/src/c#/GeneralUpdate.Extension/Examples/ExtensionSystemExample.cs index 25562db7..ab9f00aa 100644 --- a/src/c#/GeneralUpdate.Extension/Examples/ExtensionSystemExample.cs +++ b/src/c#/GeneralUpdate.Extension/Examples/ExtensionSystemExample.cs @@ -21,6 +21,7 @@ public void Initialize() var hostVersion = new Version(1, 5, 0); var installPath = @"C:\MyApp\Extensions"; var downloadPath = @"C:\MyApp\Temp\Downloads"; + var serverUrl = "https://your-server.com/api/extensions"; // Detect current platform var currentPlatform = DetectCurrentPlatform(); @@ -31,6 +32,7 @@ public void Initialize() HostVersion = hostVersion, InstallBasePath = installPath, DownloadPath = downloadPath, + ServerUrl = serverUrl, TargetPlatform = currentPlatform, DownloadTimeout = 300 // 5 minutes }; diff --git a/src/c#/GeneralUpdate.Extension/ExtensionHostConfig.cs b/src/c#/GeneralUpdate.Extension/ExtensionHostConfig.cs index 6c4bd0e1..d4fa96f6 100644 --- a/src/c#/GeneralUpdate.Extension/ExtensionHostConfig.cs +++ b/src/c#/GeneralUpdate.Extension/ExtensionHostConfig.cs @@ -26,6 +26,13 @@ public class ExtensionHostConfig /// public string DownloadPath { get; set; } = null!; + /// + /// Gets or sets the server URL for extension queries and downloads. + /// This is the base URL used to construct Query and Download endpoints. + /// Example: "https://your-server.com/api/extensions" + /// + public string ServerUrl { get; set; } = null!; + /// /// Gets or sets the target platform (Windows/Linux/macOS). /// Defaults to Windows if not specified. @@ -62,6 +69,8 @@ public void Validate() throw new ArgumentNullException(nameof(InstallBasePath)); if (string.IsNullOrWhiteSpace(DownloadPath)) throw new ArgumentNullException(nameof(DownloadPath)); + if (string.IsNullOrWhiteSpace(ServerUrl)) + throw new ArgumentNullException(nameof(ServerUrl)); } } } diff --git a/src/c#/GeneralUpdate.Extension/GeneralExtensionHost.cs b/src/c#/GeneralUpdate.Extension/GeneralExtensionHost.cs index 557930ec..fa037fcd 100644 --- a/src/c#/GeneralUpdate.Extension/GeneralExtensionHost.cs +++ b/src/c#/GeneralUpdate.Extension/GeneralExtensionHost.cs @@ -109,6 +109,7 @@ public GeneralExtensionHost(ExtensionHostConfig config) new List(), config.DownloadPath, _updateQueue, + config.ServerUrl, config.HostVersion, _validator, config.DownloadTimeout, @@ -130,6 +131,7 @@ public GeneralExtensionHost(ExtensionHostConfig config) /// The current host application version. /// Base directory for extension installations. /// Directory for downloading extension packages. + /// Server base URL for extension queries and downloads. /// The current platform (Windows/Linux/macOS). /// Download timeout in seconds (default: 300). /// Optional HTTP authentication scheme (e.g., "Bearer", "Basic"). @@ -140,6 +142,7 @@ public GeneralExtensionHost( Version hostVersion, string installBasePath, string downloadPath, + string serverUrl, Metadata.TargetPlatform targetPlatform = Metadata.TargetPlatform.Windows, int downloadTimeout = 300, string? authScheme = null, @@ -149,6 +152,7 @@ public GeneralExtensionHost( HostVersion = hostVersion, InstallBasePath = installBasePath, DownloadPath = downloadPath, + ServerUrl = serverUrl, TargetPlatform = targetPlatform, DownloadTimeout = downloadTimeout, AuthScheme = authScheme, diff --git a/src/c#/GeneralUpdate.Extension/Metadata/ExtensionDescriptor.cs b/src/c#/GeneralUpdate.Extension/Metadata/ExtensionDescriptor.cs index 6c9c9ff4..06c476c7 100644 --- a/src/c#/GeneralUpdate.Extension/Metadata/ExtensionDescriptor.cs +++ b/src/c#/GeneralUpdate.Extension/Metadata/ExtensionDescriptor.cs @@ -75,6 +75,9 @@ public class ExtensionDescriptor /// /// Gets or sets the download URL for the extension package. + /// NOTE: This field is optional and primarily for backward compatibility. + /// The system constructs download URLs dynamically from the ServerUrl configured in ExtensionHostConfig. + /// Format: {ServerUrl}/Download/{ExtensionName} /// [JsonPropertyName("downloadUrl")] public string? DownloadUrl { get; set; } diff --git a/src/c#/GeneralUpdate.Extension/README.md b/src/c#/GeneralUpdate.Extension/README.md index 611bb0fe..b812dcda 100644 --- a/src/c#/GeneralUpdate.Extension/README.md +++ b/src/c#/GeneralUpdate.Extension/README.md @@ -34,12 +34,17 @@ Note: This library is currently distributed as source. A NuGet package may be av using GeneralUpdate.Extension; using GeneralUpdate.Extension.Metadata; -// Create extension host -var host = new ExtensionHost( - hostVersion: new Version(1, 0, 0), - installPath: @"C:\MyApp\Extensions", - downloadPath: @"C:\MyApp\Downloads", - targetPlatform: TargetPlatform.Windows); +// Create extension host with configuration +var config = new ExtensionHostConfig +{ + HostVersion = new Version(1, 0, 0), + InstallBasePath = @"C:\MyApp\Extensions", + DownloadPath = @"C:\MyApp\Downloads", + ServerUrl = "https://your-server.com/api/extensions", + TargetPlatform = TargetPlatform.Windows +}; + +var host = new GeneralExtensionHost(config); // Load installed extensions host.LoadInstalledExtensions(); @@ -54,6 +59,32 @@ host.UpdateStateChanged += (sender, args) => var installed = host.GetInstalledExtensions(); ``` +## Server URL Architecture + +The extension system uses a server-based architecture for querying and downloading extensions. The `ServerUrl` configured in `ExtensionHostConfig` serves as the base URL for all extension operations. + +### URL Construction + +The system automatically constructs the following endpoints: + +- **Query Endpoint**: `{ServerUrl}/Query` - Used for searching and filtering extensions +- **Download Endpoint**: `{ServerUrl}/Download/{ExtensionName}` - Used for downloading extension packages + +### Example + +If your `ServerUrl` is `https://your-server.com/api/extensions`: +- Query endpoint: `https://your-server.com/api/extensions/Query` +- Download for extension "my-extension": `https://your-server.com/api/extensions/Download/my-extension` + +### Server Requirements + +Your server should implement these endpoints: + +1. **Query Endpoint** - Returns available extensions based on filter criteria +2. **Download Endpoint** - Returns the extension package file (typically .zip format) + +The `DownloadUrl` field in extension descriptors is now optional and primarily for backward compatibility. The system constructs download URLs dynamically from the configured ServerUrl. + ## Complete Usage Guide ### 1. Dependency Injection Setup @@ -70,17 +101,21 @@ public class YourModule : IModule { public void RegisterTypes(IContainerRegistry containerRegistry) { - var hostVersion = new Version(1, 0, 0); - var installPath = @"C:\MyApp\Extensions"; - var downloadPath = @"C:\MyApp\Downloads"; - var platform = Metadata.TargetPlatform.Windows; + var config = new ExtensionHostConfig + { + HostVersion = new Version(1, 0, 0), + InstallBasePath = @"C:\MyApp\Extensions", + DownloadPath = @"C:\MyApp\Downloads", + ServerUrl = "https://your-server.com/api/extensions", + TargetPlatform = Metadata.TargetPlatform.Windows + }; // Register as singletons containerRegistry.RegisterSingleton(() => - new Core.ExtensionCatalog(installPath)); + new Core.ExtensionCatalog(config.InstallBasePath)); containerRegistry.RegisterSingleton(() => - new Compatibility.CompatibilityValidator(hostVersion)); + new Compatibility.CompatibilityValidator(config.HostVersion)); containerRegistry.RegisterSingleton(); @@ -88,7 +123,7 @@ public class YourModule : IModule PackageGeneration.ExtensionPackageGenerator>(); containerRegistry.RegisterSingleton(() => - new ExtensionHost(hostVersion, installPath, downloadPath, platform)); + new GeneralExtensionHost(config)); } } @@ -102,15 +137,20 @@ var host = container.Resolve(); using Microsoft.Extensions.DependencyInjection; var services = new ServiceCollection(); -var hostVersion = new Version(1, 0, 0); -var installPath = @"C:\Extensions"; -var downloadPath = @"C:\Downloads"; +var config = new ExtensionHostConfig +{ + HostVersion = new Version(1, 0, 0), + InstallBasePath = @"C:\Extensions", + DownloadPath = @"C:\Downloads", + ServerUrl = "https://your-server.com/api/extensions", + TargetPlatform = Metadata.TargetPlatform.Windows +}; services.AddSingleton(sp => - new Core.ExtensionCatalog(installPath)); + new Core.ExtensionCatalog(config.InstallBasePath)); services.AddSingleton(sp => - new Compatibility.CompatibilityValidator(hostVersion)); + new Compatibility.CompatibilityValidator(config.HostVersion)); services.AddSingleton(); @@ -118,8 +158,7 @@ services.AddSingleton(); services.AddSingleton(sp => - new ExtensionHost(hostVersion, installPath, downloadPath, - Metadata.TargetPlatform.Windows)); + new GeneralExtensionHost(config)); var provider = services.BuildServiceProvider(); var host = provider.GetRequiredService(); @@ -128,11 +167,16 @@ var host = provider.GetRequiredService(); #### Without DI (Direct Instantiation) ```csharp -var host = new ExtensionHost( - new Version(1, 0, 0), - @"C:\Extensions", - @"C:\Downloads", - Metadata.TargetPlatform.Windows); +var config = new ExtensionHostConfig +{ + HostVersion = new Version(1, 0, 0), + InstallBasePath = @"C:\Extensions", + DownloadPath = @"C:\Downloads", + ServerUrl = "https://your-server.com/api/extensions", + TargetPlatform = Metadata.TargetPlatform.Windows +}; + +var host = new GeneralExtensionHost(config); ``` ### 2. Loading and Managing Extensions diff --git a/src/c#/GeneralUpdate.Extension/Services/ExtensionService.cs b/src/c#/GeneralUpdate.Extension/Services/ExtensionService.cs index 21466afc..c2989a9e 100644 --- a/src/c#/GeneralUpdate.Extension/Services/ExtensionService.cs +++ b/src/c#/GeneralUpdate.Extension/Services/ExtensionService.cs @@ -2,6 +2,10 @@ using System.Collections.Generic; using System.IO; using System.Linq; +using System.Net.Http; +using System.Net.Http.Headers; +using System.Text; +using System.Text.Json; using System.Threading.Tasks; using GeneralUpdate.Common.Download; using GeneralUpdate.Common.Shared.Object; @@ -25,6 +29,8 @@ public class ExtensionService : IExtensionService private readonly Download.IUpdateQueue _updateQueue; private readonly string? _authScheme; private readonly string? _authToken; + private readonly string _serverUrl; + private readonly HttpClient _httpClient; /// /// Occurs when download progress updates during package retrieval. @@ -47,6 +53,7 @@ public class ExtensionService : IExtensionService /// List of available extensions /// Directory path where extension packages will be downloaded /// The update queue for managing operation state + /// Server base URL for extension queries and downloads /// Optional host version for compatibility checking /// Optional compatibility validator /// Timeout in seconds for download operations (default: 300) @@ -56,6 +63,7 @@ public ExtensionService( List availableExtensions, string downloadPath, Download.IUpdateQueue updateQueue, + string serverUrl, Version? hostVersion = null, Compatibility.ICompatibilityValidator? validator = null, int downloadTimeout = 300, @@ -67,7 +75,12 @@ public ExtensionService( if (string.IsNullOrWhiteSpace(downloadPath)) throw new ArgumentNullException(nameof(downloadPath)); + if (string.IsNullOrWhiteSpace(serverUrl)) + throw new ArgumentNullException(nameof(serverUrl)); + _downloadPath = downloadPath; + // Remove trailing slashes for consistent URL construction (only forward slashes expected in URLs) + _serverUrl = serverUrl.TrimEnd('/'); _updateQueue = updateQueue ?? throw new ArgumentNullException(nameof(updateQueue)); _downloadTimeout = downloadTimeout; _hostVersion = hostVersion; @@ -75,6 +88,19 @@ public ExtensionService( _authScheme = authScheme; _authToken = authToken; + // Initialize HttpClient with timeout + _httpClient = new HttpClient + { + Timeout = TimeSpan.FromSeconds(_downloadTimeout) + }; + + // Set authentication headers if provided + if (!string.IsNullOrWhiteSpace(_authScheme) && !string.IsNullOrWhiteSpace(_authToken)) + { + _httpClient.DefaultRequestHeaders.Authorization = + new AuthenticationHeaderValue(_authScheme, _authToken); + } + if (!Directory.Exists(_downloadPath)) { Directory.CreateDirectory(_downloadPath); @@ -91,128 +117,256 @@ public void UpdateAvailableExtensions(List availableExtensio } /// - /// Queries available extensions based on filter criteria + /// Queries available extensions based on filter criteria via HTTP request to the server /// /// Query parameters including pagination and filters /// Paginated result of extensions matching the query - public Task>> Query(ExtensionQueryDTO query) + public async Task>> Query(ExtensionQueryDTO query) { try { if (query == null) { - return Task.FromResult(HttpResponseDTO>.Failure( - "Query parameter cannot be null")); + return 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")); + return HttpResponseDTO>.Failure( + "PageNumber must be greater than 0"); } if (query.PageSize < 1) { - return Task.FromResult(HttpResponseDTO>.Failure( - "PageSize must be greater than 0")); + return HttpResponseDTO>.Failure( + "PageSize must be greater than 0"); } - // Parse host version if provided - Version? queryHostVersion = null; + // Build query string from parameters + var queryParams = new List(); + queryParams.Add($"PageNumber={query.PageNumber}"); + queryParams.Add($"PageSize={query.PageSize}"); + + if (!string.IsNullOrWhiteSpace(query.Name)) + queryParams.Add($"Name={Uri.EscapeDataString(query.Name)}"); + + if (!string.IsNullOrWhiteSpace(query.Publisher)) + queryParams.Add($"Publisher={Uri.EscapeDataString(query.Publisher)}"); + + if (!string.IsNullOrWhiteSpace(query.Category)) + queryParams.Add($"Category={Uri.EscapeDataString(query.Category)}"); + + if (query.TargetPlatform.HasValue) + queryParams.Add($"TargetPlatform={(int)query.TargetPlatform.Value}"); + if (!string.IsNullOrWhiteSpace(query.HostVersion)) + queryParams.Add($"HostVersion={Uri.EscapeDataString(query.HostVersion)}"); + + queryParams.Add($"IncludePreRelease={query.IncludePreRelease}"); + + if (!string.IsNullOrWhiteSpace(query.SearchTerm)) + queryParams.Add($"SearchTerm={Uri.EscapeDataString(query.SearchTerm)}"); + + var queryString = string.Join("&", queryParams); + var requestUrl = $"{_serverUrl}/Query?{queryString}"; + + // Make HTTP GET request + var response = await _httpClient.GetAsync(requestUrl); + + if (!response.IsSuccessStatusCode) { - if (!Version.TryParse(query.HostVersion, out queryHostVersion)) + var errorContent = await response.Content.ReadAsStringAsync(); + return HttpResponseDTO>.Failure( + $"Server returned error {response.StatusCode}: {errorContent}"); + } + + var jsonContent = await response.Content.ReadAsStringAsync(); + var result = JsonSerializer.Deserialize>(jsonContent, + new JsonSerializerOptions { PropertyNameCaseInsensitive = true }); + + if (result == null) + { + return HttpResponseDTO>.Failure( + "Failed to deserialize server response"); + } + + // Update local cache with results + if (result.Items != null && result.Items.Any()) + { + var availableExtensions = result.Items + .Select(dto => MapFromExtensionDTO(dto)) + .Where(ext => ext != null) + .Cast() + .ToList(); + + // Merge with existing extensions + foreach (var ext in availableExtensions) { - return Task.FromResult(HttpResponseDTO>.Failure( - $"Invalid host version format: {query.HostVersion}")); + var existing = _availableExtensions.FirstOrDefault(e => + e.Descriptor.Name?.Equals(ext.Descriptor.Name, StringComparison.OrdinalIgnoreCase) == true); + + if (existing == null) + { + _availableExtensions.Add(ext); + } } } - // Use query host version if provided, otherwise use service host version - var effectiveHostVersion = queryHostVersion ?? _hostVersion; - - // Start with all available extensions - IEnumerable filtered = _availableExtensions; + return HttpResponseDTO>.Success(result); + } + catch (HttpRequestException ex) + { + return HttpResponseDTO>.InnerException( + $"HTTP request error: {ex.Message}"); + } + catch (TaskCanceledException ex) + { + return HttpResponseDTO>.InnerException( + $"Request timeout: {ex.Message}"); + } + catch (Exception ex) + { + return HttpResponseDTO>.InnerException( + $"Error querying extensions: {ex.Message}"); + } + } - // Apply filters - if (!string.IsNullOrWhiteSpace(query.Name)) + /// + /// Downloads an extension package by ID via HTTP GET request to the server with automatic resume support. + /// Automatically detects partial downloads and resumes from where it left off. + /// 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)) { - filtered = filtered.Where(e => - e.Descriptor.Name?.IndexOf(query.Name, StringComparison.OrdinalIgnoreCase) >= 0); + return HttpResponseDTO.Failure("Extension ID cannot be null or empty"); } - if (!string.IsNullOrWhiteSpace(query.Publisher)) + // Construct download URL with encoded extension name + var encodedExtensionName = Uri.EscapeDataString(id); + var downloadUrl = $"{_serverUrl}/Download/{encodedExtensionName}"; + + // Determine the temporary file path for partial downloads + var tempFileName = $"{id}.partial"; + var tempFilePath = Path.Combine(_downloadPath, tempFileName); + + // Check if a partial download exists and get its size + long startPosition = 0; + if (File.Exists(tempFilePath)) { - filtered = filtered.Where(e => - e.Descriptor.Publisher?.IndexOf(query.Publisher, StringComparison.OrdinalIgnoreCase) >= 0); + var fileInfo = new FileInfo(tempFilePath); + startPosition = fileInfo.Length; } - if (!string.IsNullOrWhiteSpace(query.Category)) + // Create request message to support Range header + var request = new HttpRequestMessage(HttpMethod.Get, downloadUrl); + + // Add Range header if resuming from a specific position + if (startPosition > 0) { - filtered = filtered.Where(e => - e.Descriptor.Categories?.Any(c => - c.IndexOf(query.Category, StringComparison.OrdinalIgnoreCase) >= 0) == true); + request.Headers.Range = new System.Net.Http.Headers.RangeHeaderValue(startPosition, null); } - if (query.TargetPlatform.HasValue && query.TargetPlatform.Value != TargetPlatform.None) + // Make HTTP GET request to download the file + var response = await _httpClient.SendAsync(request, HttpCompletionOption.ResponseHeadersRead); + + // Check for success status codes (200 for full content, 206 for partial content) + if (response.StatusCode != System.Net.HttpStatusCode.OK && + response.StatusCode != System.Net.HttpStatusCode.PartialContent) { - filtered = filtered.Where(e => - (e.Descriptor.SupportedPlatforms & query.TargetPlatform.Value) != 0); + var errorContent = await response.Content.ReadAsStringAsync(); + return HttpResponseDTO.Failure( + $"Server returned error {response.StatusCode}: {errorContent}"); } - if (!query.IncludePreRelease) + // If we received a 200 (full content) response but we had a partial file, delete it and start fresh + if (response.StatusCode == System.Net.HttpStatusCode.OK && File.Exists(tempFilePath)) { - filtered = filtered.Where(e => !e.IsPreRelease); + File.Delete(tempFilePath); + startPosition = 0; } - if (!string.IsNullOrWhiteSpace(query.SearchTerm)) + // Download and append to the partial file + using (var responseStream = await response.Content.ReadAsStreamAsync()) { - 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)); + using (var fileStream = new FileStream(tempFilePath, + startPosition > 0 ? FileMode.Append : FileMode.Create, + FileAccess.Write, + FileShare.None)) + { + await responseStream.CopyToAsync(fileStream); + } } - // Convert to list for pagination - var filteredList = filtered.ToList(); + // Try to get filename from content-disposition header + var fileName = $"{id}.zip"; + if (response.Content.Headers.ContentDisposition?.FileName != null) + { + fileName = response.Content.Headers.ContentDisposition.FileName.Trim('"'); + } + // URL decode the filename if it was URL encoded + fileName = System.Net.WebUtility.UrlDecode(fileName); - // Calculate pagination - var totalCount = filteredList.Count; - var totalPages = (int)Math.Ceiling(totalCount / (double)query.PageSize); + // Read the complete file into a memory stream + var memoryStream = new MemoryStream(); + using (var fileStream = new FileStream(tempFilePath, FileMode.Open, FileAccess.Read, FileShare.Read)) + { + await fileStream.CopyToAsync(memoryStream); + } + memoryStream.Position = 0; - // Apply pagination - var items = filteredList - .Skip((query.PageNumber - 1) * query.PageSize) - .Take(query.PageSize) - .Select(e => MapToExtensionDTO(e, effectiveHostVersion)) - .ToList(); + // Delete the temporary file now that we have it in memory + if (File.Exists(tempFilePath)) + { + File.Delete(tempFilePath); + } - var result = new PagedResultDTO + var result = new DownloadExtensionDTO { - PageNumber = query.PageNumber, - PageSize = query.PageSize, - TotalCount = totalCount, - TotalPages = totalPages, - Items = items + FileName = fileName, + Stream = memoryStream }; - return Task.FromResult(HttpResponseDTO>.Success(result)); + return HttpResponseDTO.Success(result); + } + catch (HttpRequestException ex) + { + return HttpResponseDTO.InnerException( + $"HTTP request error: {ex.Message}"); + } + catch (TaskCanceledException ex) + { + return HttpResponseDTO.InnerException( + $"Request timeout: {ex.Message}"); + } + catch (IOException ex) + { + return HttpResponseDTO.InnerException( + $"File I/O error: {ex.Message}"); } catch (Exception ex) { - return Task.FromResult(HttpResponseDTO>.InnerException( - $"Error querying extensions: {ex.Message}")); + return HttpResponseDTO.InnerException( + $"Error downloading extension: {ex.Message}"); } } /// - /// Downloads an extension and its dependencies by ID. + /// Downloads an extension package by ID via HTTP GET request with support for resumable downloads. + /// This overload allows manual control of the resume position. /// Note: The caller is responsible for disposing the Stream in the returned DownloadExtensionDTO. /// /// Extension ID (Name) + /// Starting byte position for resuming a download (0 for full download) /// Download result containing file name and stream. The caller must dispose the stream. - public async Task> Download(string id) + public async Task> Download(string id, long startPosition) { try { @@ -221,66 +375,69 @@ public async Task> Download(string id) return HttpResponseDTO.Failure("Extension ID cannot be null or empty"); } - if (_availableExtensions == null || _availableExtensions.Count == 0) + if (startPosition < 0) { - return HttpResponseDTO.Failure("Available extensions list is empty"); + return HttpResponseDTO.Failure("Start position cannot be negative"); } - // Find the extension by ID (using Name as ID) - var extension = _availableExtensions.FirstOrDefault(e => - e.Descriptor.Name?.Equals(id, StringComparison.OrdinalIgnoreCase) == true); + // Construct download URL with encoded extension name + var encodedExtensionName = Uri.EscapeDataString(id); + var downloadUrl = $"{_serverUrl}/Download/{encodedExtensionName}"; + + // Create request message to support Range header + var request = new HttpRequestMessage(HttpMethod.Get, downloadUrl); - if (extension == null) + // Add Range header if resuming from a specific position + if (startPosition > 0) { - return HttpResponseDTO.Failure( - $"Extension with ID '{id}' not found"); + request.Headers.Range = new System.Net.Http.Headers.RangeHeaderValue(startPosition, null); } - // Collect all extensions to download (main extension + dependencies) - var extensionsToDownload = new List { extension }; + // Make HTTP GET request to download the file + var response = await _httpClient.SendAsync(request, HttpCompletionOption.ResponseHeadersRead); - // Resolve dependencies - if (extension.Descriptor.Dependencies != null && extension.Descriptor.Dependencies.Count > 0) + // Check for success status codes (200 for full content, 206 for partial content) + if (response.StatusCode != System.Net.HttpStatusCode.OK && + response.StatusCode != System.Net.HttpStatusCode.PartialContent) { - 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); - } - } + var errorContent = await response.Content.ReadAsStringAsync(); + return HttpResponseDTO.Failure( + $"Server returned error {response.StatusCode}: {errorContent}"); } - // 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 + // Read the file content as stream + var stream = await response.Content.ReadAsStreamAsync(); + var memoryStream = new MemoryStream(); + await stream.CopyToAsync(memoryStream); + memoryStream.Position = 0; - // Use the shared update queue - var operation = _updateQueue.Enqueue(extension, false); - - var downloadedPath = await DownloadAsync(operation); - - if (downloadedPath == null || !File.Exists(downloadedPath)) + // Try to get filename from content-disposition header + var fileName = $"{id}.zip"; + if (response.Content.Headers.ContentDisposition?.FileName != null) { - return HttpResponseDTO.Failure( - $"Failed to download extension '{extension.Descriptor.DisplayName}'"); + fileName = response.Content.Headers.ContentDisposition.FileName.Trim('"'); } - - // Read the file into a memory stream - var fileBytes = File.ReadAllBytes(downloadedPath); - var stream = new MemoryStream(fileBytes); + // URL decode the filename if it was URL encoded + fileName = System.Net.WebUtility.UrlDecode(fileName); var result = new DownloadExtensionDTO { - FileName = Path.GetFileName(downloadedPath), - Stream = stream + FileName = fileName, + Stream = memoryStream }; return HttpResponseDTO.Success(result); } + catch (HttpRequestException ex) + { + return HttpResponseDTO.InnerException( + $"HTTP request error: {ex.Message}"); + } + catch (TaskCanceledException ex) + { + return HttpResponseDTO.InnerException( + $"Request timeout: {ex.Message}"); + } catch (Exception ex) { return HttpResponseDTO.InnerException( @@ -302,27 +459,22 @@ public async Task> Download(string id) 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; - } + // Construct download URL from server URL and extension ID (URL-encoded for safety) + var encodedExtensionName = Uri.EscapeDataString(descriptor.Name); + var downloadUrl = $"{_serverUrl}/Download/{encodedExtensionName}"; 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"; + // Default to .zip format + var format = ".zip"; // Create version info for the download manager var versionInfo = new VersionInfo { Name = $"{descriptor.Name}_{descriptor.Version}", - Url = descriptor.DownloadUrl, + Url = downloadUrl, Hash = descriptor.PackageHash, Version = descriptor.Version, Size = descriptor.PackageSize, @@ -446,6 +598,10 @@ private ExtensionDTO MapToExtensionDTO(AvailableExtension extension, Version? ho isCompatible = _validator.IsCompatible(descriptor); } + // Construct download URL from server URL (URL-encoded for safety) + var encodedExtensionName = Uri.EscapeDataString(descriptor.Name ?? string.Empty); + var downloadUrl = $"{_serverUrl}/Download/{encodedExtensionName}"; + return new ExtensionDTO { Id = descriptor.Name ?? string.Empty, @@ -456,7 +612,7 @@ private ExtensionDTO MapToExtensionDTO(AvailableExtension extension, Version? ho UploadTime = descriptor.ReleaseDate, Status = true, // Assume enabled if it's in the available list Description = descriptor.Description, - Format = GetFileFormat(descriptor.DownloadUrl), + Format = ".zip", // Default format Hash = descriptor.PackageHash, Publisher = descriptor.Publisher, License = descriptor.License, @@ -467,29 +623,57 @@ private ExtensionDTO MapToExtensionDTO(AvailableExtension extension, Version? ho ReleaseDate = descriptor.ReleaseDate, Dependencies = descriptor.Dependencies, IsPreRelease = extension.IsPreRelease, - DownloadUrl = descriptor.DownloadUrl, + DownloadUrl = downloadUrl, // Use constructed URL from server CustomProperties = descriptor.CustomProperties, IsCompatible = isCompatible }; } /// - /// Extracts file format from download URL + /// Maps an ExtensionDTO to an AvailableExtension /// - private string? GetFileFormat(string? downloadUrl) + private AvailableExtension? MapFromExtensionDTO(ExtensionDTO dto) { - if (string.IsNullOrWhiteSpace(downloadUrl)) + if (dto == null || string.IsNullOrWhiteSpace(dto.Name)) return null; - try + Version? minVersion = null; + Version? maxVersion = null; + + if (!string.IsNullOrWhiteSpace(dto.MinHostVersion)) + Version.TryParse(dto.MinHostVersion, out minVersion); + + if (!string.IsNullOrWhiteSpace(dto.MaxHostVersion)) + Version.TryParse(dto.MaxHostVersion, out maxVersion); + + var descriptor = new ExtensionDescriptor { - var extension = Path.GetExtension(downloadUrl); - return string.IsNullOrWhiteSpace(extension) ? null : extension; - } - catch + Name = dto.Name, + DisplayName = dto.DisplayName ?? dto.Name, + Version = dto.Version ?? "1.0.0", + Description = dto.Description, + Publisher = dto.Publisher, + License = dto.License, + Categories = dto.Categories, + SupportedPlatforms = dto.SupportedPlatforms, + Compatibility = new VersionCompatibility + { + MinHostVersion = minVersion, + MaxHostVersion = maxVersion + }, + DownloadUrl = dto.DownloadUrl, + PackageHash = dto.Hash, + PackageSize = dto.FileSize ?? 0, + ReleaseDate = dto.ReleaseDate, + Dependencies = dto.Dependencies, + CustomProperties = dto.CustomProperties + }; + + return new AvailableExtension { - return null; - } + Descriptor = descriptor, + IsPreRelease = dto.IsPreRelease + }; } } } diff --git a/src/c#/GeneralUpdate.Extension/Services/IExtensionService.cs b/src/c#/GeneralUpdate.Extension/Services/IExtensionService.cs index c78bf2ed..406bbb32 100644 --- a/src/c#/GeneralUpdate.Extension/Services/IExtensionService.cs +++ b/src/c#/GeneralUpdate.Extension/Services/IExtensionService.cs @@ -46,6 +46,15 @@ public interface IExtensionService /// Download result containing file name and stream. The caller must dispose the stream. Task> Download(string id); + /// + /// Downloads an extension by ID with support for resumable downloads. + /// Note: The caller is responsible for disposing the Stream in the returned DownloadExtensionDTO. + /// + /// Extension ID + /// Starting byte position for resuming a download (0 for full download) + /// Download result containing file name and stream. The caller must dispose the stream. + Task> Download(string id, long startPosition); + /// /// Downloads an extension package asynchronously with progress tracking. /// Updates the operation state in the queue throughout the download process.