using System.Diagnostics; using System.IO.Compression; using System.Net.Http; using System.Runtime.InteropServices; using System.Security.Cryptography; using System.Text.Json; namespace XFEToolBox.Client.Core.Tools; public sealed record ToolPreparationProgress(string Message, double? Percentage = null); public sealed record ToolchainInstallation(string Root, string SdkVersion, string RuntimeVersion, string RuntimeIdentifier) { public string DotNetPath => Path.Combine(Root, "dotnet.exe"); } /// Downloads a portable Windows SDK, including the WPF runtime, without changing the system installation. public sealed class ToolchainManager { private const long MaximumDownloadBytes = 1024L * 1024 * 1024; private const long MaximumExpandedBytes = 4L * 1024 * 1024 * 1024; private const string MetadataUrl = "https://builds.dotnet.microsoft.com/dotnet/release-metadata/10.0/releases.json"; private static readonly HttpClient SharedClient = new() { Timeout = Timeout.InfiniteTimeSpan }; public static ToolchainManager Default { get; } = new( Path.Combine(Environment.GetFolderPath(Environment.SpecialFolder.LocalApplicationData), "XFEToolBox", "Toolchains"), GetRuntimeIdentifier(), SharedClient); private readonly HttpClient _client; private readonly string _runtimeIdentifier; public string CacheRoot { get; } public ToolchainManager(string cacheRoot, string runtimeIdentifier, HttpClient client) { if (runtimeIdentifier is not ("win-x64" or "win-arm64" or "win-x86")) throw new PlatformNotSupportedException("工具运行组件不支持当前处理器架构。"); CacheRoot = Path.Combine(Path.GetFullPath(cacheRoot), "net10", runtimeIdentifier); _runtimeIdentifier = runtimeIdentifier; _client = client; } public async Task EnsureInstalledAsync( IProgress? progress = null, CancellationToken cancellationToken = default) { cancellationToken.ThrowIfCancellationRequested(); Directory.CreateDirectory(CacheRoot); if (FindInstallation() is { } existing) return existing; progress?.Report(new("正在准备工具运行组件,首次使用需要下载…")); // File sharing also serializes downloads from separate toolbox processes. await using var installationLock = await AcquireLockAsync(cancellationToken).ConfigureAwait(false); if (FindInstallation() is { } installedByAnotherProcess) return installedByAnotherProcess; var stagingRoot = Path.Combine(CacheRoot, ".staging-" + Guid.NewGuid().ToString("N")); Directory.CreateDirectory(stagingRoot); try { using var timeout = CancellationTokenSource.CreateLinkedTokenSource(cancellationToken); timeout.CancelAfter(TimeSpan.FromMinutes(30)); var token = timeout.Token; var release = await GetReleaseAsync(token).ConfigureAwait(false); var archivePath = Path.Combine(stagingRoot, "sdk.zip"); await DownloadAsync(release, archivePath, progress, token).ConfigureAwait(false); var sdkRoot = Path.Combine(stagingRoot, "sdk"); progress?.Report(new("正在解压工具运行组件…")); await Task.Run(() => ExtractAsync(archivePath, sdkRoot, token), token).ConfigureAwait(false); var installation = new ToolchainInstallation(sdkRoot, release.SdkVersion, release.RuntimeVersion, _runtimeIdentifier); if (!IsComplete(installation)) throw new InvalidDataException("下载的工具运行组件不完整,请重试。"); await File.WriteAllTextAsync(Path.Combine(sdkRoot, "toolchain.json"), JsonSerializer.Serialize(new CachedToolchain(release.SdkVersion, release.RuntimeVersion, _runtimeIdentifier)), token).ConfigureAwait(false); token.ThrowIfCancellationRequested(); var destination = Path.Combine(CacheRoot, $"sdk-{release.SdkVersion}-{Guid.NewGuid():N}"); Directory.Move(sdkRoot, destination); progress?.Report(new("工具运行组件已就绪", 100)); return installation with { Root = destination }; } catch (OperationCanceledException) when (!cancellationToken.IsCancellationRequested) { throw new IOException("下载工具运行组件超时,请检查网络后重试。"); } catch (HttpRequestException exception) { throw new IOException("无法下载工具运行组件,请检查网络后重试。" + exception.Message, exception); } finally { TryDeleteDirectory(stagingRoot); } } public string CreateRunDirectory() { // The apphost embeds a relative path to the private runtime, so both must be on the same volume. var directory = Path.Combine(CacheRoot, "runs", Guid.NewGuid().ToString("N")); Directory.CreateDirectory(directory); return directory; } public void ConfigureBuildEnvironment(ProcessStartInfo startInfo, ToolchainInstallation installation) { foreach (var key in startInfo.Environment.Keys.Where(key => key.StartsWith("DOTNET_ROOT", StringComparison.OrdinalIgnoreCase) || key.StartsWith("DOTNET_MSBUILD_SDK_RESOLVER_", StringComparison.OrdinalIgnoreCase) || key.StartsWith("MSBUILD", StringComparison.OrdinalIgnoreCase)).ToArray()) startInfo.Environment.Remove(key); startInfo.Environment["DOTNET_ROOT"] = installation.Root; startInfo.Environment["DOTNET_HOST_PATH"] = installation.DotNetPath; startInfo.Environment["PATH"] = installation.Root + Path.PathSeparator + startInfo.Environment["PATH"]; startInfo.Environment["DOTNET_MULTILEVEL_LOOKUP"] = "0"; startInfo.Environment["DOTNET_CLI_HOME"] = Path.Combine(CacheRoot, "cli-home"); startInfo.Environment["DOTNET_CLI_TELEMETRY_OPTOUT"] = "1"; startInfo.Environment["DOTNET_SKIP_FIRST_TIME_EXPERIENCE"] = "1"; startInfo.Environment["DOTNET_ADD_GLOBAL_TOOLS_TO_PATH"] = "false"; startInfo.Environment["DOTNET_CLI_WORKLOAD_UPDATE_NOTIFY_DISABLE"] = "true"; startInfo.Environment["MSBUILDDISABLENODEREUSE"] = "1"; startInfo.Environment["NUGET_PACKAGES"] = Path.Combine(CacheRoot, "nuget", "packages"); startInfo.Environment["NUGET_HTTP_CACHE_PATH"] = Path.Combine(CacheRoot, "nuget", "http-cache"); } private ToolchainInstallation? FindInstallation() { foreach (var root in Directory.EnumerateDirectories(CacheRoot, "sdk-*")) { try { var manifest = JsonSerializer.Deserialize(File.ReadAllText(Path.Combine(root, "toolchain.json"))); if (manifest is null || manifest.RuntimeIdentifier != _runtimeIdentifier) continue; var installation = new ToolchainInstallation(root, manifest.SdkVersion, manifest.RuntimeVersion, manifest.RuntimeIdentifier); if (IsComplete(installation)) return installation; } catch (Exception exception) when (exception is IOException or UnauthorizedAccessException or JsonException) { // An interrupted or incomplete installation is never used for compilation. } } return null; } private static bool IsComplete(ToolchainInstallation installation) { if (!IsStableVersion(installation.SdkVersion) || !IsStableVersion(installation.RuntimeVersion)) return false; string[] requiredFiles = [ "dotnet.exe", $"sdk/{installation.SdkVersion}/MSBuild.dll", $"sdk/{installation.SdkVersion}/Roslyn/bincore/csc.dll", $"sdk/{installation.SdkVersion}/Sdks/Microsoft.NET.Sdk/targets/Microsoft.NET.Sdk.targets", $"sdk/{installation.SdkVersion}/Sdks/Microsoft.NET.Sdk.WindowsDesktop/targets/Microsoft.NET.Sdk.WindowsDesktop.targets", $"host/fxr/{installation.RuntimeVersion}/hostfxr.dll", $"shared/Microsoft.NETCore.App/{installation.RuntimeVersion}/coreclr.dll", $"shared/Microsoft.WindowsDesktop.App/{installation.RuntimeVersion}/PresentationFramework.dll", $"packs/Microsoft.NETCore.App.Ref/{installation.RuntimeVersion}/ref/net10.0/System.Runtime.dll", $"packs/Microsoft.WindowsDesktop.App.Ref/{installation.RuntimeVersion}/ref/net10.0/PresentationFramework.dll" ]; return requiredFiles.All(file => new FileInfo(Path.Combine(installation.Root, file)) is { Exists: true, Length: > 0 }); } private async Task AcquireLockAsync(CancellationToken cancellationToken) { while (true) { cancellationToken.ThrowIfCancellationRequested(); try { return new FileStream(Path.Combine(CacheRoot, "install.lock"), FileMode.OpenOrCreate, FileAccess.ReadWrite, FileShare.None); } catch (IOException exception) when ((exception.HResult & 0xffff) is 32 or 33) { await Task.Delay(300, cancellationToken).ConfigureAwait(false); } } } private async Task GetReleaseAsync(CancellationToken cancellationToken) { using var timeout = CancellationTokenSource.CreateLinkedTokenSource(cancellationToken); timeout.CancelAfter(TimeSpan.FromSeconds(60)); using var response = await _client.GetAsync(MetadataUrl, HttpCompletionOption.ResponseHeadersRead, timeout.Token).ConfigureAwait(false); response.EnsureSuccessStatusCode(); await using var stream = await response.Content.ReadAsStreamAsync(timeout.Token).ConfigureAwait(false); using var metadata = new MemoryStream(); await CopyBoundedAsync(stream, metadata, 8 * 1024 * 1024, timeout.Token).ConfigureAwait(false); using var document = JsonDocument.Parse(metadata.ToArray()); var latest = document.RootElement.GetProperty("latest-sdk").GetString(); foreach (var release in document.RootElement.GetProperty("releases").EnumerateArray()) { foreach (var sdk in release.GetProperty("sdks").EnumerateArray()) { var version = sdk.GetProperty("version").GetString(); var runtime = sdk.GetProperty("runtime-version").GetString(); if (version != latest || !IsStableVersion(version) || !IsStableVersion(runtime)) continue; foreach (var file in sdk.GetProperty("files").EnumerateArray()) { if (file.GetProperty("rid").GetString() != _runtimeIdentifier || !file.GetProperty("name").GetString()!.EndsWith(".zip", StringComparison.OrdinalIgnoreCase)) continue; var url = new Uri(file.GetProperty("url").GetString()!); var hash = file.GetProperty("hash").GetString()!; if (url.Scheme != Uri.UriSchemeHttps || url.Host is not ("builds.dotnet.microsoft.com" or "download.visualstudio.microsoft.com" or "dotnetcli.azureedge.net") || !url.AbsolutePath.EndsWith(".zip", StringComparison.OrdinalIgnoreCase) || hash.Length != 128 || !hash.All(Uri.IsHexDigit)) throw new InvalidDataException("工具运行组件下载地址或校验信息无效。"); return new SdkRelease(version!, runtime!, url, hash); } } } throw new InvalidDataException("未找到适合当前设备的 .NET 10 工具运行组件,请稍后重试。"); } private async Task DownloadAsync(SdkRelease release, string path, IProgress? progress, CancellationToken token) { using var response = await _client.GetAsync(release.Url, HttpCompletionOption.ResponseHeadersRead, token).ConfigureAwait(false); response.EnsureSuccessStatusCode(); var total = response.Content.Headers.ContentLength; if (total > MaximumDownloadBytes) throw new InvalidDataException("工具运行组件下载大小超过限制。"); await using var input = await response.Content.ReadAsStreamAsync(token).ConfigureAwait(false); await using var output = new FileStream(path, FileMode.CreateNew, FileAccess.Write, FileShare.None, 81920, FileOptions.Asynchronous); using var hash = IncrementalHash.CreateHash(HashAlgorithmName.SHA512); var buffer = new byte[81920]; long received = 0; var clock = Stopwatch.StartNew(); progress?.Report(new("正在下载工具运行组件…", total > 0 ? 0 : null)); while (await input.ReadAsync(buffer, token).ConfigureAwait(false) is var count && count > 0) { received += count; if (received > MaximumDownloadBytes) throw new InvalidDataException("工具运行组件下载大小超过限制。"); hash.AppendData(buffer, 0, count); await output.WriteAsync(buffer.AsMemory(0, count), token).ConfigureAwait(false); if (clock.ElapsedMilliseconds < 200) continue; clock.Restart(); var size = total > 0 ? $"{received / 1048576d:0.0} / {total / 1048576d:0.0} MB" : $"{received / 1048576d:0.0} MB"; progress?.Report(new($"正在下载工具运行组件 · {size}", total > 0 ? received * 100d / total.Value : null)); } if ((total is not null && received != total.Value) || !CryptographicOperations.FixedTimeEquals(hash.GetHashAndReset(), Convert.FromHexString(release.Sha512))) throw new InvalidDataException("工具运行组件校验失败,请重试下载。"); } private static async Task ExtractAsync(string archivePath, string destination, CancellationToken token) { Directory.CreateDirectory(destination); using var archive = ZipFile.OpenRead(archivePath); if (archive.Entries.Count > 50000) throw new InvalidDataException("工具运行组件文件数量超过限制。"); long expanded = 0; foreach (var entry in archive.Entries) { token.ThrowIfCancellationRequested(); var relative = entry.FullName.Replace('\\', '/'); var segments = relative.TrimEnd('/').Split('/'); if (segments.Any(segment => string.IsNullOrWhiteSpace(segment) || segment is "." or ".." || segment.IndexOfAny(Path.GetInvalidFileNameChars()) >= 0 || segment.EndsWith('.') || segment.EndsWith(' ')) || ((entry.ExternalAttributes >> 16) & 0xf000) == 0xa000 || (entry.ExternalAttributes & (int)FileAttributes.ReparsePoint) != 0) throw new InvalidDataException("工具运行组件包含不安全的文件路径。"); var path = Path.GetFullPath(Path.Combine(destination, Path.Combine(segments))); if (!path.StartsWith(destination + Path.DirectorySeparatorChar, StringComparison.OrdinalIgnoreCase)) throw new InvalidDataException("工具运行组件包含越界路径。"); expanded = checked(expanded + entry.Length); if (expanded > MaximumExpandedBytes) throw new InvalidDataException("工具运行组件解压大小超过限制。"); if (relative.EndsWith('/')) { Directory.CreateDirectory(path); continue; } Directory.CreateDirectory(Path.GetDirectoryName(path)!); await using var source = entry.Open(); await using var target = new FileStream(path, FileMode.CreateNew, FileAccess.Write, FileShare.None, 81920, FileOptions.Asynchronous); await CopyBoundedAsync(source, target, entry.Length, token).ConfigureAwait(false); } } private static async Task CopyBoundedAsync(Stream source, Stream destination, long limit, CancellationToken token) { var buffer = new byte[81920]; long copied = 0; while (await source.ReadAsync(buffer, token).ConfigureAwait(false) is var count && count > 0) { copied += count; if (copied > limit) throw new InvalidDataException("工具运行组件数据大小超过限制。"); await destination.WriteAsync(buffer.AsMemory(0, count), token).ConfigureAwait(false); } } private static bool IsStableVersion(string? value) => Version.TryParse(value, out var version) && version.Major == 10 && version.Minor == 0 && version.Build >= 0 && version.Revision == -1 && value == version.ToString(); private static string GetRuntimeIdentifier() => RuntimeInformation.ProcessArchitecture switch { Architecture.Arm64 => "win-arm64", Architecture.X86 => "win-x86", _ => "win-x64" }; private static void TryDeleteDirectory(string path) { try { if (Directory.Exists(path)) Directory.Delete(path, recursive: true); } catch (IOException) { } catch (UnauthorizedAccessException) { } } private sealed record SdkRelease(string SdkVersion, string RuntimeVersion, Uri Url, string Sha512); private sealed record CachedToolchain(string SdkVersion, string RuntimeVersion, string RuntimeIdentifier); }