using System.Diagnostics; using System.IO; using System.Net.Http; using System.Net.Http.Json; using System.Security.Cryptography; using System.Windows; using System.Windows.Media; using CommunityToolkit.Mvvm.ComponentModel; using CommunityToolkit.Mvvm.Input; using XFEToolBox.Client.Model; using XFEToolBox.Client.Models; using XFEToolBox.Client.Profiles.CrossVersionProfiles; using XFEToolBox.Client.Utilities; using XFEToolBox.Client.Utilities.Server; using XFEToolBox.Client.Views.Pages; using XFEToolBox.Client.Views.Pages.Popups; using XFEToolBox.Core.Downloads; namespace XFEToolBox.Client.ViewModel.Pages; public partial class DownloadInfoPageViewModel : ObservableObject { private static readonly HttpClient DownloadClient = CreateDownloadClient(); private readonly SoftwareCatalogItem _software; public DownloadInfoPageViewModel(DownloadInfoPage viewPage, SoftwareCatalogItem software, ImageSource iconSource) { ViewPage = viewPage; _software = software; IconSource = iconSource; Channels = software.GetEffectiveChannels().Where(channel => channel.Enabled).ToArray(); selectedChannel = Channels.FirstOrDefault() ?? new SoftwareDownloadChannel { Name = "暂无可用渠道", Enabled = false }; DownloadButtonName = GetDownloadButtonName(selectedChannel); CanDownload = selectedChannel.Enabled; } public DownloadInfoPage ViewPage { get; } public string AppTitle => _software.Name; public string Summary => _software.Summary; public string Description => _software.Description; public string Publisher => _software.Publisher; public string Category => _software.Category; public string Version => _software.Version; public IReadOnlyList Channels { get; } public string DownloadModeText => SelectedChannel.Mode switch { SoftwareDownloadMode.Direct => "客户端直接下载", SoftwareDownloadMode.Server => "工具服务器下载", _ => "浏览器获取" }; public string SelectedChannelName => SelectedChannel.Name; public string Notice => _software.Notice; public Visibility NoticeVisibility => string.IsNullOrWhiteSpace(_software.Notice) ? Visibility.Collapsed : Visibility.Visible; public Visibility WebsiteVisibility => IsWebAddress(_software.WebsiteUrl) ? Visibility.Visible : Visibility.Collapsed; [ObservableProperty] private ImageSource iconSource; [ObservableProperty] private SoftwareDownloadChannel selectedChannel; [ObservableProperty] private string downloadButtonName; [ObservableProperty] private string statusText = "确认信息后即可开始获取。"; [ObservableProperty] private double progressValue; [ObservableProperty] private bool isProgressIndeterminate; [ObservableProperty] private Visibility progressVisibility = Visibility.Collapsed; [ObservableProperty] private bool canDownload = true; partial void OnSelectedChannelChanged(SoftwareDownloadChannel value) { DownloadButtonName = GetDownloadButtonName(value); CanDownload = value.Enabled; StatusText = value.Enabled ? $"已选择“{value.Name}”,确认后即可开始获取。" : "该渠道当前不可用。"; OnPropertyChanged(nameof(DownloadModeText)); OnPropertyChanged(nameof(SelectedChannelName)); } [RelayCommand] private void OpenWebsite() { if (IsWebAddress(_software.WebsiteUrl)) OpenAddress(_software.WebsiteUrl); } [RelayCommand] private async Task DownloadClick() { if (!CanDownload || !EnsureAgreementAccepted()) return; var channel = SelectedChannel; if (channel.Mode == SoftwareDownloadMode.Browser) { try { OpenAddress(channel.Url); StatusText = $"已在浏览器中打开“{channel.Name}”。"; } catch (Exception exception) { StatusText = $"无法打开官方页面:{exception.Message}"; } return; } await DownloadDirectAsync(channel); } private bool EnsureAgreementAccepted() { if (DownloadProfile.DownloadAgreementAccepted) return true; var result = PopupHelper.ShowDialog(new AgreementDialogPopupPage { Title = "下载协议同意书", Agreement = """ 1. 下载来源 软件信息和获取地址由当前配置的工具箱服务器提供。对于跳转到第三方官方网站的内容,实际文件、许可协议和隐私条款由对应发布者负责。 2. 安全检查 下载前请确认发布者、文件来源与数字签名。服务器配置了 SHA-256 时,工具箱会校验文件完整性;该校验不替代杀毒软件、数字签名或人工审核。 3. 使用责任 请遵守相关法律法规、软件许可协议和服务条款。不得将下载的软件用于侵权、破坏系统或其他非法用途。 4. 自动运行 若设置中启用了“下载完成后自动运行”,文件下载完成后会由系统打开。你可以随时在选项设置中关闭该功能。 继续即表示你已阅读并理解以上内容。 """ }, new PopupWindowOptions { Title = "下载协议", Subtitle = "首次下载前需要确认", Width = 520, Height = 520, ContentMargin = new Thickness(12, 0, 12, 12) }); if (result != MessageBoxResult.Yes) return false; DownloadProfile.DownloadAgreementAccepted = true; DownloadProfile.SaveProfile(); return true; } private async Task DownloadDirectAsync(SoftwareDownloadChannel channel) { using var activity = ActivityCenterService.Start( $"下载 {_software.Name}", XFEToolBox.Client.Models.ActivityKind.SoftwareDownload, canCancel: true); var cancellationToken = activity.CancellationToken; string? temporaryPath = null; CanDownload = false; ProgressVisibility = Visibility.Visible; IsProgressIndeterminate = true; ProgressValue = 0; DownloadButtonName = "正在下载…"; StatusText = "正在连接下载服务器…"; try { Directory.CreateDirectory(DownloadProfile.DownloadDirectory); using var response = channel.Mode == SoftwareDownloadMode.Server ? await DownloadFromServerAsync(channel, cancellationToken) : IsWebAddress(channel.Url) ? await DownloadClient.GetAsync(channel.Url, HttpCompletionOption.ResponseHeadersRead, cancellationToken) : throw new InvalidOperationException("服务器返回了无效的下载地址。"); response.EnsureSuccessStatusCode(); var fileName = ResolveFileName(response, _software.Id, channel); var destinationPath = GetAvailablePath(Path.Combine(DownloadProfile.DownloadDirectory, fileName)); temporaryPath = destinationPath + $".{Guid.NewGuid():N}.download"; var contentLength = response.Content.Headers.ContentLength; IsProgressIndeterminate = contentLength is null or <= 0; await using (var input = await response.Content.ReadAsStreamAsync(cancellationToken)) await using (var output = new FileStream(temporaryPath, FileMode.CreateNew, FileAccess.Write, FileShare.None, 81920, FileOptions.Asynchronous | FileOptions.SequentialScan)) { var buffer = new byte[81920]; long received = 0; int read; while ((read = await input.ReadAsync(buffer, cancellationToken)) > 0) { await output.WriteAsync(buffer.AsMemory(0, read), cancellationToken); received += read; if (contentLength is > 0) { ProgressValue = received * 100d / contentLength.Value; StatusText = $"正在下载… {FormatBytes(received)} / {FormatBytes(contentLength.Value)}"; activity.Report(ProgressValue, StatusText); } else { StatusText = $"正在下载… 已接收 {FormatBytes(received)}"; activity.Report(null, StatusText); } } await output.FlushAsync(cancellationToken); } if (!string.IsNullOrWhiteSpace(channel.Sha256)) { StatusText = "正在校验文件完整性…"; await VerifySha256Async(temporaryPath, channel.Sha256, cancellationToken); } File.Move(temporaryPath, destinationPath); temporaryPath = null; ProgressValue = 100; IsProgressIndeterminate = false; StatusText = $"下载完成:{Path.GetFileName(destinationPath)}"; activity.Succeed(StatusText); var followUpMessages = new List(); if (DownloadProfile.AutoRunWhenComplete) { try { Process.Start(new ProcessStartInfo(destinationPath) { UseShellExecute = true }); } catch (Exception exception) { followUpMessages.Add($"自动打开失败:{exception.Message}"); } } if (DownloadProfile.OpenFolderWhenComplete) { try { Process.Start(new ProcessStartInfo(DownloadProfile.DownloadDirectory) { UseShellExecute = true }); } catch (Exception exception) { followUpMessages.Add($"打开目录失败:{exception.Message}"); } } if (followUpMessages.Count > 0) StatusText += $"({string.Join(";", followUpMessages)})"; } catch (OperationCanceledException) { ProgressValue = 0; IsProgressIndeterminate = false; StatusText = "下载已取消。"; activity.Cancel(StatusText); } catch (Exception exception) { ProgressValue = 0; IsProgressIndeterminate = false; StatusText = $"下载失败:{exception.Message}"; activity.Fail(StatusText); } finally { if (temporaryPath is not null && File.Exists(temporaryPath)) File.Delete(temporaryPath); CanDownload = true; DownloadButtonName = GetDownloadButtonName(SelectedChannel); } } private async Task DownloadFromServerAsync( SoftwareDownloadChannel channel, CancellationToken cancellationToken) { var request = new HttpRequestMessage(HttpMethod.Post, $"{ClientSession.ApiAddress}/v1/software/download") { Content = JsonContent.Create(new { execute = "v1/software/download", softwareId = _software.Id, channelId = channel.Id }) }; return await DownloadClient.SendAsync(request, HttpCompletionOption.ResponseHeadersRead, cancellationToken); } private static string ResolveFileName(HttpResponseMessage response, string softwareId, SoftwareDownloadChannel channel) { var value = channel.FileName; if (string.IsNullOrWhiteSpace(value)) value = response.Content.Headers.ContentDisposition?.FileNameStar ?? response.Content.Headers.ContentDisposition?.FileName; value = value?.Trim().Trim('"'); if (string.IsNullOrWhiteSpace(value) && Uri.TryCreate(channel.Url, UriKind.Absolute, out var uri)) value = Uri.UnescapeDataString(Path.GetFileName(uri.LocalPath)); value = Path.GetFileName(value); if (string.IsNullOrWhiteSpace(value)) value = $"{SanitizeFileName(softwareId)}.download"; return SanitizeFileName(value); } private static string SanitizeFileName(string value) { foreach (var invalid in Path.GetInvalidFileNameChars()) value = value.Replace(invalid, '_'); return string.IsNullOrWhiteSpace(value) ? "download.bin" : value; } private static string GetAvailablePath(string path) { if (!File.Exists(path)) return path; var directory = Path.GetDirectoryName(path)!; var name = Path.GetFileNameWithoutExtension(path); var extension = Path.GetExtension(path); for (var index = 1; ; index++) { var candidate = Path.Combine(directory, $"{name} ({index}){extension}"); if (!File.Exists(candidate)) return candidate; } } private static async Task VerifySha256Async(string path, string expected, CancellationToken cancellationToken) { await using var stream = new FileStream(path, FileMode.Open, FileAccess.Read, FileShare.Read, 81920, FileOptions.Asynchronous | FileOptions.SequentialScan); var actual = Convert.ToHexString(await SHA256.HashDataAsync(stream, cancellationToken)); if (!string.Equals(actual, expected.Trim(), StringComparison.OrdinalIgnoreCase)) throw new InvalidDataException("SHA-256 校验失败,文件可能已损坏或被替换。"); } private static string FormatBytes(long bytes) { string[] units = ["B", "KB", "MB", "GB"]; var value = (double)bytes; var unit = 0; while (value >= 1024 && unit < units.Length - 1) { value /= 1024; unit++; } return $"{value:0.##} {units[unit]}"; } private static bool IsWebAddress(string value) => Uri.TryCreate(value, UriKind.Absolute, out var uri) && (uri.Scheme == Uri.UriSchemeHttp || uri.Scheme == Uri.UriSchemeHttps); private static void OpenAddress(string value) { if (!IsWebAddress(value)) return; Process.Start(new ProcessStartInfo(value) { UseShellExecute = true }); } private static string GetDownloadButtonName(SoftwareDownloadChannel channel) => channel.Mode switch { SoftwareDownloadMode.Browser => $"前往{channel.Name}", SoftwareDownloadMode.Server => $"从{channel.Name}下载", _ => $"通过{channel.Name}下载" }; private static HttpClient CreateDownloadClient() { var client = new HttpClient { Timeout = TimeSpan.FromMinutes(30) }; client.DefaultRequestHeaders.UserAgent.ParseAdd("XFEToolBox/0.2"); return client; } }