XFE Git
XFE Studio Git
Git 首页 全局搜索
XFE 主站 文档 NuGet

XFEToolBox

【WPF】XFE工具箱

公开
关注 0 Fork 0 Star 0
UTF-8
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");
}

/// <summary>Downloads a portable Windows SDK, including the WPF runtime, without changing the system installation.</summary>
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<ToolchainInstallation> EnsureInstalledAsync(
        IProgress<ToolPreparationProgress>? 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<CachedToolchain>(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<FileStream> 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<SdkRelease> 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<ToolPreparationProgress>? 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);
}