Skip to content

Commit a5bfd13

Browse files
committed
Feat | BaseHttpHelper
1 parent c15a789 commit a5bfd13

3 files changed

Lines changed: 303 additions & 4 deletions

File tree

Lines changed: 292 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,292 @@
1+
using System;
2+
using System.Collections.Generic;
3+
using System.IO;
4+
using System.Linq;
5+
using System.Net;
6+
using System.Net.Http;
7+
using System.Text;
8+
using System.Text.Json;
9+
using System.Threading;
10+
using System.Threading.Tasks;
11+
using Serilog;
12+
13+
namespace ShadowPluginLoader.WinUI.Helpers;
14+
15+
/// <summary>
16+
/// HttpHelper
17+
/// </summary>
18+
public class BaseHttpHelper
19+
{
20+
/// <summary>
21+
/// Lazy, thread-safe singleton instance
22+
/// </summary>
23+
private static readonly Lazy<BaseHttpHelper> InnerInstance = new(() => new BaseHttpHelper(), LazyThreadSafetyMode.ExecutionAndPublication);
24+
25+
/// <summary>
26+
/// 获取单例实例(线程安全、惰性初始化)
27+
/// </summary>
28+
public static BaseHttpHelper Instance => InnerInstance.Value;
29+
30+
/// <summary>
31+
/// 私有构造函数,防止外部直接实例化
32+
/// </summary>
33+
protected BaseHttpHelper()
34+
{
35+
// 初始化 HttpClient(默认不使用代理)
36+
Client = new HttpClient(new HttpClientHandler()) { Timeout = TimeSpan.FromSeconds(100) };
37+
}
38+
39+
/// <summary>
40+
/// HttpClient 可以在运行时被替换(例如切换代理),因此不是 readonly
41+
/// </summary>
42+
protected HttpClient Client;
43+
private readonly object _clientLock = new();
44+
/// <summary>
45+
/// Proxy
46+
/// </summary>
47+
protected IWebProxy? Proxy;
48+
private readonly JsonSerializerOptions _jsonOptions = new() { PropertyNameCaseInsensitive = true };
49+
50+
/// <summary>
51+
/// 创建HttpRequestMessage
52+
/// </summary>
53+
/// <param name="method"></param>
54+
/// <param name="url"></param>
55+
/// <param name="headers"></param>
56+
/// <returns></returns>
57+
public HttpRequestMessage CreateRequestMessage(HttpMethod method, string url,
58+
Dictionary<string, string>? headers = null)
59+
{
60+
var httpRequestMessage = new HttpRequestMessage(method, url);
61+
// 默认接受 JSON
62+
httpRequestMessage.Headers.Accept.Clear();
63+
httpRequestMessage.Headers.Accept.Add(new System.Net.Http.Headers.MediaTypeWithQualityHeaderValue("application/json"));
64+
65+
if (headers == null) return httpRequestMessage;
66+
foreach (var header in headers.Where(header => !string.IsNullOrEmpty(header.Key) && !string.IsNullOrEmpty(header.Value)))
67+
{
68+
try
69+
{
70+
httpRequestMessage.Headers.Add(header.Key, header.Value);
71+
}
72+
catch
73+
{
74+
// 忽略无法添加为 Header 的键(例如 Content headers),调用方应通过 Content 设置
75+
}
76+
}
77+
78+
return httpRequestMessage;
79+
}
80+
81+
/// <summary>
82+
/// GET 并将 JSON 反序列化为 T
83+
/// </summary>
84+
public async Task<T?> GetAsync<T>(string url, Dictionary<string, string>? query = null, Dictionary<string, string>? headers = null, CancellationToken cancellationToken = default)
85+
{
86+
try
87+
{
88+
// 支持可选的 query 参数
89+
var finalUrl = AppendQueryToUrl(url, query);
90+
using var request = CreateRequestMessage(HttpMethod.Get, finalUrl, headers);
91+
Log.Debug("[GET] Sending request to {Url}", url);
92+
93+
// 抓取当前 HttpClient 引用,避免在重置时发生竞争
94+
var client = Client;
95+
using var response = await client.SendAsync(request, cancellationToken);
96+
var respText = await response.Content.ReadAsStringAsync(cancellationToken);
97+
98+
Log.Debug("[GET] Response from {Url}: {Resp}", url, respText);
99+
// 记录重要信息
100+
var respLen = respText.Length;
101+
Log.Information("[GET] {Url} -> {StatusCode}, length={Len}", url, response.StatusCode, respLen);
102+
103+
response.EnsureSuccessStatusCode();
104+
105+
// 如果响应为空,则返回默认值
106+
if (string.IsNullOrWhiteSpace(respText)) return default;
107+
return JsonSerializer.Deserialize<T>(respText, _jsonOptions);
108+
}
109+
catch (Exception ex)
110+
{
111+
Log.Error(ex, "GetAsync failed for {Url}", url);
112+
throw;
113+
}
114+
}
115+
116+
// 将查询参数正确追加到 URL(处理已有 query 并对键/值进行编码)
117+
private static string AppendQueryToUrl(string url, Dictionary<string, string>? query)
118+
{
119+
if (query == null || query.Count == 0) return url;
120+
121+
try
122+
{
123+
var ub = new UriBuilder(url);
124+
// ub.Query 带前导 '?', 去掉
125+
var existing = ub.Query;
126+
if (!string.IsNullOrEmpty(existing) && existing.StartsWith("?")) existing = existing.Substring(1);
127+
128+
var parts = new List<string>();
129+
if (!string.IsNullOrEmpty(existing)) parts.Add(existing);
130+
foreach (var kv in query)
131+
{
132+
var k = Uri.EscapeDataString(kv.Key);
133+
var v = Uri.EscapeDataString(kv.Value);
134+
parts.Add($"{k}={v}");
135+
}
136+
137+
ub.Query = string.Join("&", parts);
138+
return ub.Uri.ToString();
139+
}
140+
catch
141+
{
142+
// fallback: 直接拼接
143+
var sb = new StringBuilder(url);
144+
sb.Append(url.Contains("?") ? "&" : "?");
145+
sb.Append(string.Join("&", query.Select(kv => $"{Uri.EscapeDataString(kv.Key)}={Uri.EscapeDataString(kv.Value)}")));
146+
return sb.ToString();
147+
}
148+
}
149+
150+
/// <summary>
151+
/// POST JSON 并将响应反序列化为 TResponse
152+
/// </summary>
153+
public async Task<TResponse?> PostJsonAsync<TRequest, TResponse>(string url, TRequest payload, Dictionary<string, string>? headers = null, CancellationToken cancellationToken = default)
154+
{
155+
try
156+
{
157+
using var request = CreateRequestMessage(HttpMethod.Post, url, headers);
158+
var json = JsonSerializer.Serialize(payload, _jsonOptions);
159+
request.Content = new StringContent(json, Encoding.UTF8, "application/json");
160+
161+
Log.Debug("[POST] Sending to {Url}, payload: {Payload}", url, json);
162+
163+
var client = Client;
164+
using var response = await client.SendAsync(request, cancellationToken);
165+
var respText = await response.Content.ReadAsStringAsync(cancellationToken);
166+
167+
Log.Debug("[POST] Response from {Url}: {Resp}", url, respText);
168+
var respLen = respText.Length;
169+
Log.Information("[POST] {Url} -> {StatusCode}, response-length={Len}", url, response.StatusCode, respLen);
170+
171+
response.EnsureSuccessStatusCode();
172+
173+
if (string.IsNullOrWhiteSpace(respText)) return default;
174+
return JsonSerializer.Deserialize<TResponse>(respText, _jsonOptions);
175+
}
176+
catch (Exception ex)
177+
{
178+
Log.Error(ex, "PostJsonAsync failed for {Url}", url);
179+
throw;
180+
}
181+
}
182+
183+
/// <summary>
184+
/// 下载文件并返回字节数组
185+
/// </summary>
186+
public async Task<byte[]> GetFileAsync(string url, Dictionary<string, string>? headers = null, CancellationToken cancellationToken = default)
187+
{
188+
try
189+
{
190+
using var request = CreateRequestMessage(HttpMethod.Get, url, headers);
191+
Log.Debug("[GET FILE] Downloading from {Url}", url);
192+
193+
var client = Client;
194+
using var response = await client.SendAsync(request, cancellationToken);
195+
var bytes = await response.Content.ReadAsByteArrayAsync(cancellationToken);
196+
var len = bytes.Length;
197+
Log.Information("[GET FILE] {Url} downloaded {Len} bytes", url, len);
198+
Log.Debug("[GET FILE] First 128 bytes (hex) of {Url}: {Preview}", url, BitConverter.ToString(bytes, 0, Math.Min(128, len)));
199+
200+
response.EnsureSuccessStatusCode();
201+
return bytes;
202+
}
203+
catch (Exception ex)
204+
{
205+
Log.Error(ex, "GetFileAsync failed for {Url}", url);
206+
throw;
207+
}
208+
}
209+
210+
/// <summary>
211+
/// 下载并保存到磁盘
212+
/// </summary>
213+
public async Task SaveFileAsync(string url, string destPath, Dictionary<string, string>? headers = null, CancellationToken cancellationToken = default)
214+
{
215+
try
216+
{
217+
var bytes = await GetFileAsync(url, headers, cancellationToken);
218+
219+
var dir = Path.GetDirectoryName(destPath);
220+
if (!string.IsNullOrEmpty(dir) && !Directory.Exists(dir)) Directory.CreateDirectory(dir);
221+
222+
await File.WriteAllBytesAsync(destPath, bytes, cancellationToken);
223+
224+
Log.Information("[SAVE FILE] Saved {Url} -> {Path} (len={Len})", url, destPath, bytes.Length);
225+
Log.Debug("[SAVE FILE] Saved file from {Url} to {Path}", url, destPath);
226+
}
227+
catch (Exception ex)
228+
{
229+
Log.Error(ex, "SaveFileAsync failed for {Url} -> {Path}", url, destPath);
230+
throw;
231+
}
232+
}
233+
234+
/// <summary>
235+
/// 配置代理:传入 proxyUrl 设置代理,传入 null 则取消代理。
236+
/// 支持用户名/密码和是否使用默认凭据。
237+
/// </summary>
238+
public void ConfigureProxy(string? proxyUrl, string? username = null, string? password = null, bool bypassOnLocal = false, bool useDefaultCredentials = false)
239+
{
240+
IWebProxy? proxy = null;
241+
if (!string.IsNullOrWhiteSpace(proxyUrl))
242+
{
243+
var wp = new WebProxy(proxyUrl) { BypassProxyOnLocal = bypassOnLocal };
244+
if (!string.IsNullOrEmpty(username)) wp.Credentials = new NetworkCredential(username, password ?? string.Empty);
245+
proxy = wp;
246+
}
247+
248+
ConfigureProxy(proxy, useDefaultCredentials);
249+
}
250+
251+
/// <summary>
252+
/// 配置代理(IWebProxy 形式)。线程安全,会重建 HttpClient 并替换当前实例。
253+
/// </summary>
254+
public void ConfigureProxy(IWebProxy? proxy, bool useDefaultCredentials = false)
255+
{
256+
lock (_clientLock)
257+
{
258+
Proxy = proxy;
259+
260+
var old = Client;
261+
var handler = new HttpClientHandler();
262+
if (proxy != null)
263+
{
264+
handler.Proxy = proxy;
265+
handler.UseProxy = true;
266+
handler.UseDefaultCredentials = useDefaultCredentials;
267+
}
268+
else
269+
{
270+
handler.UseProxy = false;
271+
}
272+
273+
Client = new HttpClient(handler) { Timeout = old.Timeout };
274+
try
275+
{
276+
old.Dispose();
277+
}
278+
catch
279+
{
280+
// 忽略 dispose 异常
281+
}
282+
283+
Log.Information("[PROXY] Configured proxy: {Proxy}, UseDefaultCredentials={UseDefaultCredentials}", proxy?.ToString() ?? "none", useDefaultCredentials);
284+
Log.Debug("[PROXY] Proxy object details: {@Proxy}", proxy);
285+
}
286+
}
287+
288+
/// <summary>
289+
/// 获取当前代理的 ToString() 表示,或 null 表示未配置代理
290+
/// </summary>
291+
public string? GetCurrentProxy() => Proxy?.ToString();
292+
}

