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

XFEToolBox

【WPF】XFE工具箱

公开
关注 0 Fork 0 Star 0
返回提交历史

XFEstudio/XFEToolBox

工具包下载支持字节级进度与 UI 反馈

新增 DownloadPackageAsync 重载,支持 IProgress<ToolPackageDownloadProgress> 回调,实时上报下载进度并校验 SHA-256。新增 ToolPackageDownloadProgress 类型。ToolBoxPage.xaml 及 ToolCardViewModel 增加进度属性,UI 支持进度条与文本切换。ToolBoxPage.xaml.cs 实时更新进度并处理状态复位。WebImageSourceLoader 解码非动态 GIF 后包装为 DrawingImage,提升图片跨线程显示兼容性。补充相关单元测试。项目文件引入 WpfAnimatedGif 包。整体提升下载体验和图片显示健壮性。

621c8ef
XFE工作室室长 <mail@xfegzs.com>
提交于

代码差异

9 个文件 +288 -7
Modified XFEToolBox.Client.Wpf.Test/WebImageSourceLoaderTests.cs +109 -2
@@ -1,9 +1,12 @@
1 1 using System.Diagnostics;
2 2 using System.IO;
3 3 using System.Text;
4 using System.Windows;
5 using System.Windows.Controls;
4 6 using System.Windows.Media;
5 7 using System.Windows.Media.Imaging;
6 8 using System.Windows.Threading;
9 using WpfAnimatedGif;
7 10 using XFEToolBox.Client.Utilities;
8 11
9 12 namespace XFEToolBox.Client.Wpf.Test;
@@ -33,8 +36,15 @@ public static class WebImageSourceLoaderTests
33 36 File.ReadAllBytes(icoPath),
34 37 "image/x-icon",
35 38 "favicon.ico");
36 Ensure(icoImage is BitmapSource { IsFrozen: true } bitmap && bitmap.PixelWidth >= 32,
37 "ICO 没有选择可清晰显示的位图帧。");
39 Ensure(icoImage is DrawingImage
40 {
41 IsFrozen: true,
42 Drawing: ImageDrawing
43 {
44 ImageSource: BitmapSource { PixelWidth: >= 32 }
45 }
46 },
47 "ICO 没有选择清晰的位图帧并包装成可安全跨线程显示的图像。");
38 48
39 49 var dataUri = "data:image/svg+xml," + Uri.EscapeDataString(SvgMarkup);
40 50 var dataImage = AwaitWithDispatcher(WebImageSourceLoader.LoadAsync(dataUri), TimeSpan.FromSeconds(10));
@@ -83,6 +93,85 @@ public static class WebImageSourceLoaderTests
83 93 WebImageSourceLoader.Decode(Encoding.UTF8.GetBytes("not an image"), "application/octet-stream"));
84 94 }
85 95
96 [Test]
97 public static void BackgroundDecodedPngCanBeDisplayedByAnimatedImageBehavior()
98 {
99 Exception? failure = null;
100 var thread = new Thread(() =>
101 {
102 Window? window = null;
103 DispatcherUnhandledExceptionEventHandler? dispatcherFailureHandler = null;
104 try
105 {
106 var dispatcher = Dispatcher.CurrentDispatcher;
107 Exception? dispatcherFailure = null;
108 dispatcherFailureHandler = (_, eventArgs) =>
109 {
110 dispatcherFailure = eventArgs.Exception;
111 eventArgs.Handled = true;
112 };
113 dispatcher.UnhandledException += dispatcherFailureHandler;
114
115 var dataUri = "data:image/png;base64," + Convert.ToBase64String(CreatePng());
116 var source = AwaitWithDispatcher(
117 WebImageSourceLoader.LoadAsync(dataUri),
118 TimeSpan.FromSeconds(10));
119 var image = new Image { Width = 32, Height = 32 };
120 var fallback = WebImageSourceLoader.Decode(
121 Encoding.UTF8.GetBytes(SvgMarkup),
122 "image/svg+xml",
123 "fallback.svg");
124 ImageBehavior.SetAnimatedSource(image, fallback);
125 window = new Window
126 {
127 Width = 80,
128 Height = 80,
129 Left = -10_000,
130 Top = -10_000,
131 ShowInTaskbar = false,
132 WindowStartupLocation = WindowStartupLocation.Manual,
133 Content = image
134 };
135
136 window.Show();
137 PumpDispatcher(dispatcher);
138 window.UpdateLayout();
139 Ensure(ReferenceEquals(image.Source, fallback),
140 "主页卡片的初始回退图标没有完成显示。");
141
142 // 模拟 RecentUsageCardViewModel 在卡片 Loaded 后异步换成目录中的真实工具图标。
143 ImageBehavior.SetAnimatedSource(image, source);
144 PumpDispatcher(dispatcher);
145 window.UpdateLayout();
146
147 Ensure(dispatcherFailure is null,
148 $"后台解码的静态 PNG 进入动画兼容显示路径时触发 UI 异常:{dispatcherFailure}");
149 Ensure(ReferenceEquals(image.Source, source),
150 "后台解码的静态 PNG 没有替换主页卡片的回退图标。");
151 }
152 catch (Exception exception)
153 {
154 failure = exception;
155 }
156 finally
157 {
158 window?.Close();
159 if (dispatcherFailureHandler is not null)
160 Dispatcher.CurrentDispatcher.UnhandledException -= dispatcherFailureHandler;
161 }
162 })
163 {
164 IsBackground = true,
165 Name = "Background decoded recent icon display test"
166 };
167
168 thread.SetApartmentState(ApartmentState.STA);
169 thread.Start();
170 Ensure(thread.Join(TimeSpan.FromSeconds(15)), "后台解码的最近使用图标显示测试超时。");
171 if (failure is not null)
172 throw new InvalidOperationException($"后台解码的最近使用图标无法显示:{failure.Message}", failure);
173 }
174
86 175 [Test]
87 176 public static void LargeSvgLoadingStaysOffTheUiThreadAndSharesItsCachedResult()
88 177 {
@@ -194,6 +283,24 @@ public static class WebImageSourceLoaderTests
194 283 return output.ToArray();
195 284 }
196 285
286 private static byte[] CreatePng()
287 {
288 var encoder = new PngBitmapEncoder();
289 encoder.Frames.Add(CreateSolidFrame(0x98, 0x98, 0xE7));
290 using var output = new MemoryStream();
291 encoder.Save(output);
292 return output.ToArray();
293 }
294
295 private static void PumpDispatcher(Dispatcher dispatcher)
296 {
297 var frame = new DispatcherFrame();
298 _ = dispatcher.BeginInvoke(
299 DispatcherPriority.ApplicationIdle,
300 new Action(() => frame.Continue = false));
301 Dispatcher.PushFrame(frame);
302 }
303
197 304 private static BitmapFrame CreateSolidFrame(byte red, byte green, byte blue)
198 305 {
199 306 const int size = 4;
Modified XFEToolBox.Client.Wpf.Test/XFEToolBox.Client.Wpf.Test.csproj +1 -0
@@ -10,6 +10,7 @@
10 10
11 11 <ItemGroup>
12 12 <PackageReference Include="SharpVectors" Version="1.8.5" />
13 <PackageReference Include="WpfAnimatedGif" Version="2.0.2" />
13 14 <PackageReference Include="XFEExtension.NetCore.XUnit" Version="4.0.2" />
14 15 </ItemGroup>
15 16
Modified XFEToolBox.Core/Tools/ToolCatalogClient.cs +27 -0
@@ -1,3 +1,4 @@
1 using System.Diagnostics;
1 2 using System.Net;
2 3 using System.Net.Http.Json;
3 4 using System.Security.Cryptography;
@@ -41,9 +42,19 @@ public sealed class ToolCatalogClient(HttpClient httpClient)
41 42 /// <summary>
42 43 /// Downloads a package and verifies it against the catalog SHA-256 before returning.
43 44 /// </summary>
45 public Task DownloadPackageAsync(
46 ToolPackageVersionInfo package,
47 Stream destination,
48 CancellationToken cancellationToken = default)
49 => DownloadPackageAsync(package, destination, progress: null, cancellationToken);
50
51 /// <summary>
52 /// Downloads a package, reports streamed byte progress, and verifies the catalog SHA-256.
53 /// </summary>
44 54 public async Task DownloadPackageAsync(
45 55 ToolPackageVersionInfo package,
46 56 Stream destination,
57 IProgress<ToolPackageDownloadProgress>? progress,
47 58 CancellationToken cancellationToken = default)
48 59 {
49 60 ArgumentNullException.ThrowIfNull(package);
@@ -60,15 +71,31 @@ public sealed class ToolCatalogClient(HttpClient httpClient)
60 71 response.EnsureSuccessStatusCode();
61 72
62 73 await using var source = await response.Content.ReadAsStreamAsync(cancellationToken);
74 var totalBytes = response.Content.Headers.ContentLength is > 0
75 ? response.Content.Headers.ContentLength
76 : package.PackageSize > 0 ? package.PackageSize : null;
77 progress?.Report(new ToolPackageDownloadProgress(0, totalBytes));
78
63 79 using var sha256 = IncrementalHash.CreateHash(HashAlgorithmName.SHA256);
64 80 var buffer = new byte[81920];
81 var received = 0L;
82 var reportTimer = Stopwatch.StartNew();
83 var lastReportAt = TimeSpan.Zero;
65 84 int read;
66 85 while ((read = await source.ReadAsync(buffer, cancellationToken)) > 0)
67 86 {
68 87 await destination.WriteAsync(buffer.AsMemory(0, read), cancellationToken);
69 88 sha256.AppendData(buffer, 0, read);
89 received += read;
90 if (reportTimer.Elapsed - lastReportAt >= TimeSpan.FromMilliseconds(100))
91 {
92 progress?.Report(new ToolPackageDownloadProgress(received, totalBytes));
93 lastReportAt = reportTimer.Elapsed;
94 }
70 95 }
71 96
97 progress?.Report(new ToolPackageDownloadProgress(received, totalBytes));
98
72 99 var actualHash = Convert.ToHexString(sha256.GetHashAndReset()).ToLowerInvariant();
73 100 if (!string.Equals(actualHash, package.Sha256, StringComparison.OrdinalIgnoreCase))
74 101 throw new InvalidDataException($"工具包校验失败。期望 SHA-256:{package.Sha256},实际:{actualHash}。");
Modified XFEToolBox.Core/Tools/ToolCatalogContracts.cs +11 -0
@@ -45,6 +45,17 @@ public sealed class ToolPackageVersionInfo
45 45 public required string DownloadUrl { get; init; }
46 46 }
47 47
48 /// <summary>
49 /// 工具包流式下载进度。服务器未返回长度且目录也没有包大小时,
50 /// <see cref="TotalBytes"/> 为 <see langword="null"/>。
51 /// </summary>
52 public sealed record ToolPackageDownloadProgress(long BytesReceived, long? TotalBytes)
53 {
54 public double? Percentage => TotalBytes is > 0
55 ? Math.Clamp(BytesReceived * 100d / TotalBytes.Value, 0, 100)
56 : null;
57 }
58
48 59 public sealed class ToolPackageUploadResult
49 60 {
50 61 public required ToolPackageManifest Manifest { get; init; }
Modified XFEToolBox.Test/Program.cs +56 -0
@@ -1,7 +1,10 @@
1 1 using XFEToolBox.Core.Model;
2 2
3 3 using System.Diagnostics;
4 using System.Net;
5 using System.Security.Cryptography;
4 6 using XFEToolBox.Client.Core.Console;
7 using XFEToolBox.Core.Tools;
5 8
6 9 namespace XFEToolBox.Test;
7 10
@@ -63,9 +66,62 @@ public class Program
63 66 Console.WriteLine($"控制台输出缓冲吞吐:{throughput:N0} 条/秒({outputCount:N0} 条,共 {stopwatch.Elapsed.TotalMilliseconds:N1} ms)");
64 67 }
65 68
69 [SMTest]
70 public static void ToolPackageDownloadReportsByteProgress()
71 {
72 var payload = Enumerable.Range(0, 240_000).Select(index => (byte)(index % 251)).ToArray();
73 var expectedHash = Convert.ToHexString(SHA256.HashData(payload)).ToLowerInvariant();
74 using var httpClient = new HttpClient(new PackageDownloadHandler(payload))
75 {
76 BaseAddress = new Uri("https://toolbox.test/")
77 };
78 var client = new ToolCatalogClient(httpClient);
79 var reports = new List<ToolPackageDownloadProgress>();
80 var progress = new InlineProgress<ToolPackageDownloadProgress>(reports.Add);
81 var package = new ToolPackageVersionInfo
82 {
83 ToolId = "test.progress",
84 Version = "1.0.0",
85 Sha256 = expectedHash,
86 PackageSize = payload.Length,
87 UploadedAtUtc = DateTimeOffset.UtcNow,
88 Published = true,
89 DownloadUrl = "api/v1/tools/download"
90 };
91 using var destination = new MemoryStream();
92
93 client.DownloadPackageAsync(package, destination, progress).GetAwaiter().GetResult();
94
95 Ensure(destination.ToArray().SequenceEqual(payload), "工具包下载内容与服务器响应不一致。");
96 Ensure(reports.Count >= 2 && reports[0].BytesReceived == 0, "工具包下载没有上报初始进度。");
97 var completed = reports[^1];
98 Ensure(completed.BytesReceived == payload.Length && completed.TotalBytes == payload.Length,
99 "工具包下载没有上报最终字节数。");
100 Ensure(Math.Abs(completed.Percentage.GetValueOrDefault() - 100) < 0.001,
101 "工具包下载完成进度不是 100%。");
102 }
103
66 104 private static void Ensure(bool condition, string message)
67 105 {
68 106 if (!condition)
69 107 throw new InvalidOperationException(message);
70 108 }
109
110 private sealed class PackageDownloadHandler(byte[] payload) : HttpMessageHandler
111 {
112 protected override Task<HttpResponseMessage> SendAsync(HttpRequestMessage request, CancellationToken cancellationToken)
113 {
114 var response = new HttpResponseMessage(HttpStatusCode.OK)
115 {
116 Content = new ByteArrayContent(payload),
117 RequestMessage = request
118 };
119 return Task.FromResult(response);
120 }
121 }
122
123 private sealed class InlineProgress<T>(Action<T> report) : IProgress<T>
124 {
125 public void Report(T value) => report(value);
126 }
71 127 }
Modified XFEToolBox/Utilities/WebImageSourceLoader.cs +15 -1
@@ -189,7 +189,21 @@ public static class WebImageSourceLoader
189 189 ?? throw new InvalidDataException("图标中没有可显示的位图帧。");
190 190 if (frame.CanFreeze)
191 191 frame.Freeze();
192 return frame;
192
193 // WpfAnimatedGif 会为每个 BitmapSource 访问其 Decoder。静态位图在后台线程
194 // 解码后,即使帧已冻结,Decoder 仍属于创建线程,UI 线程访问时会抛出异常。
195 // 仅动态 GIF 需要保留 BitmapFrame;其他格式包装为 DrawingImage 后既能跨线程
196 // 显示,也会让动画行为直接把它赋给 Image.Source,而不再访问 Decoder。
197 if (decoder is GifBitmapDecoder { Frames.Count: > 1 })
198 return frame;
199
200 var drawing = new ImageDrawing(frame, new Rect(0, 0, frame.Width, frame.Height));
201 if (drawing.CanFreeze)
202 drawing.Freeze();
203 var image = new DrawingImage(drawing);
204 if (image.CanFreeze)
205 image.Freeze();
206 return image;
193 207 }
194 208 catch (Exception exception) when (exception is NotSupportedException or FileFormatException or ArgumentException)
195 209 {
Modified XFEToolBox/ViewModel/Pages/ToolCardViewModel.cs +28 -0
@@ -8,6 +8,10 @@ public sealed class ToolCardViewModel(ToolPackageSummary package, ImageSource ic
8 8 {
9 9 private bool _isEnabled = true;
10 10 private string _cacheState = isCached ? "点击打开" : "获取并打开";
11 private bool _isDownloading;
12 private bool _isDownloadIndeterminate;
13 private double _downloadProgress;
14 private string _downloadProgressText = string.Empty;
11 15
12 16 public ToolPackageSummary Package { get; } = package;
13 17 public string Id => Package.Id;
@@ -29,4 +33,28 @@ public sealed class ToolCardViewModel(ToolPackageSummary package, ImageSource ic
29 33 get => _cacheState;
30 34 set => SetProperty(ref _cacheState, value);
31 35 }
36
37 public bool IsDownloading
38 {
39 get => _isDownloading;
40 set => SetProperty(ref _isDownloading, value);
41 }
42
43 public bool IsDownloadIndeterminate
44 {
45 get => _isDownloadIndeterminate;
46 set => SetProperty(ref _isDownloadIndeterminate, value);
47 }
48
49 public double DownloadProgress
50 {
51 get => _downloadProgress;
52 set => SetProperty(ref _downloadProgress, Math.Clamp(value, 0, 100));
53 }
54
55 public string DownloadProgressText
56 {
57 get => _downloadProgressText;
58 set => SetProperty(ref _downloadProgressText, value);
59 }
32 60 }
Modified XFEToolBox/Views/Pages/ToolBoxPage.xaml +14 -1
@@ -235,7 +235,7 @@
235 235 </DockPanel>
236 236 <TextBlock Grid.Row="1" Text="{Binding Description}" Margin="0,8,0,8" Foreground="#7F7F91" FontSize="10.5"
237 237 TextWrapping="Wrap" TextTrimming="CharacterEllipsis" MaxHeight="45"/>
238 <Grid Grid.Row="2">
238 <Grid x:Name="NormalFooter" Grid.Row="2">
239 239 <Grid.ColumnDefinitions><ColumnDefinition Width="*"/><ColumnDefinition Width="Auto"/></Grid.ColumnDefinitions>
240 240 <controls:ScrollTextBlock InnerText="{Binding Author}" Height="18" Margin="0,0,72,0"
241 241 InnerFontSize="9.5" InnerForeground="#A0A0B0"
@@ -245,9 +245,22 @@
245 245 <TextBlock Grid.Column="1" Text="{Binding LatestVersion, StringFormat=v{0}}"
246 246 Foreground="#8585D8" FontSize="9.5" VerticalAlignment="Center"/>
247 247 </Grid>
248 <Grid x:Name="DownloadFooter" Grid.Row="2" Visibility="Collapsed">
249 <Grid.RowDefinitions><RowDefinition Height="Auto"/><RowDefinition Height="Auto"/></Grid.RowDefinitions>
250 <TextBlock Text="{Binding DownloadProgressText}" Foreground="#7777C9" FontSize="8.8"
251 TextTrimming="CharacterEllipsis"/>
252 <ProgressBar Grid.Row="1" Height="4" Margin="0,4,0,0" Minimum="0" Maximum="100"
253 Value="{Binding DownloadProgress}" IsIndeterminate="{Binding IsDownloadIndeterminate}"/>
254 </Grid>
248 255 </Grid>
249 256 </Grid>
250 257 </Button>
258 <DataTemplate.Triggers>
259 <DataTrigger Binding="{Binding IsDownloading}" Value="True">
260 <Setter TargetName="NormalFooter" Property="Visibility" Value="Collapsed"/>
261 <Setter TargetName="DownloadFooter" Property="Visibility" Value="Visible"/>
262 </DataTrigger>
263 </DataTemplate.Triggers>
251 264 </DataTemplate>
252 265
253 266 <DataTemplate x:Key="ToolCategoryHeaderTemplate">
Modified XFEToolBox/Views/Pages/ToolBoxPage.xaml.cs +27 -3
@@ -384,7 +384,13 @@ public partial class ToolBoxPage : Page
384 384 cachePath = GetCachePath(card.Package);
385 385 if (!await IsCachedPackageValidAsync(cachePath, package.Sha256))
386 386 {
387 card.CacheState = "正在获取…";
387 card.IsDownloading = true;
388 card.IsDownloadIndeterminate = package.PackageSize <= 0;
389 card.DownloadProgress = 0;
390 card.DownloadProgressText = package.PackageSize > 0
391 ? $"0 B / {FormatDataSize(package.PackageSize)}"
392 : "正在连接下载服务器…";
393 card.CacheState = "下载 0%";
388 394 StatusText.Text = $"正在获取 {card.Name} {card.LatestVersion}…";
389 395 var cacheDirectory = Path.GetDirectoryName(cachePath)!;
390 396 Directory.CreateDirectory(cacheDirectory);
@@ -393,18 +399,35 @@ public partial class ToolBoxPage : Page
393 399 using var client = new HttpClient
394 400 {
395 401 BaseAddress = new Uri(ClientSession.ApiAddress + "/"),
396 Timeout = TimeSpan.FromMinutes(2)
402 Timeout = TimeSpan.FromMinutes(10)
397 403 };
398 404 var catalogClient = new ToolCatalogClient(client);
405 var downloadProgress = new Progress<ToolPackageDownloadProgress>(item =>
406 {
407 card.IsDownloadIndeterminate = item.TotalBytes is null or <= 0;
408 card.DownloadProgress = item.Percentage ?? 0;
409 card.DownloadProgressText = item.TotalBytes is > 0
410 ? $"{FormatDataSize(item.BytesReceived)} / {FormatDataSize(item.TotalBytes.Value)}"
411 : $"已下载 {FormatDataSize(item.BytesReceived)}";
412 card.CacheState = item.Percentage is { } percentage
413 ? $"下载 {percentage:0}%"
414 : "正在下载…";
415 StatusText.Text = $"正在下载 {card.Name} · {card.DownloadProgressText}";
416 });
399 417 await using (var output = new FileStream(
400 418 temporaryPath, FileMode.CreateNew, FileAccess.Write, FileShare.None, 81920,
401 419 FileOptions.Asynchronous | FileOptions.SequentialScan))
402 420 {
403 await catalogClient.DownloadPackageAsync(package, output);
421 await catalogClient.DownloadPackageAsync(package, output, downloadProgress);
404 422 await output.FlushAsync();
405 423 }
424 card.CacheState = "正在校验…";
425 card.DownloadProgress = 100;
426 card.IsDownloadIndeterminate = false;
427 card.DownloadProgressText = "下载完成,校验通过";
406 428 File.Move(temporaryPath, cachePath, overwrite: true);
407 429 temporaryPath = null;
430 card.IsDownloading = false;
408 431 }
409 432
410 433 card.CacheState = "正在打开…";
@@ -431,6 +454,7 @@ public partial class ToolBoxPage : Page
431 454 }
432 455 finally
433 456 {
457 card.IsDownloading = false;
434 458 card.IsEnabled = true;
435 459 if (temporaryPath is not null && File.Exists(temporaryPath))
436 460 File.Delete(temporaryPath);