diff --git a/eng/update-dependencies/LcuVariableUpdater.cs b/eng/update-dependencies/LcuVariableUpdater.cs index 5e775e90..55802ddc 100644 --- a/eng/update-dependencies/LcuVariableUpdater.cs +++ b/eng/update-dependencies/LcuVariableUpdater.cs @@ -3,7 +3,7 @@ // See the LICENSE file in the project root for more information. using System.Text.RegularExpressions; -using Microsoft.Playwright; +using Microsoft.DotNet.Framework.UpdateDependencies.UpdateCatalog; namespace Microsoft.DotNet.Framework.UpdateDependencies; @@ -11,27 +11,8 @@ namespace Microsoft.DotNet.Framework.UpdateDependencies; /// Updates Latest Cumulative Update (LCU) download URLs variables by fetching /// the most recent update information from the Microsoft Update Catalog. /// -internal sealed partial class LcuVariableUpdater : IVariableUpdater, IAsyncDisposable +internal sealed partial class LcuVariableUpdater(UpdateCatalogClient catalog) : IVariableUpdater { - private static readonly BrowserNewContextOptions s_newBrowserOptions = new() { Locale = "en-US" }; - - private readonly Lazy> _playwright; - private readonly Lazy> _browser; - - public LcuVariableUpdater() - { - _playwright = new Lazy>(Playwright.Playwright.CreateAsync); - _browser = new( - async () => - { - var playwright = await _playwright.Value; - return await playwright.Chromium.LaunchAsync( - new BrowserTypeLaunchOptions { Headless = true } - ); - } - ); - } - /// /// Matches LCU variable names in the format "lcu|{version}|{framework}" /// where version contains digits and periods. Examples: @@ -77,72 +58,66 @@ public async Task GetNewValueAsync(string variableKey, IVariableContext /// KB article download URL private async Task GetKbDownloadUrlAsync(string kb, string windowsVersion) { - var browser = await _browser.Value; - var context = await browser.NewContextAsync(s_newBrowserOptions); - var page = await context.NewPageAsync(); - - await page.GotoAsync($"https://catalog.update.microsoft.com/Search.aspx?q={kb}"); - // Some windows versions require a more precise regex to match the // correct LCU in on the update catalog page. By convention, the // Windows version is the second part of the version name. - var tableRowRegex = windowsVersion switch + Regex titleRegex = windowsVersion switch { - "ltsc2022" => Server2022TableRowRegex, - "ltsc2025" => Server2025TableRowRegex, - _ => WindowsServerTableRowRegex + "ltsc2022" => Server2022TitleRegex, + "ltsc2025" => Server2025TitleRegex, + _ => WindowsServerTitleRegex }; - var downloadPopUpPage = await page.RunAndWaitForPopupAsync( - async () => - { - await page - .GetByRole(AriaRole.Row, new PageGetByRoleOptions() { NameRegex = tableRowRegex, Exact = true }) - .GetByRole(AriaRole.Button) - .ClickAsync(); - } - ); + // A KB has an update for each Windows version and architecture. + IReadOnlyList results = await catalog.SearchAsync(kb); + CatalogUpdate[] matchingUpdates = results + .Where(result => titleRegex.IsMatch(result.Title)) + .ToArray(); - var url = await downloadPopUpPage - .GetByRole(AriaRole.Link, s_getDownloadLinkOptions) - .GetAttributeAsync("href"); + CatalogUpdate update = matchingUpdates switch + { + [var match] => match, - await context.CloseAsync(); + [] => throw new InvalidOperationException( + $"No Update Catalog result for {kb} matches {windowsVersion}. " + + $"Results: [{string.Join("; ", results.Select(result => result.Title))}]"), - Console.WriteLine($"{kb} download URL: {url}"); + _ => throw new InvalidOperationException( + $"Multiple Update Catalog results for {kb} match {windowsVersion}: " + + $"[{string.Join("; ", matchingUpdates.Select(match => match.Title))}]"), + }; - return url ?? ""; - } + IReadOnlyList downloadUrls = await catalog.GetDownloadUrlsAsync(update); + string[] msuUrls = downloadUrls + .Where(url => MsuUrlRegex.IsMatch(url)) + .ToArray(); - public async ValueTask DisposeAsync() - { - if (_browser.IsValueCreated) + string msuUrl = msuUrls switch { - var browser = await _browser.Value; - await browser.DisposeAsync(); - } + [var url] => url, - if (_playwright.IsValueCreated) - { - var playwright = await _playwright.Value; - playwright.Dispose(); - } - } + [] => throw new InvalidOperationException( + $"No MSU download URL for '{update.Title}'. URLs: [{string.Join("; ", downloadUrls)}]"), - private static readonly PageGetByRoleOptions s_getDownloadLinkOptions = new() - { - NameRegex = DownloadLinkRegex, - }; + _ => throw new InvalidOperationException( + $"Multiple MSU download URLs for '{update.Title}': [{string.Join("; ", msuUrls)}]"), + }; + + Console.WriteLine($"{kb} download URL: {msuUrl}"); + + return msuUrl; + } - [GeneratedRegex(@"^windows.*\.msu$")] - private static partial Regex DownloadLinkRegex { get; } + // The file name at the end of the URL, e.g. ".../windows10.0-kb5126422-x64-ndp481_1e92b2.msu". + [GeneratedRegex(@"/windows[^/]*\.msu$")] + private static partial Regex MsuUrlRegex { get; } [GeneratedRegex(@"server.*x64", RegexOptions.IgnoreCase, "en-US")] - private static partial Regex WindowsServerTableRowRegex { get; } + private static partial Regex WindowsServerTitleRegex { get; } [GeneratedRegex(@"server.*21H2.*x64", RegexOptions.IgnoreCase, "en-US")] - private static partial Regex Server2022TableRowRegex { get; } + private static partial Regex Server2022TitleRegex { get; } - [GeneratedRegex(@"24H2.*x64", RegexOptions.IgnoreCase, "en-US")] - private static partial Regex Server2025TableRowRegex { get; } + [GeneratedRegex(@"server.*24H2.*x64", RegexOptions.IgnoreCase, "en-US")] + private static partial Regex Server2025TitleRegex { get; } } diff --git a/eng/update-dependencies/Microsoft.DotNet.Framework.UpdateDependencies.csproj b/eng/update-dependencies/Microsoft.DotNet.Framework.UpdateDependencies.csproj index 8ff60a5f..09139f69 100644 --- a/eng/update-dependencies/Microsoft.DotNet.Framework.UpdateDependencies.csproj +++ b/eng/update-dependencies/Microsoft.DotNet.Framework.UpdateDependencies.csproj @@ -11,7 +11,8 @@ - + + diff --git a/eng/update-dependencies/NuGet/NuGetFeedClient.cs b/eng/update-dependencies/NuGet/NuGetFeedClient.cs new file mode 100644 index 00000000..63187c48 --- /dev/null +++ b/eng/update-dependencies/NuGet/NuGetFeedClient.cs @@ -0,0 +1,114 @@ +// Licensed to the .NET Foundation under one or more agreements. +// The .NET Foundation licenses this file to you under the MIT license. +// See the LICENSE file in the project root for more information. + +using System.Text.Json; +using System.Text.Json.Serialization; +using NuGet.Versioning; + +namespace Microsoft.DotNet.Framework.UpdateDependencies.NuGet; + +/// +/// Client for a NuGet v3 feed. Reads the service index, then the package's +/// version list. +/// +internal sealed class NuGetFeedClient(HttpClient httpClient) +{ + private const string PackageBaseAddress = "PackageBaseAddress/3.0.0"; + + private static readonly JsonSerializerOptions s_jsonOptions = new(JsonSerializerDefaults.Web) + { + RespectNullableAnnotations = true, + RespectRequiredConstructorParameters = true, + }; + + /// + /// Gets the highest stable version of a package. Prerelease versions are + /// ignored. The version list is not assumed to be sorted. + /// + public async Task GetLatestStableVersionAsync(string serviceIndexUrl, string packageId) + { + string packageBaseAddress = await GetPackageBaseAddressAsync(serviceIndexUrl); + string versionsUrl = + $"{packageBaseAddress.TrimEnd('/')}/{packageId.ToLowerInvariant()}/index.json"; + + PackageVersions packageVersions = await GetJsonAsync(versionsUrl); + + var stable = new List(); + foreach (string versionText in packageVersions.Versions) + { + if (!NuGetVersion.TryParse(versionText, out NuGetVersion? version)) + { + throw new InvalidOperationException( + $"Package '{packageId}' has an unparseable version '{versionText}'."); + } + + if (!version.IsPrerelease) + { + stable.Add(version); + } + } + + if (stable.Count == 0) + { + throw new InvalidOperationException( + $"Package '{packageId}' has no stable version at {versionsUrl}."); + } + + // OriginalVersion is the string the feed listed, not a normalized rewrite. + NuGetVersion latest = stable.Max()!; + return latest.OriginalVersion ?? latest.ToNormalizedString(); + } + + private async Task GetPackageBaseAddressAsync(string serviceIndexUrl) + { + ServiceIndex index = await GetJsonAsync(serviceIndexUrl); + + string? packageBaseAddress = index.Resources + .FirstOrDefault(resource => IsPackageBaseAddress(resource.Type)) + ?.Id; + + if (packageBaseAddress is null) + { + throw new InvalidOperationException( + $"NuGet service index {serviceIndexUrl} has no {PackageBaseAddress} resource."); + } + + return packageBaseAddress; + } + + private async Task GetJsonAsync(string url) + { + using var request = new HttpRequestMessage(HttpMethod.Get, url); + using HttpResponseMessage response = await httpClient.SendAsync(request); + + if (response.StatusCode == System.Net.HttpStatusCode.NotFound) + { + throw new InvalidOperationException($"NuGet feed returned 404 for {url}."); + } + + response.EnsureSuccessStatusCode(); + + string json = await response.Content.ReadAsStringAsync(); + return JsonSerializer.Deserialize(json, s_jsonOptions) ?? + throw new InvalidOperationException($"NuGet feed response from {url} was empty."); + } + + private static bool IsPackageBaseAddress(JsonElement type) + { + return type.ValueKind switch + { + JsonValueKind.String => type.GetString() == PackageBaseAddress, + JsonValueKind.Array => type.EnumerateArray().Any(item => item.GetString() == PackageBaseAddress), + _ => false, + }; + } + + private sealed record ServiceIndex(IReadOnlyList Resources); + + private sealed record ServiceResource( + [property: JsonPropertyName("@id")] string Id, + [property: JsonPropertyName("@type")] JsonElement Type); + + private sealed record PackageVersions(IReadOnlyList Versions); +} diff --git a/eng/update-dependencies/NuGetVariableUpdater.cs b/eng/update-dependencies/NuGetVariableUpdater.cs new file mode 100644 index 00000000..55317afd --- /dev/null +++ b/eng/update-dependencies/NuGetVariableUpdater.cs @@ -0,0 +1,72 @@ +// Licensed to the .NET Foundation under one or more agreements. +// The .NET Foundation licenses this file to you under the MIT license. +// See the LICENSE file in the project root for more information. + +using System.Text.Json; +using System.Text.Json.Serialization; + +namespace Microsoft.DotNet.Framework.UpdateDependencies; + +/// +/// Updates the NuGet CLI version variable to the latest recommended nuget.exe. +/// +internal sealed class NuGetVariableUpdater(HttpClient httpClient) : IVariableUpdater +{ + private const string ToolsUrl = "https://dist.nuget.org/tools.json"; + + // Recommended for consumption. See https://learn.microsoft.com/nuget/api/tools-json + private const string ReleasedAndBlessed = "ReleasedAndBlessed"; + + private static readonly JsonSerializerOptions s_jsonOptions = new(JsonSerializerDefaults.Web) + { + RespectNullableAnnotations = true, + RespectRequiredConstructorParameters = true, + }; + + /// + public bool ShouldUpdate(string variableKey, IVariableContext variables) + { + // The manifest has a single NuGet variable, "nuget|version". + return variableKey == "nuget|version"; + } + + /// + public async Task GetNewValueAsync(string variableKey, IVariableContext variables) + { + string version = await GetLatestVersionAsync(); + Console.WriteLine($"NuGet version: {version}"); + return version; + } + + /// + /// Gets the latest recommended nuget.exe version from tools.json. + /// + /// + /// tools.json is sorted by SemVer descending, so the latest recommended + /// version is the first entry with stage ReleasedAndBlessed. + /// + private async Task GetLatestVersionAsync() + { + using var request = new HttpRequestMessage(HttpMethod.Get, ToolsUrl); + using HttpResponseMessage response = await httpClient.SendAsync(request); + response.EnsureSuccessStatusCode(); + + string json = await response.Content.ReadAsStringAsync(); + NuGetTools tools = JsonSerializer.Deserialize(json, s_jsonOptions) ?? + throw new InvalidOperationException($"tools.json response from {ToolsUrl} was empty."); + + NuGetTool? latest = tools.NuGetExe.FirstOrDefault(tool => tool.Stage == ReleasedAndBlessed); + if (latest is null) + { + throw new InvalidOperationException( + $"tools.json from {ToolsUrl} has no ReleasedAndBlessed nuget.exe version."); + } + + return latest.Version; + } + + private sealed record NuGetTools( + [property: JsonPropertyName("nuget.exe")] IReadOnlyList NuGetExe); + + private sealed record NuGetTool(string Version, string Stage); +} diff --git a/eng/update-dependencies/Program.cs b/eng/update-dependencies/Program.cs index dc083370..a0d16875 100644 --- a/eng/update-dependencies/Program.cs +++ b/eng/update-dependencies/Program.cs @@ -3,14 +3,13 @@ // See the LICENSE file in the project root for more information. using Microsoft.DotNet.Framework.UpdateDependencies; +using Microsoft.DotNet.Framework.UpdateDependencies.NuGet; +using Microsoft.DotNet.Framework.UpdateDependencies.UpdateCatalog; using System.CommandLine; // Command-line tool for updating dependency variables in .NET Framework Docker // manifest files. // -// Before running, you may need to build the app and then run: -// pwsh bin/Debug/net*/playwright.ps1 install -// // Usage: dotnet run -- --help var manifestFileOption = new Argument("manifest file path") @@ -25,7 +24,34 @@ manifestFileOption }; -var rootCommand = new RootCommand() { updateLcusCommand }; +var updateNuGetCommand = new Command( + name: "update-nuget", + description: "Update the NuGet version variable in the specified manifest file.") +{ + manifestFileOption +}; + +var updateReferenceAssembliesCommand = new Command( + name: "update-referenceassemblies", + description: "Update the reference assemblies version variable in the specified manifest file.") +{ + manifestFileOption +}; + +var updateServiceMonitorCommand = new Command( + name: "update-servicemonitor", + description: "Update the ServiceMonitor version variable in the specified manifest file.") +{ + manifestFileOption +}; + +var rootCommand = new RootCommand() +{ + updateLcusCommand, + updateNuGetCommand, + updateReferenceAssembliesCommand, + updateServiceMonitorCommand +}; updateLcusCommand.SetAction( async parseResult => @@ -36,10 +62,58 @@ var manifestVersionsContent = await File.ReadAllTextAsync(manifestFilePath); var manifestVersionsContext = new ManifestVariableContext(manifestVersionsContent); - await using (var lcuUpdater = new LcuVariableUpdater()) - { - await manifestVersionsContext.ApplyAsync(lcuUpdater); - } + using var httpClient = new HttpClient(); + var catalog = new UpdateCatalogClient(httpClient); + var lcuUpdater = new LcuVariableUpdater(catalog); + await manifestVersionsContext.ApplyAsync(lcuUpdater); + + await File.WriteAllTextAsync(manifestFilePath, manifestVersionsContext.Content); + } +); + +updateNuGetCommand.SetAction( + async parseResult => + { + string manifestFilePath = parseResult.GetRequiredValue(manifestFileOption); + + var manifestVersionsContent = await File.ReadAllTextAsync(manifestFilePath); + var manifestVersionsContext = new ManifestVariableContext(manifestVersionsContent); + + using var httpClient = new HttpClient(); + var nugetUpdater = new NuGetVariableUpdater(httpClient); + await manifestVersionsContext.ApplyAsync(nugetUpdater); + + await File.WriteAllTextAsync(manifestFilePath, manifestVersionsContext.Content); + } +); + +updateReferenceAssembliesCommand.SetAction( + async parseResult => + { + string manifestFilePath = parseResult.GetRequiredValue(manifestFileOption); + + var manifestVersionsContent = await File.ReadAllTextAsync(manifestFilePath); + var manifestVersionsContext = new ManifestVariableContext(manifestVersionsContent); + + using var httpClient = new HttpClient(); + var feed = new NuGetFeedClient(httpClient); + var updater = new ReferenceAssembliesVariableUpdater(feed); + await manifestVersionsContext.ApplyAsync(updater); + + await File.WriteAllTextAsync(manifestFilePath, manifestVersionsContext.Content); + } +); + +updateServiceMonitorCommand.SetAction( + async parseResult => + { + string manifestFilePath = parseResult.GetRequiredValue(manifestFileOption); + + var manifestVersionsContent = await File.ReadAllTextAsync(manifestFilePath); + var manifestVersionsContext = new ManifestVariableContext(manifestVersionsContent); + + var updater = new ServiceMonitorVariableUpdater(); + await manifestVersionsContext.ApplyAsync(updater); await File.WriteAllTextAsync(manifestFilePath, manifestVersionsContext.Content); } diff --git a/eng/update-dependencies/ReferenceAssembliesVariableUpdater.cs b/eng/update-dependencies/ReferenceAssembliesVariableUpdater.cs new file mode 100644 index 00000000..9051ba13 --- /dev/null +++ b/eng/update-dependencies/ReferenceAssembliesVariableUpdater.cs @@ -0,0 +1,38 @@ +// Licensed to the .NET Foundation under one or more agreements. +// The .NET Foundation licenses this file to you under the MIT license. +// See the LICENSE file in the project root for more information. + +using Microsoft.DotNet.Framework.UpdateDependencies.NuGet; + +namespace Microsoft.DotNet.Framework.UpdateDependencies; + +/// +/// Updates the reference assemblies version to the latest stable +/// Microsoft.NETFramework.ReferenceAssemblies package. The images install the +/// per-framework packages at this version from the dotnet-public feed, so that +/// feed is the source of the version rather than nuget.org. +/// +internal sealed class ReferenceAssembliesVariableUpdater(NuGetFeedClient feed) : IVariableUpdater +{ + private const string PackageId = "Microsoft.NETFramework.ReferenceAssemblies"; + + /// + public bool ShouldUpdate(string variableKey, IVariableContext variables) + { + return variableKey == "referenceassemblies|version"; + } + + /// + public async Task GetNewValueAsync(string variableKey, IVariableContext variables) + { + string source = variables["dotnet-public|source"]; + if (string.IsNullOrEmpty(source)) + { + throw new InvalidOperationException("Manifest has no dotnet-public|source variable."); + } + + string version = await feed.GetLatestStableVersionAsync(source, PackageId); + Console.WriteLine($"Reference assemblies version: {version}"); + return version; + } +} diff --git a/eng/update-dependencies/ServiceMonitorVariableUpdater.cs b/eng/update-dependencies/ServiceMonitorVariableUpdater.cs new file mode 100644 index 00000000..031861a6 --- /dev/null +++ b/eng/update-dependencies/ServiceMonitorVariableUpdater.cs @@ -0,0 +1,80 @@ +// Licensed to the .NET Foundation under one or more agreements. +// The .NET Foundation licenses this file to you under the MIT license. +// See the LICENSE file in the project root for more information. + +using NuGet.Versioning; +using Octokit; + +namespace Microsoft.DotNet.Framework.UpdateDependencies; + +/// +/// Updates the ServiceMonitor version variable to the latest GitHub release +/// that publishes ServiceMonitor.exe. +/// +internal sealed class ServiceMonitorVariableUpdater : IVariableUpdater +{ + private const string Owner = "microsoft"; + private const string Repository = "IIS.ServiceMonitor"; + + // The images download this exact file name from the release. + private const string AssetName = "ServiceMonitor.exe"; + + private readonly GitHubClient _github = new(new ProductHeaderValue("dotnet-framework-docker-update-dependencies")); + + /// + public bool ShouldUpdate(string variableKey, IVariableContext variables) + { + // The manifest also has servicemonitor|url, which references this version. + return variableKey == "servicemonitor|version"; + } + + /// + public async Task GetNewValueAsync(string variableKey, IVariableContext variables) + { + string version = await GetLatestVersionAsync(); + Console.WriteLine($"ServiceMonitor version: {version}"); + return version; + } + + /// + /// Gets the latest stable release version that publishes ServiceMonitor.exe. + /// The returned version has no leading "v"; the manifest URL adds that. + /// + private async Task GetLatestVersionAsync() + { + IReadOnlyList releases = await _github.Repository.Release.GetAll(Owner, Repository); + + NuGetVersion? latest = null; + foreach (Release release in releases) + { + if (release.Draft || release.Prerelease || + !release.Assets.Any(asset => asset.Name == AssetName)) + { + continue; + } + + // Tags look like "v2.0.1.10". The manifest stores the part after the "v". + string versionText = release.TagName.StartsWith('v') + ? release.TagName[1..] + : release.TagName; + if (!NuGetVersion.TryParse(versionText, out NuGetVersion? version) || version.IsPrerelease) + { + throw new InvalidOperationException( + $"Release {release.TagName} has {AssetName} but its tag is not a stable version."); + } + + if (latest is null || version > latest) + { + latest = version; + } + } + + if (latest is null) + { + throw new InvalidOperationException( + $"No IIS.ServiceMonitor release has a {AssetName} asset."); + } + + return latest.OriginalVersion ?? latest.ToNormalizedString(); + } +} diff --git a/eng/update-dependencies/UpdateCatalog/UpdateCatalogClient.cs b/eng/update-dependencies/UpdateCatalog/UpdateCatalogClient.cs new file mode 100644 index 00000000..9f612f64 --- /dev/null +++ b/eng/update-dependencies/UpdateCatalog/UpdateCatalogClient.cs @@ -0,0 +1,115 @@ +// Licensed to the .NET Foundation under one or more agreements. +// The .NET Foundation licenses this file to you under the MIT license. +// See the LICENSE file in the project root for more information. + +using System.Text.Json; +using System.Text.RegularExpressions; + +namespace Microsoft.DotNet.Framework.UpdateDependencies.UpdateCatalog; + +/// +/// Client for the Microsoft Update Catalog website, which has no API. It +/// requests the same pages a browser does and reads the results from them. +/// +internal sealed partial class UpdateCatalogClient(HttpClient httpClient) +{ + private const string CatalogUrl = "https://www.catalog.update.microsoft.com"; + + // Shown instead of the results table when a search finds nothing. + private const string NoResultsText = "We did not find any results for"; + + // camelCase names, as the download dialog expects. + private static readonly JsonSerializerOptions s_jsonOptions = new(JsonSerializerDefaults.Web); + + /// + /// Searches the catalog, e.g. for a KB number. Returns an empty list if + /// nothing matches. + /// + public async Task> SearchAsync(string query) + { + using var request = new HttpRequestMessage( + HttpMethod.Get, + $"{CatalogUrl}/Search.aspx?q={Uri.EscapeDataString(query)}"); + + string html = await SendAsync(request); + + List updates = SearchResultPattern.Matches(html) + .Select(match => new CatalogUpdate(Id: match.Groups["id"].Value, Title: match.Groups["title"].Value)) + .ToList(); + + // The catalog reports errors as an ordinary page with a 200 status. + if (updates.Count == 0 && !html.Contains(NoResultsText)) + { + throw new InvalidOperationException( + $"The Update Catalog returned an unexpected page for search '{query}'."); + } + + return updates; + } + + /// + /// Gets the URLs of the files that can be downloaded for an update. + /// + public async Task> GetDownloadUrlsAsync(CatalogUpdate update) + { + // The dialog takes a list of updates to download. + UpdateToDownload[] updatesToDownload = [new UpdateToDownload(update.Id)]; + string updateIDsJson = JsonSerializer.Serialize(updatesToDownload, s_jsonOptions); + + var form = new Dictionary { ["updateIDs"] = updateIDsJson }; + + using var request = new HttpRequestMessage(HttpMethod.Post, $"{CatalogUrl}/DownloadDialog.aspx") + { + Content = new FormUrlEncodedContent(form), + }; + + string dialogHtml = await SendAsync(request); + + List urls = DownloadUrlPattern.Matches(dialogHtml) + .Select(match => match.Groups["url"].Value) + .ToList(); + + // An update that exists always has at least one file, so a page + // without URLs is an error page. + if (urls.Count == 0) + { + throw new InvalidOperationException( + $"The Update Catalog returned no download URLs for '{update.Title}' ({update.Id})."); + } + + return urls; + } + + private async Task SendAsync(HttpRequestMessage request) + { + // The titles we match on are in English. + request.Headers.AcceptLanguage.ParseAdd("en-US"); + + using HttpResponseMessage response = await httpClient.SendAsync(request); + response.EnsureSuccessStatusCode(); + + return await response.Content.ReadAsStringAsync(); + } + + // Matches the link to an update's details in a search result row: + // {title} + [GeneratedRegex(@"goToDetails\(""(?[^""]+)""\);'[^>]*>\s*(?[^<]+?)\s*</a>")] + private static partial Regex SearchResultPattern { get; } + + // Matches a file URL in the script embedded in the download dialog's page: + // downloadInformation[0].files[0].url = '{url}'; + [GeneratedRegex(@"downloadInformation\[\d+\]\.files\[\d+\]\.url\s*=\s*'(?<url>[^']+)'")] + private static partial Regex DownloadUrlPattern { get; } + + // An update to download. The dialog expects its ID twice, and a size. + private sealed record UpdateToDownload(string UpdateID) + { + public string Uid => UpdateID; + public int Size => 0; + } +} + +/// <summary> +/// An update in the Update Catalog. +/// </summary> +internal sealed record CatalogUpdate(string Id, string Title);