ShadowPluginLoader.WinUI/Installer/ZipPluginInstaller.cs

Lines changed: 9 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -36,9 +36,16 @@ public async Task<List<SortPluginData<TMeta>>> InstallAsync(IEnumerable<string>
3636
var sortDataList = new List<SortPluginData<TMeta>>();
3737
foreach (var shadowFile in shadowFiles)
3838
{
39+
var targetFile = shadowFile;
40+
if (shadowFile.StartsWith("http"))
41+
{
42+
var tempFile = Path.Combine(BaseSdkConfig.TempFolderPath, Path.GetFileName(shadowFile));
43+
await BaseHttpHelper.Instance.SaveFileAsync(shadowFile, tempFile);
44+
targetFile = tempFile;
45+
}
3946
sortDataList.Add(new SortPluginData<TMeta>(
40-
await MetaDataHelper.ToMetaAsyncFromZip<TMeta>(shadowFile),
41-
shadowFile));
47+
await MetaDataHelper.ToMetaAsyncFromZip<TMeta>(targetFile),
48+
targetFile));
4249
}
4350

4451
var res = DependencyChecker.DetermineLoadOrder(sortDataList);

ShadowPluginLoader.WinUI/ShadowPluginLoader.WinUI.csproj

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -13,7 +13,7 @@
1313
<AllowUnsafeBlocks>true</AllowUnsafeBlocks>
1414
<LangVersion>12</LangVersion>
1515
<!-- Nuget -->
16-
<Version>3.0.14</Version>
16+
<Version>3.0.16</Version>
1717
<PackageId>ShadowPluginLoader.WinUI</PackageId>
1818
<Owner>kitUIN</Owner>
1919
<Authors>kitUIN</Authors>
@@ -43,7 +43,7 @@
4343
<PackageReference Include="ShadowPluginLoader.SourceGenerator" Version="3.0.7" />
4444
<PackageReference Include="ShadowObservableConfig.Yaml" Version="0.6.3" />
4545
<PackageReference Include="ShadowObservableConfig.Json" Version="0.6.3" />
46-
<PackageReference Include="ShadowPluginLoader.Tool" Version="3.0.14" />
46+
<PackageReference Include="ShadowPluginLoader.Tool" Version="3.0.16" />
4747
<PackageReference Include="NuGet.Versioning" Version="7.0.0" />
4848
<PackageReference Include="SharpCompress" Version="0.41.0" />
4949
</ItemGroup>

0 commit comments

Comments
 (0)