From 8b7334de1092e7eccef92fa6b0505343206f1c66 Mon Sep 17 00:00:00 2001 From: "copilot-swe-agent[bot]" <198982749+Copilot@users.noreply.github.com> Date: Fri, 6 Feb 2026 11:43:40 +0000 Subject: [PATCH 1/8] Initial plan From 506601ae8507fc16aa4e4b9b5be955343632afbc Mon Sep 17 00:00:00 2001 From: "copilot-swe-agent[bot]" <198982749+Copilot@users.noreply.github.com> Date: Fri, 6 Feb 2026 11:47:04 +0000 Subject: [PATCH 2/8] Add ServerUrl to ExtensionHostConfig and update service to use it for downloads Co-authored-by: JusterZhu <11714536+JusterZhu@users.noreply.github.com> --- .../Services/ExtensionServiceTests.cs | 2 +- .../Examples/ExtensionSystemExample.cs | 2 + .../ExtensionHostConfig.cs | 9 +++ .../GeneralExtensionHost.cs | 4 ++ src/c#/GeneralUpdate.Extension/README.md | 68 ++++++++++++------- .../Services/ExtensionService.cs | 19 +++--- 6 files changed, 70 insertions(+), 34 deletions(-) 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/README.md b/src/c#/GeneralUpdate.Extension/README.md index 611bb0fe..fa3829e8 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(); @@ -70,17 +75,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 +97,7 @@ public class YourModule : IModule PackageGeneration.ExtensionPackageGenerator>(); containerRegistry.RegisterSingleton(() => - new ExtensionHost(hostVersion, installPath, downloadPath, platform)); + new GeneralExtensionHost(config)); } } @@ -102,15 +111,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 +132,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 +141,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..ff60bee4 100644 --- a/src/c#/GeneralUpdate.Extension/Services/ExtensionService.cs +++ b/src/c#/GeneralUpdate.Extension/Services/ExtensionService.cs @@ -25,6 +25,7 @@ public class ExtensionService : IExtensionService private readonly Download.IUpdateQueue _updateQueue; private readonly string? _authScheme; private readonly string? _authToken; + private readonly string _serverUrl; /// /// Occurs when download progress updates during package retrieval. @@ -47,6 +48,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 +58,7 @@ public ExtensionService( List availableExtensions, string downloadPath, Download.IUpdateQueue updateQueue, + string serverUrl, Version? hostVersion = null, Compatibility.ICompatibilityValidator? validator = null, int downloadTimeout = 300, @@ -67,7 +70,11 @@ public ExtensionService( if (string.IsNullOrWhiteSpace(downloadPath)) throw new ArgumentNullException(nameof(downloadPath)); + if (string.IsNullOrWhiteSpace(serverUrl)) + throw new ArgumentNullException(nameof(serverUrl)); + _downloadPath = downloadPath; + _serverUrl = serverUrl.TrimEnd('/'); // Remove trailing slash for consistent URL construction _updateQueue = updateQueue ?? throw new ArgumentNullException(nameof(updateQueue)); _downloadTimeout = downloadTimeout; _hostVersion = hostVersion; @@ -302,18 +309,14 @@ 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 + var downloadUrl = $"{_serverUrl}/Download/{descriptor.Name}"; try { _updateQueue.ChangeState(operation.OperationId, GeneralUpdate.Extension.Download.UpdateState.Updating); - // Determine file format from URL or default to .zip + // Determine file format from descriptor or default to .zip var format = !string.IsNullOrWhiteSpace(descriptor.DownloadUrl) && descriptor.DownloadUrl!.Contains(".") ? Path.GetExtension(descriptor.DownloadUrl) : ".zip"; @@ -322,7 +325,7 @@ public async Task> Download(string id) var versionInfo = new VersionInfo { Name = $"{descriptor.Name}_{descriptor.Version}", - Url = descriptor.DownloadUrl, + Url = downloadUrl, Hash = descriptor.PackageHash, Version = descriptor.Version, Size = descriptor.PackageSize, From d72233b80c0888b045ff22e2ceb9fc93a384e526 Mon Sep 17 00:00:00 2001 From: "copilot-swe-agent[bot]" <198982749+Copilot@users.noreply.github.com> Date: Fri, 6 Feb 2026 11:49:22 +0000 Subject: [PATCH 3/8] Remove dependency on DownloadUrl property and use ServerUrl for all downloads Co-authored-by: JusterZhu <11714536+JusterZhu@users.noreply.github.com> --- .../Services/ExtensionService.cs | 32 ++++--------------- 1 file changed, 7 insertions(+), 25 deletions(-) diff --git a/src/c#/GeneralUpdate.Extension/Services/ExtensionService.cs b/src/c#/GeneralUpdate.Extension/Services/ExtensionService.cs index ff60bee4..33f1475f 100644 --- a/src/c#/GeneralUpdate.Extension/Services/ExtensionService.cs +++ b/src/c#/GeneralUpdate.Extension/Services/ExtensionService.cs @@ -316,10 +316,8 @@ public async Task> Download(string id) { _updateQueue.ChangeState(operation.OperationId, GeneralUpdate.Extension.Download.UpdateState.Updating); - // Determine file format from descriptor 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 @@ -449,6 +447,9 @@ private ExtensionDTO MapToExtensionDTO(AvailableExtension extension, Version? ho isCompatible = _validator.IsCompatible(descriptor); } + // Construct download URL from server URL + var downloadUrl = $"{_serverUrl}/Download/{descriptor.Name}"; + return new ExtensionDTO { Id = descriptor.Name ?? string.Empty, @@ -459,7 +460,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, @@ -470,29 +471,10 @@ 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 - /// - 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; - } - } } } From 7a39110a12defaafb15c960eae0b0389c412cbdd Mon Sep 17 00:00:00 2001 From: "copilot-swe-agent[bot]" <198982749+Copilot@users.noreply.github.com> Date: Fri, 6 Feb 2026 11:50:09 +0000 Subject: [PATCH 4/8] Add documentation for ServerUrl architecture and update DownloadUrl comments Co-authored-by: JusterZhu <11714536+JusterZhu@users.noreply.github.com> --- .../Metadata/ExtensionDescriptor.cs | 3 +++ src/c#/GeneralUpdate.Extension/README.md | 26 +++++++++++++++++++ 2 files changed, 29 insertions(+) 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 fa3829e8..b812dcda 100644 --- a/src/c#/GeneralUpdate.Extension/README.md +++ b/src/c#/GeneralUpdate.Extension/README.md @@ -59,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 From 9dc11ca5cd79b353fa19cf3c49a76c05efb8c36a Mon Sep 17 00:00:00 2001 From: "copilot-swe-agent[bot]" <198982749+Copilot@users.noreply.github.com> Date: Fri, 6 Feb 2026 11:51:39 +0000 Subject: [PATCH 5/8] Add URL encoding for extension names and improve URL construction comments Co-authored-by: JusterZhu <11714536+JusterZhu@users.noreply.github.com> --- .../Services/ExtensionService.cs | 13 ++++++++----- 1 file changed, 8 insertions(+), 5 deletions(-) diff --git a/src/c#/GeneralUpdate.Extension/Services/ExtensionService.cs b/src/c#/GeneralUpdate.Extension/Services/ExtensionService.cs index 33f1475f..871541d0 100644 --- a/src/c#/GeneralUpdate.Extension/Services/ExtensionService.cs +++ b/src/c#/GeneralUpdate.Extension/Services/ExtensionService.cs @@ -74,7 +74,8 @@ public ExtensionService( throw new ArgumentNullException(nameof(serverUrl)); _downloadPath = downloadPath; - _serverUrl = serverUrl.TrimEnd('/'); // Remove trailing slash for consistent URL construction + // 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; @@ -309,8 +310,9 @@ public async Task> Download(string id) var descriptor = operation.Extension.Descriptor; - // Construct download URL from server URL and extension ID - var downloadUrl = $"{_serverUrl}/Download/{descriptor.Name}"; + // 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 { @@ -447,8 +449,9 @@ private ExtensionDTO MapToExtensionDTO(AvailableExtension extension, Version? ho isCompatible = _validator.IsCompatible(descriptor); } - // Construct download URL from server URL - var downloadUrl = $"{_serverUrl}/Download/{descriptor.Name}"; + // 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 { From 63cb95f85d7e665744f72acd447bc6e936b1d6a3 Mon Sep 17 00:00:00 2001 From: "copilot-swe-agent[bot]" <198982749+Copilot@users.noreply.github.com> Date: Fri, 6 Feb 2026 12:03:39 +0000 Subject: [PATCH 6/8] Implement HTTP GET requests for Query and Download methods with authentication support Co-authored-by: JusterZhu <11714536+JusterZhu@users.noreply.github.com> --- .../Services/ExtensionService.cs | 295 ++++++++++-------- 1 file changed, 173 insertions(+), 122 deletions(-) diff --git a/src/c#/GeneralUpdate.Extension/Services/ExtensionService.cs b/src/c#/GeneralUpdate.Extension/Services/ExtensionService.cs index 871541d0..e5ce4e53 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; @@ -26,6 +30,7 @@ public class ExtensionService : IExtensionService private readonly string? _authScheme; private readonly string? _authToken; private readonly string _serverUrl; + private readonly HttpClient _httpClient; /// /// Occurs when download progress updates during package retrieval. @@ -83,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); @@ -99,123 +117,124 @@ 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")); - } - - // 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}")); - } + return HttpResponseDTO>.Failure( + "PageSize must be greater than 0"); } - // 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 + // Build query string from parameters + var queryParams = new List(); + queryParams.Add($"PageNumber={query.PageNumber}"); + queryParams.Add($"PageSize={query.PageSize}"); + if (!string.IsNullOrWhiteSpace(query.Name)) - { - filtered = filtered.Where(e => - e.Descriptor.Name?.IndexOf(query.Name, StringComparison.OrdinalIgnoreCase) >= 0); - } - + queryParams.Add($"Name={Uri.EscapeDataString(query.Name)}"); + if (!string.IsNullOrWhiteSpace(query.Publisher)) - { - filtered = filtered.Where(e => - e.Descriptor.Publisher?.IndexOf(query.Publisher, StringComparison.OrdinalIgnoreCase) >= 0); - } - + queryParams.Add($"Publisher={Uri.EscapeDataString(query.Publisher)}"); + if (!string.IsNullOrWhiteSpace(query.Category)) - { - filtered = filtered.Where(e => - e.Descriptor.Categories?.Any(c => - c.IndexOf(query.Category, StringComparison.OrdinalIgnoreCase) >= 0) == true); - } + 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)}"); - if (query.TargetPlatform.HasValue && query.TargetPlatform.Value != TargetPlatform.None) - { - filtered = filtered.Where(e => - (e.Descriptor.SupportedPlatforms & query.TargetPlatform.Value) != 0); - } + var queryString = string.Join("&", queryParams); + var requestUrl = $"{_serverUrl}/Query?{queryString}"; - if (!query.IncludePreRelease) + // Make HTTP GET request + var response = await _httpClient.GetAsync(requestUrl); + + if (!response.IsSuccessStatusCode) { - filtered = filtered.Where(e => !e.IsPreRelease); + var errorContent = await response.Content.ReadAsStringAsync(); + return HttpResponseDTO>.Failure( + $"Server returned error {response.StatusCode}: {errorContent}"); } - if (!string.IsNullOrWhiteSpace(query.SearchTerm)) + var jsonContent = await response.Content.ReadAsStringAsync(); + var result = JsonSerializer.Deserialize>(jsonContent, + new JsonSerializerOptions { PropertyNameCaseInsensitive = true }); + + if (result == null) { - 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)); + return HttpResponseDTO>.Failure( + "Failed to deserialize server response"); } - // 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 + // Update local cache with results + if (result.Items != null && result.Items.Any()) { - PageNumber = query.PageNumber, - PageSize = query.PageSize, - TotalCount = totalCount, - TotalPages = totalPages, - Items = items - }; + var availableExtensions = result.Items + .Select(dto => MapFromExtensionDTO(dto)) + .Where(ext => ext != null) + .Cast() + .ToList(); + + // Merge with existing extensions + foreach (var ext in availableExtensions) + { + var existing = _availableExtensions.FirstOrDefault(e => + e.Descriptor.Name?.Equals(ext.Descriptor.Name, StringComparison.OrdinalIgnoreCase) == true); + + if (existing == null) + { + _availableExtensions.Add(ext); + } + } + } - 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 (Exception ex) { - return Task.FromResult(HttpResponseDTO>.InnerException( - $"Error querying extensions: {ex.Message}")); + return HttpResponseDTO>.InnerException( + $"Error querying extensions: {ex.Message}"); } } /// - /// Downloads an extension and its dependencies by ID. + /// Downloads an extension package by ID via HTTP GET request to the server. /// Note: The caller is responsible for disposing the Stream in the returned DownloadExtensionDTO. /// /// Extension ID (Name) @@ -229,66 +248,51 @@ public async Task> Download(string 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"); - } + // Construct download URL with encoded extension name + var encodedExtensionName = Uri.EscapeDataString(id); + var downloadUrl = $"{_serverUrl}/Download/{encodedExtensionName}"; - // Find the extension by ID (using Name as ID) - var extension = _availableExtensions.FirstOrDefault(e => - e.Descriptor.Name?.Equals(id, StringComparison.OrdinalIgnoreCase) == true); + // Make HTTP GET request to download the file + var response = await _httpClient.GetAsync(downloadUrl); - if (extension == null) + if (!response.IsSuccessStatusCode) { + var errorContent = await response.Content.ReadAsStringAsync(); 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); - } - } + $"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 - - // Use the shared update queue - var operation = _updateQueue.Enqueue(extension, false); - - var downloadedPath = await DownloadAsync(operation); + // Read the file content as stream + var stream = await response.Content.ReadAsStreamAsync(); + var memoryStream = new MemoryStream(); + await stream.CopyToAsync(memoryStream); + memoryStream.Position = 0; - 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); - 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( @@ -479,5 +483,52 @@ private ExtensionDTO MapToExtensionDTO(AvailableExtension extension, Version? ho IsCompatible = isCompatible }; } + + /// + /// Maps an ExtensionDTO to an AvailableExtension + /// + private AvailableExtension? MapFromExtensionDTO(ExtensionDTO dto) + { + if (dto == null || string.IsNullOrWhiteSpace(dto.Name)) + return null; + + 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 + { + 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 + { + Descriptor = descriptor, + IsPreRelease = dto.IsPreRelease + }; + } } } From 73075edb56016d148748a32d0ae81ef0b9193a29 Mon Sep 17 00:00:00 2001 From: "copilot-swe-agent[bot]" <198982749+Copilot@users.noreply.github.com> Date: Fri, 6 Feb 2026 12:11:04 +0000 Subject: [PATCH 7/8] Add support for resumable downloads with Range header Co-authored-by: JusterZhu <11714536+JusterZhu@users.noreply.github.com> --- .../Services/ExtensionService.cs | 34 +++++++++++++++++-- .../Services/IExtensionService.cs | 9 +++++ 2 files changed, 41 insertions(+), 2 deletions(-) diff --git a/src/c#/GeneralUpdate.Extension/Services/ExtensionService.cs b/src/c#/GeneralUpdate.Extension/Services/ExtensionService.cs index e5ce4e53..9c7ad319 100644 --- a/src/c#/GeneralUpdate.Extension/Services/ExtensionService.cs +++ b/src/c#/GeneralUpdate.Extension/Services/ExtensionService.cs @@ -240,6 +240,18 @@ public async Task>> Query(Extension /// Extension ID (Name) /// Download result containing file name and stream. The caller must dispose the stream. public async Task> Download(string id) + { + return await Download(id, 0); + } + + /// + /// Downloads an extension package by ID via HTTP GET request with support for resumable downloads. + /// 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, long startPosition) { try { @@ -248,14 +260,30 @@ public async Task> Download(string id) return HttpResponseDTO.Failure("Extension ID cannot be null or empty"); } + if (startPosition < 0) + { + return HttpResponseDTO.Failure("Start position cannot be negative"); + } + // 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); + + // Add Range header if resuming from a specific position + if (startPosition > 0) + { + request.Headers.Range = new System.Net.Http.Headers.RangeHeaderValue(startPosition, null); + } + // Make HTTP GET request to download the file - var response = await _httpClient.GetAsync(downloadUrl); + var response = await _httpClient.SendAsync(request, HttpCompletionOption.ResponseHeadersRead); - if (!response.IsSuccessStatusCode) + // 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) { var errorContent = await response.Content.ReadAsStringAsync(); return HttpResponseDTO.Failure( @@ -274,6 +302,8 @@ public async Task> Download(string id) { fileName = response.Content.Headers.ContentDisposition.FileName.Trim('"'); } + // URL decode the filename if it was URL encoded + fileName = System.Net.WebUtility.UrlDecode(fileName); var result = new DownloadExtensionDTO { 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. From 30df99ad1e734bf00cbcb4428b97725195fd79b8 Mon Sep 17 00:00:00 2001 From: "copilot-swe-agent[bot]" <198982749+Copilot@users.noreply.github.com> Date: Fri, 6 Feb 2026 12:17:13 +0000 Subject: [PATCH 8/8] Implement automatic resume detection for downloads based on partial file existence Co-authored-by: JusterZhu <11714536+JusterZhu@users.noreply.github.com> --- .../Services/ExtensionService.cs | 119 +++++++++++++++++- 1 file changed, 117 insertions(+), 2 deletions(-) diff --git a/src/c#/GeneralUpdate.Extension/Services/ExtensionService.cs b/src/c#/GeneralUpdate.Extension/Services/ExtensionService.cs index 9c7ad319..c2989a9e 100644 --- a/src/c#/GeneralUpdate.Extension/Services/ExtensionService.cs +++ b/src/c#/GeneralUpdate.Extension/Services/ExtensionService.cs @@ -234,18 +234,133 @@ public async Task>> Query(Extension } /// - /// Downloads an extension package by ID via HTTP GET request to the server. + /// 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) { - return await Download(id, 0); + try + { + if (string.IsNullOrWhiteSpace(id)) + { + return HttpResponseDTO.Failure("Extension ID cannot be null or empty"); + } + + // 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)) + { + var fileInfo = new FileInfo(tempFilePath); + startPosition = fileInfo.Length; + } + + // 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) + { + request.Headers.Range = new System.Net.Http.Headers.RangeHeaderValue(startPosition, null); + } + + // 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) + { + var errorContent = await response.Content.ReadAsStringAsync(); + return HttpResponseDTO.Failure( + $"Server returned error {response.StatusCode}: {errorContent}"); + } + + // 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)) + { + File.Delete(tempFilePath); + startPosition = 0; + } + + // Download and append to the partial file + using (var responseStream = await response.Content.ReadAsStreamAsync()) + { + using (var fileStream = new FileStream(tempFilePath, + startPosition > 0 ? FileMode.Append : FileMode.Create, + FileAccess.Write, + FileShare.None)) + { + await responseStream.CopyToAsync(fileStream); + } + } + + // 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); + + // 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; + + // Delete the temporary file now that we have it in memory + if (File.Exists(tempFilePath)) + { + File.Delete(tempFilePath); + } + + var result = new DownloadExtensionDTO + { + 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 (IOException ex) + { + return HttpResponseDTO.InnerException( + $"File I/O error: {ex.Message}"); + } + catch (Exception ex) + { + return HttpResponseDTO.InnerException( + $"Error downloading extension: {ex.Message}"); + } } /// /// 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)