返回提交历史
Modified
XFEToolBox.Client.Wpf.Test/WebImageSourceLoaderTests.cs
+109
-2
Modified
XFEToolBox.Client.Wpf.Test/XFEToolBox.Client.Wpf.Test.csproj
+1
-0
Modified
XFEToolBox.Core/Tools/ToolCatalogClient.cs
+27
-0
Modified
XFEToolBox.Core/Tools/ToolCatalogContracts.cs
+11
-0
Modified
XFEToolBox.Test/Program.cs
+56
-0
Modified
XFEToolBox/Utilities/WebImageSourceLoader.cs
+15
-1
Modified
XFEToolBox/ViewModel/Pages/ToolCardViewModel.cs
+28
-0
Modified
XFEToolBox/Views/Pages/ToolBoxPage.xaml
+14
-1
Modified
XFEToolBox/Views/Pages/ToolBoxPage.xaml.cs
+27
-3
XFEstudio/XFEToolBox
工具包下载支持字节级进度与 UI 反馈
新增 DownloadPackageAsync 重载,支持 IProgress<ToolPackageDownloadProgress> 回调,实时上报下载进度并校验 SHA-256。新增 ToolPackageDownloadProgress 类型。ToolBoxPage.xaml 及 ToolCardViewModel 增加进度属性,UI 支持进度条与文本切换。ToolBoxPage.xaml.cs 实时更新进度并处理状态复位。WebImageSourceLoader 解码非动态 GIF 后包装为 DrawingImage,提升图片跨线程显示兼容性。补充相关单元测试。项目文件引入 WpfAnimatedGif 包。整体提升下载体验和图片显示健壮性。
621c8ef
代码差异
9 个文件
+288
-7
@@ -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;
@@ -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
@@ -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}。");
@@ -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; }
@@ -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
}
@@ -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
{
@@ -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
}
@@ -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">
@@ -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);