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

XFEToolBox

【WPF】XFE工具箱

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

XFEstudio/XFEToolBox

统一网络图标加载,支持多格式与动画 GIF

引入 WebImageSourceLoader,统一处理 HTTP(S) 与 data:image URI,自动识别并解码 PNG、ICO、SVG/SVGZ、GIF(含动画)等格式。SVG 通过 SharpVectors 渲染为 WPF DrawingImage,GIF 支持 WpfAnimatedGif 动画播放。重构 DownloadPage、SoftwareManagementPage、MainPage、RecentUsageCardViewModel 等页面/视图模型的图标加载逻辑,提升代码复用性和兼容性。XAML 侧替换为 <Image gif:ImageBehavior.AnimatedSource=...>,实现 GIF 自动播放。支持多格式 data:image URI,更新相关提示文案。新增 WebImageSourceLoaderTests 单元测试,覆盖多种场景。升级部分依赖包,ServerProfile 配置属性声明调整为 static partial。

6cff5e8
XFE工作室室长 <mail@xfegzs.com>
提交于

代码差异

18 个文件 +663 -173
Modified XFEToolBox.Client.Installer/XFEToolBox.Client.Installer.csproj +2 -2
@@ -37,8 +37,8 @@
37 37 </ItemGroup>
38 38
39 39 <ItemGroup>
40 <PackageReference Include="CommunityToolkit.Mvvm" Version="8.4.0" />
41 <PackageReference Include="XFEExtension.NetCore" Version="3.2.7" />
40 <PackageReference Include="CommunityToolkit.Mvvm" Version="8.4.2" />
41 <PackageReference Include="XFEExtension.NetCore" Version="5.2.1" />
42 42 </ItemGroup>
43 43
44 44 <ItemGroup>
Added XFEToolBox.Client.Wpf.Test/WebImageSourceLoaderTests.cs +232 -0
@@ -0,0 +1,232 @@
1 using System.Diagnostics;
2 using System.IO;
3 using System.Text;
4 using System.Windows.Media;
5 using System.Windows.Media.Imaging;
6 using System.Windows.Threading;
7 using XFEToolBox.Client.Utilities;
8
9 namespace XFEToolBox.Client.Wpf.Test;
10
11 public static class WebImageSourceLoaderTests
12 {
13 private const string SvgMarkup =
14 "<svg xmlns=\"http://www.w3.org/2000/svg\" viewBox=\"0 0 32 32\"><rect width=\"32\" height=\"32\" rx=\"6\" fill=\"#9898e7\"/><path d=\"M8 16h16\" stroke=\"white\" stroke-width=\"3\"/></svg>";
15
16 [Test]
17 public static void WebImageLoaderDecodesSvgIcoAndSvgDataUris()
18 {
19 Exception? failure = null;
20 var thread = new Thread(() =>
21 {
22 try
23 {
24 var svgImage = WebImageSourceLoader.Decode(
25 Encoding.UTF8.GetBytes(SvgMarkup),
26 "application/octet-stream",
27 "icon?id=42");
28 Ensure(svgImage is DrawingImage { IsFrozen: true },
29 "没有后缀且类型不准确的 SVG 没有被内容识别并转换成 WPF DrawingImage。");
30
31 var icoPath = Path.Combine(AppContext.BaseDirectory, "TestAssets", "XFEToolBoxIcon.ico");
32 var icoImage = WebImageSourceLoader.Decode(
33 File.ReadAllBytes(icoPath),
34 "image/x-icon",
35 "favicon.ico");
36 Ensure(icoImage is BitmapSource { IsFrozen: true } bitmap && bitmap.PixelWidth >= 32,
37 "ICO 没有选择可清晰显示的位图帧。");
38
39 var dataUri = "data:image/svg+xml," + Uri.EscapeDataString(SvgMarkup);
40 var dataImage = AwaitWithDispatcher(WebImageSourceLoader.LoadAsync(dataUri), TimeSpan.FromSeconds(10));
41 Ensure(dataImage is DrawingImage { IsFrozen: true },
42 "非 Base64 的 SVG data URI 没有被正确解码。");
43
44 var gifBytes = CreateAnimatedGif();
45 var gifImage = WebImageSourceLoader.Decode(
46 gifBytes,
47 "application/octet-stream",
48 "icon?format=animated");
49 Ensure(gifImage is BitmapFrame { Decoder: GifBitmapDecoder gifDecoder, IsFrozen: true } &&
50 gifDecoder.Frames.Count == 2,
51 "动态 GIF 没有被内容识别,或多帧信息在解码后丢失。");
52
53 var gifDataUri = "data:image/gif;base64," + Convert.ToBase64String(gifBytes);
54 var gifDataImage = AwaitWithDispatcher(
55 WebImageSourceLoader.LoadAsync(gifDataUri),
56 TimeSpan.FromSeconds(10));
57 Ensure(gifDataImage is BitmapFrame { Decoder: GifBitmapDecoder dataGifDecoder } &&
58 dataGifDecoder.Frames.Count == 2,
59 "Base64 GIF data URI 没有保留动画帧。");
60 }
61 catch (Exception exception)
62 {
63 failure = exception;
64 }
65 })
66 {
67 IsBackground = true
68 };
69
70 thread.SetApartmentState(ApartmentState.STA);
71 thread.Start();
72 Ensure(thread.Join(TimeSpan.FromSeconds(15)), "网络图标格式测试超时。");
73 if (failure is not null)
74 throw new InvalidOperationException($"网络图标格式加载测试失败:{failure.Message}", failure);
75 }
76
77 [Test]
78 public static void WebImageLoaderRejectsOversizedAndNonImageContent()
79 {
80 EnsureThrows<InvalidDataException>(() =>
81 WebImageSourceLoader.Decode(new byte[WebImageSourceLoader.MaximumImageBytes + 1], "image/png"));
82 EnsureThrows<InvalidDataException>(() =>
83 WebImageSourceLoader.Decode(Encoding.UTF8.GetBytes("not an image"), "application/octet-stream"));
84 }
85
86 [Test]
87 public static void LargeSvgLoadingStaysOffTheUiThreadAndSharesItsCachedResult()
88 {
89 var dataUri = "data:image/svg+xml;base64," +
90 Convert.ToBase64String(Encoding.UTF8.GetBytes(CreateComplexSvg(6000)));
91 Exception? failure = null;
92 var thread = new Thread(() =>
93 {
94 try
95 {
96 var dispatcher = Dispatcher.CurrentDispatcher;
97 var invocationTimer = Stopwatch.StartNew();
98 var firstLoad = WebImageSourceLoader.LoadAsync(dataUri);
99 var concurrentLoad = WebImageSourceLoader.LoadAsync(dataUri);
100 invocationTimer.Stop();
101
102 // 如果解析仍在调用线程同步执行,这里会在大型 SVG 解析完之后才有机会投递 UI 消息。
103 Ensure(invocationTimer.Elapsed < TimeSpan.FromMilliseconds(150),
104 $"大型 SVG 的 LoadAsync 调用阻塞了 UI {invocationTimer.Elapsed.TotalMilliseconds:F1} ms。");
105
106 var uiPulseReceived = false;
107 dispatcher.BeginInvoke(DispatcherPriority.Send, new Action(() => uiPulseReceived = true));
108
109 var allLoads = Task.WhenAll(firstLoad, concurrentLoad);
110 var frame = new DispatcherFrame();
111 _ = Task.Run(async () =>
112 {
113 await Task.WhenAny(allLoads, Task.Delay(TimeSpan.FromSeconds(15)));
114 await dispatcher.BeginInvoke(DispatcherPriority.Background,
115 new Action(() => frame.Continue = false));
116 });
117 Dispatcher.PushFrame(frame);
118
119 Ensure(uiPulseReceived, "大型 SVG 解析期间 WPF Dispatcher 未能处理界面消息。");
120 Ensure(allLoads.IsCompletedSuccessfully, "大型 SVG 没有在 15 秒内完成后台解析。\n" +
121 allLoads.Exception?.GetBaseException().Message);
122
123 var images = allLoads.GetAwaiter().GetResult();
124 Ensure(images[0] is DrawingImage { IsFrozen: true },
125 "后台解析结果不是可跨线程使用的冻结 DrawingImage。");
126 Ensure(ReferenceEquals(images[0], images[1]),
127 "相同 SVG 的并发请求没有复用同一个解析任务和缓存结果。");
128
129 var cacheTimer = Stopwatch.StartNew();
130 var cachedImage = WebImageSourceLoader.LoadAsync(dataUri).GetAwaiter().GetResult();
131 cacheTimer.Stop();
132 Ensure(ReferenceEquals(images[0], cachedImage), "大型 SVG 的后续请求没有命中缓存。");
133 Ensure(cacheTimer.Elapsed < TimeSpan.FromMilliseconds(250),
134 $"大型 SVG 缓存读取耗时异常:{cacheTimer.Elapsed.TotalMilliseconds:F1} ms。");
135 }
136 catch (Exception exception)
137 {
138 failure = exception;
139 }
140 })
141 {
142 IsBackground = true,
143 Name = "WebImageSourceLoader SVG responsiveness test"
144 };
145
146 thread.SetApartmentState(ApartmentState.STA);
147 thread.Start();
148 Ensure(thread.Join(TimeSpan.FromSeconds(20)), "大型 SVG UI 响应性测试超时。");
149 if (failure is not null)
150 throw new InvalidOperationException($"大型 SVG 后台解析与缓存测试失败:{failure.Message}", failure);
151 }
152
153 private static string CreateComplexSvg(int elementCount)
154 {
155 var svg = new StringBuilder(elementCount * 64);
156 svg.Append("<svg xmlns=\"http://www.w3.org/2000/svg\" viewBox=\"0 0 512 512\">");
157 for (var index = 0; index < elementCount; index++)
158 {
159 var x = index % 512;
160 var y = index * 17 % 512;
161 svg.Append("<path d=\"M")
162 .Append(x).Append(' ').Append(y)
163 .Append("h8v8h-8z\" fill=\"#9898e7\" opacity=\".75\"/>");
164 }
165 return svg.Append("</svg>").ToString();
166 }
167
168 private static T AwaitWithDispatcher<T>(Task<T> task, TimeSpan timeout)
169 {
170 if (!task.IsCompleted)
171 {
172 var dispatcher = Dispatcher.CurrentDispatcher;
173 var frame = new DispatcherFrame();
174 _ = Task.Run(async () =>
175 {
176 await Task.WhenAny(task, Task.Delay(timeout));
177 await dispatcher.BeginInvoke(DispatcherPriority.Background,
178 new Action(() => frame.Continue = false));
179 });
180 Dispatcher.PushFrame(frame);
181 }
182
183 Ensure(task.IsCompleted, $"等待后台图像解析超过 {timeout.TotalSeconds:F0} 秒。");
184 return task.GetAwaiter().GetResult();
185 }
186
187 private static byte[] CreateAnimatedGif()
188 {
189 var encoder = new GifBitmapEncoder();
190 encoder.Frames.Add(CreateSolidFrame(0xE7, 0x98, 0x98));
191 encoder.Frames.Add(CreateSolidFrame(0x98, 0xE7, 0xB0));
192 using var output = new MemoryStream();
193 encoder.Save(output);
194 return output.ToArray();
195 }
196
197 private static BitmapFrame CreateSolidFrame(byte red, byte green, byte blue)
198 {
199 const int size = 4;
200 var pixels = new byte[size * size * 4];
201 for (var index = 0; index < pixels.Length; index += 4)
202 {
203 pixels[index] = blue;
204 pixels[index + 1] = green;
205 pixels[index + 2] = red;
206 pixels[index + 3] = 0xFF;
207 }
208
209 var bitmap = BitmapSource.Create(
210 size, size, 96, 96, PixelFormats.Bgra32, null, pixels, size * 4);
211 return BitmapFrame.Create(bitmap);
212 }
213
214 private static void EnsureThrows<TException>(Action action) where TException : Exception
215 {
216 try
217 {
218 action();
219 }
220 catch (TException)
221 {
222 return;
223 }
224 throw new InvalidOperationException($"预期抛出 {typeof(TException).Name},但操作成功了。");
225 }
226
227 private static void Ensure(bool condition, string message)
228 {
229 if (!condition)
230 throw new InvalidOperationException(message);
231 }
232 }
Modified XFEToolBox.Client.Wpf.Test/XFEToolBox.Client.Wpf.Test.csproj +9 -1
@@ -9,7 +9,8 @@
9 9 </PropertyGroup>
10 10
11 11 <ItemGroup>
12 <PackageReference Include="XFEExtension.NetCore.XUnit" Version="4.0.0" />
12 <PackageReference Include="SharpVectors" Version="1.8.5" />
13 <PackageReference Include="XFEExtension.NetCore.XUnit" Version="4.0.2" />
13 14 </ItemGroup>
14 15
15 16 <ItemGroup>
@@ -21,6 +22,13 @@
21 22 <Compile Include="..\XFEToolBox\Utilities\BufferedConsoleRenderer.cs" Link="Utilities\BufferedConsoleRenderer.cs" />
22 23 <Compile Include="..\XFEToolBox.Client.Installer\Utilities\InstallationService.cs" Link="Installer\InstallationService.cs" />
23 24 <Compile Include="..\XFEToolBox.Client.Installer\Utilities\ZipHelper.cs" Link="Installer\ZipHelper.cs" />
25 <Compile Include="..\XFEToolBox\Utilities\WebImageSourceLoader.cs" Link="Utilities\WebImageSourceLoader.cs" />
26 </ItemGroup>
27
28 <ItemGroup>
29 <None Include="..\XFEToolBox\Resources\Icon\XFEToolBoxIcon.ico" Link="TestAssets\XFEToolBoxIcon.ico">
30 <CopyToOutputDirectory>PreserveNewest</CopyToOutputDirectory>
31 </None>
24 32 </ItemGroup>
25 33
26 34 </Project>
Modified XFEToolBox.Core/XFEToolBox.Core.csproj +2 -2
@@ -7,10 +7,10 @@
7 7 </PropertyGroup>
8 8
9 9 <ItemGroup>
10 <PackageReference Include="XFEExtension.NetCore" Version="5.2.0" />
10 <PackageReference Include="XFEExtension.NetCore" Version="5.2.1" />
11 11 <PackageReference Include="XFEExtension.NetCore.AutoConfig" Version="4.1.2" />
12 12 <PackageReference Include="XFEExtension.NetCore.AutoPath" Version="2.0.0" />
13 <PackageReference Include="XFEExtension.NetCore.ServerInteractive" Version="4.0.0" />
13 <PackageReference Include="XFEExtension.NetCore.ServerInteractive" Version="4.0.1" />
14 14 <PackageReference Include="XFEExtension.NetCore.TodoHighLight" Version="1.0.0" />
15 15 </ItemGroup>
16 16
Modified XFEToolBox.Server/Profiles/ServerProfile.cs +14 -14
@@ -11,18 +11,18 @@ public partial class ServerProfile : XFEProfile
11 11
12 12 public ServerProfile() => DefaultProfileOperationMode = ProfileOperationMode.Xml;
13 13
14 [ProfileProperty] public static string HttpAddress { get; set; } = "http://localhost:3000/";
15 [ProfileProperty] public static string HttpsAddress { get; set; } = "https://localhost:3400/";
16 [ProfileProperty] public static string StorageRoot { get; set; } = "Data/ToolPackages";
17 [ProfileProperty] public static string SoftwareStorageRoot { get; set; } = "Data/SoftwareFiles";
18 [ProfileProperty] public static string AdminApiKey { get; set; } = string.Empty;
19 [ProfileProperty] public static long MaxPackageBytes { get; set; } = 10 * 1024 * 1024;
20 [ProfileProperty] public static long MaxSoftwareBytes { get; set; } = 256 * 1024 * 1024;
21 [ProfileProperty] public static long MaxExpandedBytes { get; set; } = 30 * 1024 * 1024;
22 [ProfileProperty] public static int MaxFileCount { get; set; } = 256;
23 [ProfileProperty] public static double MaxCompressionRatio { get; set; } = 100;
24 [ProfileProperty] public static int LoginKeepDays { get; set; } = 30;
25 [ProfileProperty] public static string InitialAdminUserName { get; set; } = "admin";
26 [ProfileProperty] public static string InitialAdminPassword { get; set; } = DefaultInitialAdminPassword;
27 [ProfileProperty] public static bool AllowRegistration { get; set; } = true;
14 [ProfileProperty] public static partial string HttpAddress { get; set; } = "http://localhost:3000/";
15 [ProfileProperty] public static partial string HttpsAddress { get; set; } = "https://localhost:3400/";
16 [ProfileProperty] public static partial string StorageRoot { get; set; } = "Data/ToolPackages";
17 [ProfileProperty] public static partial string SoftwareStorageRoot { get; set; } = "Data/SoftwareFiles";
18 [ProfileProperty] public static partial string AdminApiKey { get; set; } = string.Empty;
19 [ProfileProperty] public static partial long MaxPackageBytes { get; set; } = 10 * 1024 * 1024;
20 [ProfileProperty] public static partial long MaxSoftwareBytes { get; set; } = 256 * 1024 * 1024;
21 [ProfileProperty] public static partial long MaxExpandedBytes { get; set; } = 30 * 1024 * 1024;
22 [ProfileProperty] public static partial int MaxFileCount { get; set; } = 256;
23 [ProfileProperty] public static partial double MaxCompressionRatio { get; set; } = 100;
24 [ProfileProperty] public static partial int LoginKeepDays { get; set; } = 30;
25 [ProfileProperty] public static partial string InitialAdminUserName { get; set; } = "admin";
26 [ProfileProperty] public static partial string InitialAdminPassword { get; set; } = DefaultInitialAdminPassword;
27 [ProfileProperty] public static partial bool AllowRegistration { get; set; } = true;
28 28 }
Modified XFEToolBox.Server/XFEToolBox.Server.csproj +1 -1
@@ -8,7 +8,7 @@
8 8 </PropertyGroup>
9 9
10 10 <ItemGroup>
11 <PackageReference Include="XFEExtension.NetCore.ServerInteractive" Version="4.0.0" />
11 <PackageReference Include="XFEExtension.NetCore.ServerInteractive" Version="4.0.1" />
12 12 </ItemGroup>
13 13
14 14 <ItemGroup>
Modified XFEToolBox.Test/XFEToolBox.Client.Test.csproj +1 -1
@@ -8,7 +8,7 @@
8 8 </PropertyGroup>
9 9
10 10 <ItemGroup>
11 <PackageReference Include="XFEExtension.NetCore" Version="5.2.0" />
11 <PackageReference Include="XFEExtension.NetCore" Version="5.2.1" />
12 12 <PackageReference Include="XFEExtension.NetCore.TodoHighLight" Version="1.0.0" />
13 13 <PackageReference Include="XFEExtension.NetCore.XUnit" Version="4.0.2" />
14 14 </ItemGroup>
Added XFEToolBox/Utilities/WebImageSourceLoader.cs +357 -0
@@ -0,0 +1,357 @@
1 using System.Collections.Concurrent;
2 using System.IO;
3 using System.IO.Compression;
4 using System.Net;
5 using System.Net.Http;
6 using System.Net.Http.Headers;
7 using System.Security.Cryptography;
8 using System.Text;
9 using System.Windows;
10 using System.Windows.Media;
11 using System.Windows.Media.Imaging;
12 using System.Windows.Threading;
13 using System.Xml;
14 using SharpVectors.Converters;
15 using SharpVectors.Dom;
16 using SharpVectors.Renderers.Wpf;
17
18 namespace XFEToolBox.Client.Utilities;
19
20 /// <summary>
21 /// 将 HTTP(S) 或 data:image 地址统一解码为可跨线程使用的 WPF 图像。
22 /// 支持 WIC 位图格式(包括 ICO、静态或动态 GIF)以及 SVG/SVGZ。
23 /// GIF 会保留原始解码器和全部帧,供 WpfAnimatedGif 在界面中播放。
24 /// </summary>
25 public static class WebImageSourceLoader
26 {
27 public const int MaximumImageBytes = 4 * 1024 * 1024;
28
29 private const int MaximumCachedImages = 256;
30 private static readonly SemaphoreSlim DecodeGate = new(Math.Clamp(Environment.ProcessorCount / 2, 1, 4));
31 private static readonly ConcurrentDictionary<string, Lazy<Task<ImageSource?>>> Cache =
32 new(StringComparer.Ordinal);
33 private static readonly HttpClient ImageClient = CreateImageClient();
34
35 /// <summary>
36 /// 异步读取网络地址或 data:image URI。失败会抛出可诊断异常,不缓存失败结果。
37 /// </summary>
38 public static async Task<ImageSource?> LoadAsync(string? value)
39 {
40 if (string.IsNullOrWhiteSpace(value))
41 return null;
42
43 value = value.Trim();
44 var displayDispatcher = GetDisplayDispatcher();
45 // data URI 可能包含数 MB 文本,计算缓存键也不能占用 UI 线程。
46 var cacheKey = value.StartsWith("data:image/", StringComparison.OrdinalIgnoreCase)
47 ? await Task.Run(() => CreateCacheKey(value)).ConfigureAwait(false)
48 : value;
49 if (Cache.Count >= MaximumCachedImages && !Cache.ContainsKey(cacheKey))
50 Cache.Clear();
51
52 var lazy = Cache.GetOrAdd(
53 cacheKey,
54 _ => new Lazy<Task<ImageSource?>>(
55 () => LoadCoreAsync(value, displayDispatcher),
56 LazyThreadSafetyMode.ExecutionAndPublication));
57
58 try
59 {
60 var result = await lazy.Value.ConfigureAwait(false);
61 if (result is null)
62 Cache.TryRemove(new KeyValuePair<string, Lazy<Task<ImageSource?>>>(cacheKey, lazy));
63 return result;
64 }
65 catch
66 {
67 Cache.TryRemove(new KeyValuePair<string, Lazy<Task<ImageSource?>>>(cacheKey, lazy));
68 throw;
69 }
70 }
71
72 /// <summary>
73 /// 从已下载的内容解码图像;公开此入口以供缓存层和测试复用。
74 /// </summary>
75 public static ImageSource Decode(byte[] bytes, string? mediaType = null, string? sourceName = null)
76 {
77 ArgumentNullException.ThrowIfNull(bytes);
78 if (bytes.Length == 0)
79 throw new InvalidDataException("图标内容为空。");
80 if (bytes.Length > MaximumImageBytes)
81 throw new InvalidDataException($"图标超过 {MaximumImageBytes / 1024 / 1024} MB 限制。");
82
83 bytes = DecompressSvgzIfNeeded(bytes, mediaType, sourceName);
84 return IsSvg(bytes, mediaType, sourceName)
85 ? DecodeSvg(bytes)
86 : DecodeBitmap(bytes);
87 }
88
89 /// <summary>
90 /// 在合适的线程中完成图像解码:SVG 与普通位图在受限后台线程执行,
91 /// 动态 GIF 则在显示 Dispatcher 上创建,以保留可用的多帧解码器。
92 /// </summary>
93 public static Task<ImageSource> DecodeAsync(byte[] bytes, string? mediaType = null, string? sourceName = null) =>
94 DecodeForDisplayAsync(bytes, mediaType, sourceName, GetDisplayDispatcher());
95
96 private static async Task<ImageSource?> LoadCoreAsync(string value, Dispatcher? displayDispatcher)
97 {
98 if (value.StartsWith("data:image/", StringComparison.OrdinalIgnoreCase))
99 {
100 var decodedData = await Task.Run(() => DecodeDataUri(value)).ConfigureAwait(false);
101 return await DecodeForDisplayAsync(
102 decodedData.Bytes,
103 decodedData.MediaType,
104 decodedData.MediaType,
105 displayDispatcher)
106 .ConfigureAwait(false);
107 }
108
109 if (!Uri.TryCreate(value, UriKind.Absolute, out var uri) ||
110 (uri.Scheme != Uri.UriSchemeHttp && uri.Scheme != Uri.UriSchemeHttps))
111 throw new ArgumentException("图标地址必须是 HTTP(S) 地址或 data:image URI。", nameof(value));
112
113 using var request = new HttpRequestMessage(HttpMethod.Get, uri);
114 request.Headers.Accept.Add(new MediaTypeWithQualityHeaderValue("image/svg+xml"));
115 request.Headers.Accept.Add(new MediaTypeWithQualityHeaderValue("image/*", 0.9));
116 using var response = await ImageClient
117 .SendAsync(request, HttpCompletionOption.ResponseHeadersRead)
118 .ConfigureAwait(false);
119 response.EnsureSuccessStatusCode();
120
121 var contentLength = response.Content.Headers.ContentLength;
122 if (contentLength > MaximumImageBytes)
123 throw new InvalidDataException($"图标超过 {MaximumImageBytes / 1024 / 1024} MB 限制。");
124
125 await using var responseStream = await response.Content.ReadAsStreamAsync().ConfigureAwait(false);
126 var bytes = await ReadLimitedAsync(responseStream).ConfigureAwait(false);
127 return await DecodeForDisplayAsync(
128 bytes,
129 response.Content.Headers.ContentType?.MediaType,
130 uri.AbsolutePath,
131 displayDispatcher)
132 .ConfigureAwait(false);
133 }
134
135 private static async Task<byte[]> ReadLimitedAsync(Stream stream)
136 {
137 using var output = new MemoryStream();
138 var buffer = new byte[32 * 1024];
139 while (true)
140 {
141 var count = await stream.ReadAsync(buffer).ConfigureAwait(false);
142 if (count == 0)
143 break;
144 if (output.Length + count > MaximumImageBytes)
145 throw new InvalidDataException($"图标超过 {MaximumImageBytes / 1024 / 1024} MB 限制。");
146 output.Write(buffer, 0, count);
147 }
148 return output.ToArray();
149 }
150
151 private static (byte[] Bytes, string MediaType) DecodeDataUri(string value)
152 {
153 var separator = value.IndexOf(',');
154 if (separator <= 5)
155 throw new FormatException("data:image URI 缺少内容分隔符。");
156
157 var header = value[5..separator];
158 var segments = header.Split(';', StringSplitOptions.RemoveEmptyEntries | StringSplitOptions.TrimEntries);
159 var mediaType = segments.FirstOrDefault() ?? string.Empty;
160 if (!mediaType.StartsWith("image/", StringComparison.OrdinalIgnoreCase))
161 throw new FormatException("data URI 不是图像类型。");
162
163 var payload = value[(separator + 1)..];
164 var isBase64 = segments.Skip(1).Any(segment => segment.Equals("base64", StringComparison.OrdinalIgnoreCase));
165 if (isBase64)
166 {
167 if (payload.Length > (MaximumImageBytes + 2L) / 3 * 4 + 8)
168 throw new InvalidDataException($"图标超过 {MaximumImageBytes / 1024 / 1024} MB 限制。");
169 return (Convert.FromBase64String(payload), mediaType);
170 }
171
172 if (payload.Length > MaximumImageBytes * 3L)
173 throw new InvalidDataException($"图标超过 {MaximumImageBytes / 1024 / 1024} MB 限制。");
174 return (Encoding.UTF8.GetBytes(Uri.UnescapeDataString(payload)), mediaType);
175 }
176
177 private static ImageSource DecodeBitmap(byte[] bytes)
178 {
179 try
180 {
181 using var stream = new MemoryStream(bytes, writable: false);
182 var decoder = BitmapDecoder.Create(
183 stream,
184 BitmapCreateOptions.PreservePixelFormat,
185 BitmapCacheOption.OnLoad);
186 var frame = decoder.Frames
187 .OrderByDescending(candidate => (long)candidate.PixelWidth * candidate.PixelHeight)
188 .FirstOrDefault()
189 ?? throw new InvalidDataException("图标中没有可显示的位图帧。");
190 if (frame.CanFreeze)
191 frame.Freeze();
192 return frame;
193 }
194 catch (Exception exception) when (exception is NotSupportedException or FileFormatException or ArgumentException)
195 {
196 throw new InvalidDataException("不支持的图标格式或图标内容已损坏。", exception);
197 }
198 }
199
200 private static ImageSource DecodeSvg(byte[] bytes)
201 {
202 var drawingSettings = new WpfDrawingSettings
203 {
204 IncludeRuntime = false,
205 // 软件图标无需将文字逐字转成路径,也无需在首次加载时重写全部路径。
206 // 关闭这两项可显著降低复杂 SVG 的解析与内存成本。
207 TextAsGeometry = false,
208 OptimizePath = false,
209 CanUseBitmap = false,
210 ExternalResourcesAccessMode = ExternalResourcesAccessModes.Ignore
211 };
212
213 using var stream = new MemoryStream(bytes, writable: false);
214 var xmlSettings = new XmlReaderSettings
215 {
216 DtdProcessing = DtdProcessing.Prohibit,
217 XmlResolver = null,
218 MaxCharactersInDocument = MaximumImageBytes
219 };
220 using var xmlReader = XmlReader.Create(stream, xmlSettings);
221 using var svgReader = new FileSvgReader(drawingSettings, isEmbedded: true);
222 var drawing = svgReader.Read(xmlReader)
223 ?? throw new InvalidDataException("SVG 图标没有生成可显示内容。");
224 if (drawing.CanFreeze)
225 drawing.Freeze();
226
227 var image = new DrawingImage(drawing);
228 if (image.CanFreeze)
229 image.Freeze();
230 return image;
231 }
232
233 private static async Task<ImageSource> RunDecodeAsync(Func<ImageSource> decode)
234 {
235 await DecodeGate.WaitAsync().ConfigureAwait(false);
236 try
237 {
238 return await Task.Run(decode).ConfigureAwait(false);
239 }
240 finally
241 {
242 DecodeGate.Release();
243 }
244 }
245
246 private static async Task<ImageSource> DecodeForDisplayAsync(
247 byte[] bytes,
248 string? mediaType,
249 string? sourceName,
250 Dispatcher? displayDispatcher)
251 {
252 if (IsGif(bytes, mediaType, sourceName) &&
253 displayDispatcher is { HasShutdownStarted: false, HasShutdownFinished: false })
254 {
255 if (displayDispatcher.CheckAccess())
256 return Decode(bytes, mediaType, sourceName);
257
258 return await displayDispatcher
259 .InvokeAsync(
260 () => Decode(bytes, mediaType, sourceName),
261 DispatcherPriority.Background)
262 .Task
263 .ConfigureAwait(false);
264 }
265
266 return await RunDecodeAsync(() => Decode(bytes, mediaType, sourceName)).ConfigureAwait(false);
267 }
268
269 private static bool IsGif(byte[] bytes, string? mediaType, string? sourceName)
270 {
271 if (string.Equals(mediaType, "image/gif", StringComparison.OrdinalIgnoreCase) ||
272 HasExtension(sourceName, ".gif"))
273 return true;
274
275 return bytes.Length >= 6 &&
276 bytes[0] == (byte)'G' && bytes[1] == (byte)'I' && bytes[2] == (byte)'F' &&
277 bytes[3] == (byte)'8' && (bytes[4] == (byte)'7' || bytes[4] == (byte)'9') &&
278 bytes[5] == (byte)'a';
279 }
280
281 private static Dispatcher? GetDisplayDispatcher()
282 {
283 if (Thread.CurrentThread.GetApartmentState() == ApartmentState.STA)
284 return Dispatcher.FromThread(Thread.CurrentThread) ?? Dispatcher.CurrentDispatcher;
285 return Application.Current?.Dispatcher;
286 }
287
288 private static bool IsSvg(byte[] bytes, string? mediaType, string? sourceName)
289 {
290 if (string.Equals(mediaType, "image/svg+xml", StringComparison.OrdinalIgnoreCase) ||
291 HasExtension(sourceName, ".svg") || HasExtension(sourceName, ".svgz"))
292 return true;
293
294 var prefixLength = Math.Min(bytes.Length, 4096);
295 var prefix = Encoding.UTF8.GetString(bytes, 0, prefixLength)
296 .TrimStart('\uFEFF', ' ', '\t', '\r', '\n');
297 return prefix.Contains("<svg", StringComparison.OrdinalIgnoreCase);
298 }
299
300 private static byte[] DecompressSvgzIfNeeded(byte[] bytes, string? mediaType, string? sourceName)
301 {
302 var isGzip = bytes.Length >= 2 && bytes[0] == 0x1F && bytes[1] == 0x8B;
303 if (!isGzip)
304 return bytes;
305 if (!string.Equals(mediaType, "image/svg+xml", StringComparison.OrdinalIgnoreCase) &&
306 !HasExtension(sourceName, ".svgz"))
307 return bytes;
308
309 using var input = new MemoryStream(bytes, writable: false);
310 using var gzip = new GZipStream(input, CompressionMode.Decompress);
311 return ReadLimited(gzip);
312 }
313
314 private static byte[] ReadLimited(Stream stream)
315 {
316 using var output = new MemoryStream();
317 var buffer = new byte[32 * 1024];
318 while (true)
319 {
320 var count = stream.Read(buffer, 0, buffer.Length);
321 if (count == 0)
322 break;
323 if (output.Length + count > MaximumImageBytes)
324 throw new InvalidDataException($"解压后的 SVG 超过 {MaximumImageBytes / 1024 / 1024} MB 限制。");
325 output.Write(buffer, 0, count);
326 }
327 return output.ToArray();
328 }
329
330 private static bool HasExtension(string? sourceName, string extension)
331 {
332 if (string.IsNullOrWhiteSpace(sourceName))
333 return false;
334 var path = sourceName.Split('?', '#')[0];
335 return path.EndsWith(extension, StringComparison.OrdinalIgnoreCase);
336 }
337
338 private static string CreateCacheKey(string value)
339 {
340 if (!value.StartsWith("data:image/", StringComparison.OrdinalIgnoreCase))
341 return value;
342 var hash = SHA256.HashData(Encoding.UTF8.GetBytes(value));
343 return $"data:{Convert.ToHexString(hash)}";
344 }
345
346 private static HttpClient CreateImageClient()
347 {
348 var handler = new SocketsHttpHandler
349 {
350 AutomaticDecompression = DecompressionMethods.GZip | DecompressionMethods.Deflate | DecompressionMethods.Brotli,
351 ConnectTimeout = TimeSpan.FromSeconds(8)
352 };
353 var client = new HttpClient(handler) { Timeout = TimeSpan.FromSeconds(15) };
354 client.DefaultRequestHeaders.UserAgent.ParseAdd("XFEToolBox/1.1");
355 return client;
356 }
357 }
Modified XFEToolBox/ViewModel/Pages/RecentUsageCardViewModel.cs +23 -32
@@ -1,10 +1,10 @@
1 using System.IO;
2 1 using System.Text.Json;
3 2 using System.Windows.Media;
4 3 using System.Windows.Media.Imaging;
5 4 using CommunityToolkit.Mvvm.ComponentModel;
6 5 using XFEToolBox.Client.Models;
7 6 using XFEToolBox.Client.Profiles.CacheProfiles;
7 using XFEToolBox.Client.Utilities;
8 8 using XFEToolBox.Core.Downloads;
9 9 using XFEToolBox.Core.Tools;
10 10
@@ -15,7 +15,8 @@ public partial class RecentUsageCardViewModel : ObservableObject
15 15 public RecentUsageCardViewModel(RecentUsageEntry entry)
16 16 {
17 17 Entry = entry;
18 IconSource = CreateIconSource(entry);
18 IconSource = CreateFallbackIcon(entry);
19 _ = LoadConfiguredIconAsync(entry);
19 20 }
20 21
21 22 public RecentUsageEntry Entry { get; }
@@ -30,46 +31,36 @@ public partial class RecentUsageCardViewModel : ObservableObject
30 31 };
31 32
32 33 public string LastUsedText => FormatLastUsed(Entry.LastUsedAtUtc);
33 public ImageSource IconSource { get; }
34 [ObservableProperty]
35 private ImageSource iconSource;
34 36
35 37 [ObservableProperty]
36 38 private bool isEnabled = true;
37 39
38 private static ImageSource CreateIconSource(RecentUsageEntry entry)
40 private async Task LoadConfiguredIconAsync(RecentUsageEntry entry)
39 41 {
40 42 var reference = string.IsNullOrWhiteSpace(entry.IconReference)
41 43 ? ResolveCatalogIcon(entry)
42 44 : entry.IconReference;
43 if (!string.IsNullOrWhiteSpace(reference))
45 if (string.IsNullOrWhiteSpace(reference) || reference.StartsWith('/'))
46 return;
47
48 try
44 49 {
45 try
46 {
47 if (reference.StartsWith("data:image/", StringComparison.OrdinalIgnoreCase))
48 {
49 var separator = reference.IndexOf(',');
50 if (separator > 0)
51 {
52 using var stream = new MemoryStream(Convert.FromBase64String(reference[(separator + 1)..]));
53 var dataImage = new BitmapImage();
54 dataImage.BeginInit();
55 dataImage.CacheOption = BitmapCacheOption.OnLoad;
56 dataImage.DecodePixelWidth = 96;
57 dataImage.StreamSource = stream;
58 dataImage.EndInit();
59 dataImage.Freeze();
60 return dataImage;
61 }
62 }
63 else if (reference.StartsWith('/'))
64 {
65 return CreateResourceImage(reference);
66 }
67 }
68 catch
69 {
70 // 损坏或不再可用的图标回退到对应类型的内置图标。
71 }
50 var image = await WebImageSourceLoader.LoadAsync(reference);
51 if (image is not null)
52 IconSource = image;
53 }
54 catch
55 {
56 // 网络或图标格式不可用时保留对应类型的内置图标。
72 57 }
58 }
59
60 private static ImageSource CreateFallbackIcon(RecentUsageEntry entry)
61 {
62 if (!string.IsNullOrWhiteSpace(entry.IconReference) && entry.IconReference.StartsWith('/'))
63 return CreateResourceImage(entry.IconReference);
73 64
74 65 return CreateResourceImage(entry.Kind switch
75 66 {
Modified XFEToolBox/Views/Pages/Admin/SoftwareManagementPage.xaml +5 -2
@@ -1,6 +1,7 @@
1 1 <Page x:Class="XFEToolBox.Client.Views.Pages.Admin.SoftwareManagementPage"
2 2 xmlns="http://schemas.microsoft.com/winfx/2006/xaml/presentation"
3 3 xmlns:x="http://schemas.microsoft.com/winfx/2006/xaml"
4 xmlns:gif="http://wpfanimatedgif.codeplex.com"
4 5 xmlns:controls="clr-namespace:XFEToolBox.WpfCore.Controls;assembly=XFEToolBox.WpfCore"
5 6 Background="Transparent" Title="" Loaded="Page_Loaded">
6 7 <Page.Resources>
@@ -150,7 +151,9 @@
150 151 </Style>
151 152 </TextBlock.Style>
152 153 </TextBlock>
153 <Image Source="{Binding IconSource}" Width="31" Height="31" Stretch="Uniform">
154 <Image gif:ImageBehavior.AnimatedSource="{Binding IconSource}"
155 gif:ImageBehavior.RepeatBehavior="Forever"
156 Width="31" Height="31" Stretch="Uniform">
154 157 <Image.Style>
155 158 <Style TargetType="Image">
156 159 <Setter Property="Visibility" Value="Visible"/>
@@ -270,7 +273,7 @@
270 273 <Grid Margin="0,12,0,0">
271 274 <Grid.ColumnDefinitions><ColumnDefinition Width="*"/><ColumnDefinition Width="12"/><ColumnDefinition Width="*"/></Grid.ColumnDefinitions>
272 275 <StackPanel><TextBlock Text="官网地址" Style="{StaticResource AdminFormLabel}"/><controls:TextEditor x:Name="WebsiteTextBox" Style="{StaticResource AdminTextEditor}" HintText="https://"/></StackPanel>
273 <StackPanel Grid.Column="2"><TextBlock Text="图标地址(可留空)" Style="{StaticResource AdminFormLabel}"/><controls:TextEditor x:Name="IconTextBox" Style="{StaticResource AdminTextEditor}" HintText="HTTP(S) 或 data:image"/></StackPanel>
276 <StackPanel Grid.Column="2"><TextBlock Text="图标地址(可留空)" Style="{StaticResource AdminFormLabel}"/><controls:TextEditor x:Name="IconTextBox" Style="{StaticResource AdminTextEditor}" HintText="HTTP(S) 或 data:image,支持 PNG / ICO / SVG / GIF 等"/></StackPanel>
274 277 </Grid>
275 278 <TextBlock Text="详细介绍" Style="{StaticResource AdminFormLabel}" Margin="2,12,0,5"/>
276 279 <controls:TextEditor x:Name="DescriptionTextBox" Height="82" Style="{StaticResource AdminTextEditor}" Padding="11,8" AcceptsReturn="True" TextWrapping="Wrap" VerticalContentAlignment="Top" VerticalScrollBarVisibility="Auto" HintText="介绍主要功能和使用场景"/>
Modified XFEToolBox/Views/Pages/Admin/SoftwareManagementPage.xaml.cs +1 -41
@@ -2,7 +2,6 @@ using System.Collections.ObjectModel;
2 2 using System.ComponentModel;
3 3 using System.IO;
4 4 using System.Net;
5 using System.Net.Http;
6 5 using System.Runtime.CompilerServices;
7 6 using System.Windows;
8 7 using System.Windows.Controls;
@@ -21,7 +20,6 @@ public partial class SoftwareManagementPage : Page
21 20 {
22 21 public static SoftwareManagementPage Current { get; } = new();
23 22
24 private static readonly HttpClient IconClient = CreateIconClient();
25 23 private static readonly SemaphoreSlim IconLoadGate = new(4);
26 24 private readonly ObservableCollection<SoftwareCatalogItem> _software = [];
27 25 private readonly ObservableCollection<SoftwareCatalogRow> _softwareRows = [];
@@ -253,7 +251,7 @@ public partial class SoftwareManagementPage : Page
253 251 await IconLoadGate.WaitAsync();
254 252 try
255 253 {
256 var icon = await ReadIconAsync(row.Item.IconUrl);
254 var icon = await WebImageSourceLoader.LoadAsync(row.Item.IconUrl);
257 255 if (icon is not null)
258 256 row.IconSource = icon;
259 257 }
@@ -267,37 +265,6 @@ public partial class SoftwareManagementPage : Page
267 265 }
268 266 }
269 267
270 private static async Task<ImageSource?> ReadIconAsync(string value)
271 {
272 byte[] bytes;
273 if (value.StartsWith("data:image/", StringComparison.OrdinalIgnoreCase))
274 {
275 var separator = value.IndexOf(',');
276 if (separator < 0)
277 return null;
278 bytes = Convert.FromBase64String(value[(separator + 1)..]);
279 }
280 else
281 {
282 if (!Uri.TryCreate(value, UriKind.Absolute, out var uri) ||
283 (uri.Scheme != Uri.UriSchemeHttp && uri.Scheme != Uri.UriSchemeHttps))
284 return null;
285 bytes = await IconClient.GetByteArrayAsync(uri);
286 }
287
288 if (bytes.Length == 0 || bytes.Length > 1024 * 1024)
289 return null;
290
291 using var stream = new MemoryStream(bytes);
292 var image = new BitmapImage();
293 image.BeginInit();
294 image.CacheOption = BitmapCacheOption.OnLoad;
295 image.StreamSource = stream;
296 image.EndInit();
297 image.Freeze();
298 return image;
299 }
300
301 268 private static ImageSource? GetBundledIcon(string id)
302 269 {
303 270 var resource = id.ToLowerInvariant() switch
@@ -316,13 +283,6 @@ public partial class SoftwareManagementPage : Page
316 283 return image;
317 284 }
318 285
319 private static HttpClient CreateIconClient()
320 {
321 var client = new HttpClient { Timeout = TimeSpan.FromSeconds(15) };
322 client.DefaultRequestHeaders.UserAgent.ParseAdd("XFEToolBox/1.0");
323 return client;
324 }
325
326 286 private void NewButton_Click(object sender, RoutedEventArgs e)
327 287 {
328 288 _isCreating = true;
Modified XFEToolBox/Views/Pages/DownloadInfoPage.xaml +4 -1
@@ -1,6 +1,7 @@
1 1 <Page x:Class="XFEToolBox.Client.Views.Pages.DownloadInfoPage"
2 2 xmlns="http://schemas.microsoft.com/winfx/2006/xaml/presentation"
3 3 xmlns:x="http://schemas.microsoft.com/winfx/2006/xaml"
4 xmlns:gif="http://wpfanimatedgif.codeplex.com"
4 5 xmlns:controls="clr-namespace:XFEToolBox.WpfCore.Controls;assembly=XFEToolBox.WpfCore"
5 6 Background="White" Title="软件下载详情">
6 7 <ScrollViewer VerticalScrollBarVisibility="Auto" HorizontalScrollBarVisibility="Disabled">
@@ -15,7 +16,9 @@
15 16 <Grid.ColumnDefinitions><ColumnDefinition Width="74"/><ColumnDefinition Width="*"/></Grid.ColumnDefinitions>
16 17 <Border Width="58" Height="58" Background="White" CornerRadius="17" HorizontalAlignment="Left" VerticalAlignment="Top">
17 18 <Border.Effect><DropShadowEffect Color="#7777C9" BlurRadius="12" ShadowDepth="2" Opacity="0.12"/></Border.Effect>
18 <Image Source="{Binding IconSource}" Width="38" Height="38" Stretch="Uniform"/>
19 <Image gif:ImageBehavior.AnimatedSource="{Binding IconSource}"
20 gif:ImageBehavior.RepeatBehavior="Forever"
21 Width="38" Height="38" Stretch="Uniform"/>
19 22 </Border>
20 23 <StackPanel Grid.Column="1" VerticalAlignment="Center">
21 24 <TextBlock Text="{Binding AppTitle}" Foreground="#343448" FontSize="21" FontWeight="Bold" TextTrimming="CharacterEllipsis"/>
Modified XFEToolBox/Views/Pages/DownloadPage.xaml +4 -1
Modified XFEToolBox/Views/Pages/DownloadPage.xaml.cs +1 -37
Modified XFEToolBox/Views/Pages/MainPage.xaml +4 -1
Modified XFEToolBox/XFEToolBox.Client.csproj +3 -1
Deleted XfeTestArtifacts/test-results.json +0 -32
Deleted XfeTestArtifacts/test-results.xml +0 -4