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

XFEToolBox

【WPF】XFE工具箱

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