using System.Net; using System.Net.Http.Headers; using System.Security.Cryptography; using System.Text.Json; namespace Shigure; internal sealed record SpellIconDownloadProgress(string Message, int? Percentage = null); internal sealed record SpellIconDownloadResult( bool Changed, bool UpToDate, long Size, string Sha256); internal sealed class SpellIconPackageDownloadService : IDisposable { internal const string LatestReleaseApiUrl = "https://api.github.com/repos/waynebian01/Shigure/releases/latest"; internal const string LatestBrowserDownloadUrl = "https://github.com/waynebian01/Shigure/releases/latest/download/SpellIcons.shgpack"; internal const string ReleasesPageUrl = "https://github.com/waynebian01/Shigure/releases"; private const string ReleasesApiUrl = "https://api.github.com/repos/waynebian01/Shigure/releases?per_page=20"; private const string AssetName = "SpellIcons.shgpack"; private static readonly TimeSpan ApiRequestTimeout = TimeSpan.FromSeconds(15); private readonly HttpClient _httpClient; public SpellIconPackageDownloadService() { _httpClient = new HttpClient { Timeout = Timeout.InfiniteTimeSpan }; _httpClient.DefaultRequestHeaders.UserAgent.Add(new ProductInfoHeaderValue("Shigure", "1.0")); } public async Task UpdateAsync( IProgress? progress, CancellationToken cancellationToken) { // UpdateAsync is invoked by MainForm. Preserve that UI context so the final hot swap // cannot dispose images while a DataGridView is painting them on the UI thread. var asset = await ResolveLatestAssetAsync(progress, cancellationToken); var localPath = SpellIconCatalog.PackagePath; if (File.Exists(localPath) && SpellIconCatalog.IsPackageAvailable) { if (asset.Sha256 is not null) { progress?.Report(new SpellIconDownloadProgress("正在比较本地与远端 SHA-256……")); var localHash = await ComputeSha256Async(localPath, cancellationToken); if (string.Equals(localHash, asset.Sha256, StringComparison.OrdinalIgnoreCase)) { return new SpellIconDownloadResult( Changed: false, UpToDate: true, asset.Size ?? new FileInfo(localPath).Length, localHash); } } else if (asset.Size is { } remoteSize && new FileInfo(localPath).Length == remoteSize) { progress?.Report(new SpellIconDownloadProgress("正在比较本地与远端文件大小……")); var localHash = await ComputeSha256Async(localPath, cancellationToken); return new SpellIconDownloadResult( Changed: false, UpToDate: true, remoteSize, localHash); } } var dataDirectory = Path.GetDirectoryName(localPath) ?? throw new InvalidOperationException("无法确定技能图标数据目录。"); Directory.CreateDirectory(dataDirectory); var temporaryPath = Path.Combine( dataDirectory, $".{AssetName}.{Guid.NewGuid():N}.download"); try { var (downloadedHash, downloadedSize) = await DownloadAsync( asset, temporaryPath, progress, cancellationToken); if (asset.Sha256 is not null && !string.Equals(downloadedHash, asset.Sha256, StringComparison.OrdinalIgnoreCase)) { throw new InvalidDataException( $"数据包 SHA-256 校验失败:远端 {asset.Sha256},下载结果 {downloadedHash}。"); } progress?.Report(new SpellIconDownloadProgress("正在验证并安装数据包……", 100)); SpellIconCatalog.ValidatePackage(temporaryPath); SpellIconCatalog.InstallPackage(temporaryPath); return new SpellIconDownloadResult( Changed: true, UpToDate: false, downloadedSize, downloadedHash); } finally { TryDeleteFile(temporaryPath); } } private async Task ResolveLatestAssetAsync( IProgress? progress, CancellationToken cancellationToken) { var errors = new List(); progress?.Report(new SpellIconDownloadProgress("正在读取 GitHub 最新正式版本……")); if (await TryResolveAsync( () => GetAssetFromApiReleaseAsync(new Uri(LatestReleaseApiUrl), cancellationToken), errors, cancellationToken) is { } latest) { return latest; } progress?.Report(new SpellIconDownloadProgress("正在改用 GitHub 发布列表……")); if (await TryResolveAsync( () => GetAssetFromApiReleaseListAsync(cancellationToken), errors, cancellationToken) is { } listed) { return listed; } progress?.Report(new SpellIconDownloadProgress("正在改用 GitHub 发布页直链……")); if (await TryResolveAsync( () => GetAssetFromBrowserDownloadAsync(new Uri(LatestBrowserDownloadUrl), cancellationToken), errors, cancellationToken) is { } browserLatest) { return browserLatest; } var versionTag = CurrentReleaseTag; progress?.Report(new SpellIconDownloadProgress($"正在改用当前版本 {versionTag} 发布页……")); if (await TryResolveAsync( () => GetAssetFromBrowserDownloadAsync(GetCurrentVersionDownloadUri(), cancellationToken), errors, cancellationToken) is { } versioned) { return versioned; } throw new HttpRequestException( "无法从 GitHub 获取技能/物品数据包。" + Environment.NewLine + string.Join(Environment.NewLine, errors)); } private static async Task TryResolveAsync( Func> resolve, List errors, CancellationToken cancellationToken) { try { return await resolve().ConfigureAwait(false); } catch (OperationCanceledException) when (!cancellationToken.IsCancellationRequested) { errors.Add("GitHub 请求超时。"); return null; } catch (Exception ex) when (ex is HttpRequestException or InvalidDataException or FileNotFoundException or JsonException or IOException) { errors.Add(ex.Message); return null; } } private async Task GetAssetFromApiReleaseAsync( Uri uri, CancellationToken cancellationToken) { using var response = await SendApiAsync(uri, cancellationToken).ConfigureAwait(false); await using var stream = await response.Content.ReadAsStreamAsync(cancellationToken) .ConfigureAwait(false); using var document = await JsonDocument.ParseAsync(stream, cancellationToken: cancellationToken) .ConfigureAwait(false); if (!TryReadAssetFromRelease(document.RootElement, out var asset)) { throw new FileNotFoundException($"GitHub 最新正式版本中找不到资产 {AssetName}。"); } return asset; } private async Task GetAssetFromApiReleaseListAsync(CancellationToken cancellationToken) { using var response = await SendApiAsync(new Uri(ReleasesApiUrl), cancellationToken) .ConfigureAwait(false); await using var stream = await response.Content.ReadAsStreamAsync(cancellationToken) .ConfigureAwait(false); using var document = await JsonDocument.ParseAsync(stream, cancellationToken: cancellationToken) .ConfigureAwait(false); if (document.RootElement.ValueKind != JsonValueKind.Array) { throw new InvalidDataException("GitHub 发布列表格式无效。"); } ReleaseAsset? prereleaseAsset = null; foreach (var release in document.RootElement.EnumerateArray()) { if (release.TryGetProperty("draft", out var draft) && draft.ValueKind == JsonValueKind.True) { continue; } if (!TryReadAssetFromRelease(release, out var asset)) { continue; } var isPrerelease = release.TryGetProperty("prerelease", out var pre) && pre.ValueKind == JsonValueKind.True; if (!isPrerelease) { return asset; } prereleaseAsset ??= asset; } return prereleaseAsset ?? throw new FileNotFoundException($"GitHub 发布列表中找不到资产 {AssetName}。"); } private async Task GetAssetFromBrowserDownloadAsync( Uri uri, CancellationToken cancellationToken) { try { using var timeoutCts = CancellationTokenSource.CreateLinkedTokenSource(cancellationToken); timeoutCts.CancelAfter(ApiRequestTimeout); using var request = new HttpRequestMessage(HttpMethod.Head, uri); using var response = await _httpClient.SendAsync( request, HttpCompletionOption.ResponseHeadersRead, timeoutCts.Token) .ConfigureAwait(false); if (response.StatusCode is HttpStatusCode.NotFound or HttpStatusCode.Gone) { var url = response.RequestMessage?.RequestUri?.ToString() ?? uri.ToString(); throw new HttpRequestException( $"检查数据包失败:HTTP {(int)response.StatusCode} {response.ReasonPhrase}({url})"); } if (response.IsSuccessStatusCode && response.Content.Headers.ContentLength is { } size && size > 0) { return new ReleaseAsset(uri, size, Sha256: null); } } catch (OperationCanceledException) when (!cancellationToken.IsCancellationRequested) { // HEAD 超时:仍尝试正式下载该直链。 } catch (IOException) { // 网络抖动:仍尝试正式下载该直链。 } // 405/403 等:部分 CDN 不支持 HEAD,改由正式下载时读取 Content-Length。 return new ReleaseAsset(uri, Size: null, Sha256: null); } private static bool TryReadAssetFromRelease(JsonElement release, out ReleaseAsset asset) { asset = null!; if (!release.TryGetProperty("assets", out var assets) || assets.ValueKind != JsonValueKind.Array) { return false; } foreach (var item in assets.EnumerateArray()) { if (!item.TryGetProperty("name", out var nameElement) || !string.Equals(nameElement.GetString(), AssetName, StringComparison.Ordinal)) { continue; } var state = item.TryGetProperty("state", out var stateElement) ? stateElement.GetString() : null; if (!string.Equals(state, "uploaded", StringComparison.Ordinal)) { continue; } if (!item.TryGetProperty("size", out var sizeElement) || !sizeElement.TryGetInt64(out var size) || size <= 0) { continue; } var downloadUrl = item.TryGetProperty("browser_download_url", out var urlElement) ? urlElement.GetString() : null; if (!Uri.TryCreate(downloadUrl, UriKind.Absolute, out var uri) || !string.Equals(uri.Scheme, Uri.UriSchemeHttps, StringComparison.OrdinalIgnoreCase)) { continue; } string? sha256 = null; if (item.TryGetProperty("digest", out var digestElement)) { var digest = digestElement.GetString(); const string prefix = "sha256:"; if (!string.IsNullOrWhiteSpace(digest) && digest.StartsWith(prefix, StringComparison.OrdinalIgnoreCase)) { var value = digest[prefix.Length..].Trim(); if (value.Length == 64 && value.All(Uri.IsHexDigit)) { sha256 = value.ToUpperInvariant(); } } } asset = new ReleaseAsset(uri, size, sha256); return true; } return false; } private async Task<(string Sha256, long Size)> DownloadAsync( ReleaseAsset asset, string destination, IProgress? progress, CancellationToken cancellationToken) { using var response = await SendDownloadAsync(HttpMethod.Get, asset.DownloadUrl, cancellationToken) .ConfigureAwait(false); long? expectedSize = asset.Size; if (response.Content.Headers.ContentLength is { } contentLength) { if (expectedSize is { } size && contentLength != size) { throw new InvalidDataException( $"数据包大小与 GitHub 资产信息不一致:预计 {size},实际 {contentLength}。"); } expectedSize = contentLength; } await using var input = await response.Content.ReadAsStreamAsync(cancellationToken) .ConfigureAwait(false); await using var output = new FileStream( destination, FileMode.CreateNew, FileAccess.Write, FileShare.None, 1024 * 1024, FileOptions.Asynchronous | FileOptions.SequentialScan); using var hash = IncrementalHash.CreateHash(HashAlgorithmName.SHA256); var buffer = new byte[1024 * 1024]; long downloaded = 0; var lastPercentage = -1; int read; while ((read = await input.ReadAsync(buffer, cancellationToken).ConfigureAwait(false)) > 0) { await output.WriteAsync(buffer.AsMemory(0, read), cancellationToken).ConfigureAwait(false); hash.AppendData(buffer, 0, read); downloaded += read; if (expectedSize is { } size && downloaded > size) { throw new InvalidDataException("下载的数据超过 GitHub 声明的资产大小。"); } if (expectedSize is { } total and > 0) { var percentage = (int)Math.Min(100, downloaded * 100 / total); if (percentage != lastPercentage) { lastPercentage = percentage; progress?.Report(new SpellIconDownloadProgress( $"正在下载:{FormatBytes(downloaded)} / {FormatBytes(total)}", percentage)); } } else { progress?.Report(new SpellIconDownloadProgress( $"正在下载:{FormatBytes(downloaded)}")); } } await output.FlushAsync(cancellationToken).ConfigureAwait(false); if (downloaded <= 0) { throw new InvalidDataException("数据包下载结果为空。"); } if (expectedSize is { } expected && downloaded != expected) { throw new InvalidDataException( $"数据包下载不完整:预计 {expected} 字节,实际 {downloaded} 字节。"); } return (Convert.ToHexString(hash.GetHashAndReset()), downloaded); } private async Task SendApiAsync(Uri uri, CancellationToken cancellationToken) { using var timeoutCts = CancellationTokenSource.CreateLinkedTokenSource(cancellationToken); timeoutCts.CancelAfter(ApiRequestTimeout); using var request = new HttpRequestMessage(HttpMethod.Get, uri); request.Headers.Accept.Add(new MediaTypeWithQualityHeaderValue("application/vnd.github+json")); request.Headers.TryAddWithoutValidation("X-GitHub-Api-Version", "2022-11-28"); var response = await _httpClient.SendAsync( request, HttpCompletionOption.ResponseHeadersRead, timeoutCts.Token) .ConfigureAwait(false); EnsureSuccess(response, "读取 GitHub 版本信息"); return response; } private async Task SendDownloadAsync( HttpMethod method, Uri uri, CancellationToken cancellationToken) { using var request = new HttpRequestMessage(method, uri); var response = await _httpClient.SendAsync( request, HttpCompletionOption.ResponseHeadersRead, cancellationToken) .ConfigureAwait(false); EnsureSuccess(response, method == HttpMethod.Head ? "检查数据包" : "下载数据包"); return response; } private static void EnsureSuccess(HttpResponseMessage response, string action) { if (response.IsSuccessStatusCode) { return; } using (response) { var url = response.RequestMessage?.RequestUri?.ToString() ?? string.Empty; throw new HttpRequestException( $"{action}失败:HTTP {(int)response.StatusCode} {response.ReasonPhrase}({url})"); } } internal static string CurrentReleaseTag { get { var version = typeof(SpellIconPackageDownloadService).Assembly.GetName().Version; if (version is null || version.Build < 0 || version.Revision < 0) { return "1.2.1.20"; } return $"{version.Major}.{version.Minor}.{version.Build}.{version.Revision}"; } } internal static Uri GetCurrentVersionDownloadUri() => new($"https://github.com/waynebian01/Shigure/releases/download/{CurrentReleaseTag}/{AssetName}"); private static async Task ComputeSha256Async( string path, CancellationToken cancellationToken) { await using var stream = new FileStream( path, FileMode.Open, FileAccess.Read, FileShare.Read, 1024 * 1024, FileOptions.Asynchronous | FileOptions.SequentialScan); var digest = await SHA256.HashDataAsync(stream, cancellationToken).ConfigureAwait(false); return Convert.ToHexString(digest); } private static string FormatBytes(long value) { string[] units = ["B", "KiB", "MiB", "GiB"]; double size = value; var unit = 0; while (size >= 1024 && unit < units.Length - 1) { size /= 1024; unit++; } return $"{size:0.##} {units[unit]}"; } private static void TryDeleteFile(string path) { try { File.Delete(path); } catch (Exception ex) when (ex is IOException or UnauthorizedAccessException) { // 下次运行不会读取 .download 文件。 } } public void Dispose() => _httpClient.Dispose(); private sealed record ReleaseAsset(Uri DownloadUrl, long? Size, string? Sha256); }