返回提交历史
Modified
XFEToolBox.Client.Installer/XFEToolBox.Client.Installer.csproj
+2
-2
Added
XFEToolBox.Client.Wpf.Test/WebImageSourceLoaderTests.cs
+232
-0
Modified
XFEToolBox.Client.Wpf.Test/XFEToolBox.Client.Wpf.Test.csproj
+9
-1
Modified
XFEToolBox.Core/XFEToolBox.Core.csproj
+2
-2
Modified
XFEToolBox.Server/Profiles/ServerProfile.cs
+14
-14
Modified
XFEToolBox.Server/XFEToolBox.Server.csproj
+1
-1
Modified
XFEToolBox.Test/XFEToolBox.Client.Test.csproj
+1
-1
Added
XFEToolBox/Utilities/WebImageSourceLoader.cs
+357
-0
Modified
XFEToolBox/ViewModel/Pages/RecentUsageCardViewModel.cs
+23
-32
Modified
XFEToolBox/Views/Pages/Admin/SoftwareManagementPage.xaml
+5
-2
Modified
XFEToolBox/Views/Pages/Admin/SoftwareManagementPage.xaml.cs
+1
-41
Modified
XFEToolBox/Views/Pages/DownloadInfoPage.xaml
+4
-1
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
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
代码差异
18 个文件
+663
-173
@@ -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>
@@ -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
}
@@ -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>
@@ -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
@@ -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
}
@@ -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>
@@ -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>
@@ -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
}
@@ -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
{
@@ -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="介绍主要功能和使用场景"/>
@@ -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;
@@ -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"/>