Refactor download (#10534)

* Refactor download

* webapi

* Fallback

* Fix Sub

* Life cycle

* Yet another way of the life cycle

* Shared CookieContainer

* Fix
This commit is contained in:
DHR60
2026-11-08 07:47:24 +00:00
committed by GitHub
parent 6e48e507ea
commit 92a9da8b6b
24 changed files with 976 additions and 822 deletions
@@ -22,15 +22,21 @@ public class DownloadServiceHeadersTests
} }
"""; """;
await HttpRequestHeadersHelper.TryParse(json, out var headers).Should().BeTrue(); await HttpRequestHeadersHelper.TryParse(json, out var headers).Should().BeTrue();
var service = new DownloadService { AcceptHeader = "*/*", RequestHeaders = headers }; IWebProxy? proxy = useProxy ? new WebProxy(server.Url) : null;
var handler = proxy is null ? HttpClientHelper.Instance.DirectHandler : HttpClientHelper.CreateProxySocketsHttpHandler(proxy, new());
var headersHandler = HttpRequestHeadersHelper.CreateHandler(handler, "CustomSubscriptionClient/1.0", "*/*", headers);
using var service = new DownloadService
{
Handler = headersHandler,
DisposeHandler = true,
};
var uri = new UriBuilder(useProxy ? "http://subscription.invalid/sub" : server.Url) var uri = new UriBuilder(useProxy ? "http://subscription.invalid/sub" : server.Url)
{ {
UserName = "user", UserName = "user",
Password = "password" Password = "password",
}.Uri; }.Uri;
IWebProxy? proxy = useProxy ? new WebProxy(server.Url) : null;
var content = await service.TryDownloadString(uri.AbsoluteUri, proxy, "OriginalClient/1.0").WaitAsync(TimeSpan.FromSeconds(20)); var content = await service.TryDownloadString(uri.AbsoluteUri).WaitAsync(TimeSpan.FromSeconds(20));
await content.Should().BeEqualTo(SubscriptionHttpServer.Body); await content.Should().BeEqualTo(SubscriptionHttpServer.Body);
await (server.Requests.Count >= (failFirstRequest ? 2 : 1)).Should().BeTrue(); await (server.Requests.Count >= (failFirstRequest ? 2 : 1)).Should().BeTrue();
@@ -52,12 +58,13 @@ public class DownloadServiceHeadersTests
{ {
await CertPemManager.Instance.Init(new Config { GuiItem = new GUIItem() }); await CertPemManager.Instance.Init(new Config { GuiItem = new GUIItem() });
await using var server = new SubscriptionHttpServer(failFirstRequest); await using var server = new SubscriptionHttpServer(failFirstRequest);
var service = new DownloadService { AcceptHeader = "*/*" }; using var handler = HttpRequestHeadersHelper.CreateHandler(HttpClientHelper.CreateSocketsHttpHandler(false, new()), "ExistingClient/1.0", "*/*", null);
var service = new DownloadService { Handler = handler };
var helper = new DownloaderHelper { Handler = handler };
var uri = new UriBuilder(server.Url) { UserName = "user", Password = "password" }.Uri; var uri = new UriBuilder(server.Url) { UserName = "user", Password = "password" }.Uri;
var content = await service.TryDownloadString(uri.AbsoluteUri, (IWebProxy?)null, "ExistingClient/1.0").WaitAsync(TimeSpan.FromSeconds(20)); await (await service.TryDownloadString(uri.AbsoluteUri).WaitAsync(TimeSpan.FromSeconds(20))).Should().BeEqualTo(SubscriptionHttpServer.Body);
await (await helper.DownloadStringAsync(uri.AbsoluteUri).WaitAsync(TimeSpan.FromSeconds(20))).Should().BeEqualTo(SubscriptionHttpServer.Body);
await content.Should().BeEqualTo(SubscriptionHttpServer.Body);
foreach (var request in server.Requests) foreach (var request in server.Requests)
{ {
await request["Accept"].Should().BeEqualTo("*/*"); await request["Accept"].Should().BeEqualTo("*/*");
@@ -72,15 +79,16 @@ public class DownloadServiceHeadersTests
{ {
await CertPemManager.Instance.Init(new Config { GuiItem = new GUIItem() }); await CertPemManager.Instance.Init(new Config { GuiItem = new GUIItem() });
await using var server = new SubscriptionHttpServer(); await using var server = new SubscriptionHttpServer();
var subscription = new DownloadService var handler = HttpRequestHeadersHelper.CreateHandler(HttpClientHelper.CreateSocketsHttpHandler(false, new()), "TestClient/1.0", "*/*", new Dictionary<string, string> { ["X-hwid"] = "first-device" });
using var subscription = new DownloadService
{ {
AcceptHeader = "*/*", Handler = handler,
RequestHeaders = new Dictionary<string, string> { ["X-hwid"] = "first-device" } DisposeHandler = true,
}; };
var ordinaryDownload = new DownloadService(); var ordinaryDownload = new DownloadService();
await (await subscription.TryDownloadString(server.Url, (IWebProxy?)null, "TestClient/1.0")).Should().BeEqualTo(SubscriptionHttpServer.Body); await (await subscription.TryDownloadString(server.Url)).Should().BeEqualTo(SubscriptionHttpServer.Body);
await (await ordinaryDownload.TryDownloadString(server.Url, (IWebProxy?)null, "TestClient/1.0")).Should().BeEqualTo(SubscriptionHttpServer.Body); await (await ordinaryDownload.TryDownloadString(server.Url)).Should().BeEqualTo(SubscriptionHttpServer.Body);
var requests = server.Requests.ToArray(); var requests = server.Requests.ToArray();
await requests.Length.Should().BeEqualTo(2); await requests.Length.Should().BeEqualTo(2);
@@ -1,113 +0,0 @@
using ReactiveUI.Builder;
using ServiceLib.Tests.CoreConfig;
using ServiceLib.ViewModels;
namespace ServiceLib.Tests.ViewModels;
[NotInParallel]
public class ClashProxiesViewModelTests
{
static ClashProxiesViewModelTests()
{
RxAppBuilder.CreateReactiveUIBuilder().WithCoreServices().BuildApp();
}
[Test]
[Arguments("Selector", true)]
[Arguments("URLTest", true)]
[Arguments("LoadBalance", true)]
[Arguments("Fallback", true)]
[Arguments("selector", true)]
[Arguments("SELECTOR", true)]
[Arguments("Direct", false)]
[Arguments(null, false)]
public async Task GroupDelay_ShouldTestAllowedGroupTypes(string? type, bool allowed)
{
var configField = typeof(AppManager).GetField("_config", BindingFlags.Instance | BindingFlags.NonPublic)!;
var portField = typeof(AppManager).GetField("_statePort2", BindingFlags.Instance | BindingFlags.NonPublic)!;
var viewConfigField = typeof(MyReactiveObject).GetField("_config", BindingFlags.Static | BindingFlags.NonPublic)!;
var previousConfig = configField.GetValue(AppManager.Instance);
var previousPort = portField.GetValue(AppManager.Instance);
var previousViewConfig = viewConfigField.GetValue(null);
await using var server = new DelayServer();
try
{
CoreConfigTestFactory.BindAppManagerConfig(CoreConfigTestFactory.CreateConfig());
portField.SetValue(AppManager.Instance, server.Port);
var viewModel = new ClashProxiesViewModel();
var item = new ClashItem();
item.Proxies.Add("test-group", new() { type = type, all = ["test-node"] });
item.Proxies.Add("test-node", new() { type = "Shadowsocks" });
typeof(ClashProxiesViewModel).GetField("_clashItem", BindingFlags.Instance | BindingFlags.NonPublic)!
.SetValue(viewModel, item);
viewModel.SelectedGroup = new ClashProxyModel { Name = "test-group" };
var method = typeof(ClashProxiesViewModel).GetMethod("TestGroupProxiesDelay", BindingFlags.Instance | BindingFlags.NonPublic)!;
await ((Task)method.Invoke(viewModel, null)!).WaitAsync(TimeSpan.FromSeconds(10));
await server.Requests.Count.Should().BeEqualTo(allowed ? 1 : 0);
await viewModel.ProxyDetails.Single().DelayName.Should().BeEqualTo(allowed ? "42ms" : string.Empty);
if (allowed)
{
await server.Requests.Single().StartsWith("GET /proxies/test-node/delay?").Should().BeTrue();
}
}
finally
{
configField.SetValue(AppManager.Instance, previousConfig);
portField.SetValue(AppManager.Instance, previousPort);
viewConfigField.SetValue(null, previousViewConfig);
}
}
private sealed class DelayServer : IAsyncDisposable
{
private readonly TcpListener _listener = new(IPAddress.Loopback, 0);
private readonly CancellationTokenSource _cancellation = new();
private readonly Task _serverTask;
public int Port => ((IPEndPoint)_listener.LocalEndpoint).Port;
public ConcurrentQueue<string> Requests { get; } = new();
public DelayServer()
{
_listener.Start();
_serverTask = ServeAsync();
}
private async Task ServeAsync()
{
var token = _cancellation.Token;
while (!token.IsCancellationRequested)
{
using var client = await _listener.AcceptTcpClientAsync(token);
await using var stream = client.GetStream();
using var reader = new StreamReader(stream, Encoding.ASCII, leaveOpen: true);
var request = await reader.ReadLineAsync(token);
while (await reader.ReadLineAsync(token) is { Length: > 0 })
{
}
Requests.Enqueue(request!);
const string body = "{\"delay\":42}";
var response = $"HTTP/1.1 200 OK\r\nContent-Type: application/json\r\nContent-Length: {body.Length}\r\nConnection: close\r\n\r\n{body}";
await stream.WriteAsync(Encoding.ASCII.GetBytes(response), token);
}
}
public async ValueTask DisposeAsync()
{
await _cancellation.CancelAsync();
try
{
await _serverTask;
}
catch (OperationCanceledException)
{
}
finally
{
_listener.Stop();
_cancellation.Dispose();
}
}
}
}
+10 -14
View File
@@ -2644,8 +2644,8 @@ public static class ConfigHandler
/// <returns>0 if successful</returns> /// <returns>0 if successful</returns>
public static async Task<int> InitExternalRouting(Config config, bool blImportAdvancedRules = false) public static async Task<int> InitExternalRouting(Config config, bool blImportAdvancedRules = false)
{ {
var downloadHandle = new DownloadService(); using var downloadHandle = await DownloadService.CreateDownloadServiceWithCheck();
var templateContent = await downloadHandle.TryDownloadString(config.ConstItem.RouteRulesTemplateSourceUrl, true, ""); var templateContent = await downloadHandle.TryDownloadString(config.ConstItem.RouteRulesTemplateSourceUrl);
if (templateContent.IsNullOrEmpty()) if (templateContent.IsNullOrEmpty())
{ {
return await InitBuiltinRouting(config, blImportAdvancedRules); // fallback return await InitBuiltinRouting(config, blImportAdvancedRules); // fallback
@@ -2674,7 +2674,7 @@ public static class ConfigHandler
var ruleSetsString = !item.RuleSet.IsNullOrEmpty() var ruleSetsString = !item.RuleSet.IsNullOrEmpty()
? item.RuleSet ? item.RuleSet
: await downloadHandle.TryDownloadString(item.Url, true, ""); : await downloadHandle.TryDownloadString(item.Url);
if (ruleSetsString.IsNullOrEmpty()) if (ruleSetsString.IsNullOrEmpty())
{ {
@@ -2872,8 +2872,8 @@ public static class ConfigHandler
{ {
var currentItem = await AppManager.Instance.GetDNSItem(type); var currentItem = await AppManager.Instance.GetDNSItem(type);
var downloadHandle = new DownloadService(); using var downloadHandle = await DownloadService.CreateDownloadServiceWithCheck();
var templateContent = await downloadHandle.TryDownloadString(url, true, ""); var templateContent = await downloadHandle.TryDownloadString(url);
if (templateContent.IsNullOrEmpty()) if (templateContent.IsNullOrEmpty())
{ {
return currentItem; return currentItem;
@@ -2887,12 +2887,12 @@ public static class ConfigHandler
if (!template.NormalDNS.IsNullOrEmpty()) if (!template.NormalDNS.IsNullOrEmpty())
{ {
template.NormalDNS = await downloadHandle.TryDownloadString(template.NormalDNS, true, ""); template.NormalDNS = await downloadHandle.TryDownloadString(template.NormalDNS);
} }
if (!template.TunDNS.IsNullOrEmpty()) if (!template.TunDNS.IsNullOrEmpty())
{ {
template.TunDNS = await downloadHandle.TryDownloadString(template.TunDNS, true, ""); template.TunDNS = await downloadHandle.TryDownloadString(template.TunDNS);
} }
template.Id = currentItem.Id; template.Id = currentItem.Id;
@@ -2922,20 +2922,16 @@ public static class ConfigHandler
}; };
} }
public static async Task<SimpleDNSItem> GetExternalSimpleDNSItem(string url) public static async Task<SimpleDNSItem?> GetExternalSimpleDNSItem(string url)
{ {
var downloadHandle = new DownloadService(); using var downloadHandle = await DownloadService.CreateDownloadServiceWithCheck();
var templateContent = await downloadHandle.TryDownloadString(url, true, ""); var templateContent = await downloadHandle.TryDownloadString(url);
if (templateContent.IsNullOrEmpty()) if (templateContent.IsNullOrEmpty())
{ {
return null; return null;
} }
var template = JsonUtils.Deserialize<SimpleDNSItem>(templateContent); var template = JsonUtils.Deserialize<SimpleDNSItem>(templateContent);
if (template == null)
{
return null;
}
return template; return template;
} }
+29 -28
View File
@@ -20,9 +20,12 @@ public static class ConnectionHandler
/// </summary> /// </summary>
private static async Task<string?> GetIPInfo() private static async Task<string?> GetIPInfo()
{ {
var webProxy = await GetWebProxy(); using var downloadHandle = new DownloadService
{
var ipInfo = await GetIPInfo(webProxy); Handler = HttpClientHelper.CreateSocketsHttpHandler(true, new()),
DisposeHandler = true,
};
var ipInfo = await GetIPInfo(downloadHandle);
return ipInfo?.ToString() ?? Global.None; return ipInfo?.ToString() ?? Global.None;
} }
@@ -34,11 +37,9 @@ public static class ConnectionHandler
var responseTime = -1; var responseTime = -1;
try try
{ {
var webProxy = await GetWebProxy();
for (var i = 0; i < 2; i++) for (var i = 0; i < 2; i++)
{ {
responseTime = await GetRealPingTime(webProxy); responseTime = await GetRealPingTime(true);
if (responseTime > 0) if (responseTime > 0)
{ {
break; break;
@@ -54,19 +55,29 @@ public static class ConnectionHandler
return responseTime; return responseTime;
} }
/// <summary>
/// Creates local SOCKS proxy instance.
/// </summary>
private static async Task<WebProxy?> GetWebProxy()
{
var port = AppManager.Instance.GetLocalPort(EInboundProtocol.socks);
return new WebProxy($"socks5://{Global.Loopback}:{port}");
}
/// <summary> /// <summary>
/// Measures response time by sending HTTP requests through proxy. /// Measures response time by sending HTTP requests through proxy.
/// </summary> /// </summary>
public static async Task<int> GetRealPingTime(IWebProxy? webProxy, CancellationToken cancellationToken = default) public static async Task<int> GetRealPingTime(bool useProxy, CancellationToken cancellationToken = default)
{
var responseTime = -1;
try
{
using var client = HttpClientHelper.Instance.GetHttpClient(useProxy);
responseTime = await GetRealPingTime(client, cancellationToken);
}
catch (OperationCanceledException) when (cancellationToken.IsCancellationRequested)
{
throw;
}
catch
{
// Ignore
}
return responseTime;
}
public static async Task<int> GetRealPingTime(HttpClient client, CancellationToken cancellationToken = default)
{ {
var url = AppManager.Instance.Config.SpeedTestItem.SpeedPingTestUrl; var url = AppManager.Instance.Config.SpeedTestItem.SpeedPingTestUrl;
var responseTime = -1; var responseTime = -1;
@@ -76,12 +87,6 @@ public static class ConnectionHandler
timeoutCts.CancelAfter(Global.LocalFetch); timeoutCts.CancelAfter(Global.LocalFetch);
using var linkedCts = CancellationTokenSource.CreateLinkedTokenSource(cancellationToken, timeoutCts.Token); using var linkedCts = CancellationTokenSource.CreateLinkedTokenSource(cancellationToken, timeoutCts.Token);
var linkedToken = linkedCts.Token; var linkedToken = linkedCts.Token;
using var client = new HttpClient(new SocketsHttpHandler()
{
Proxy = webProxy,
UseProxy = webProxy != null,
ConnectTimeout = Global.LocalFetch,
});
List<int> oneTime = []; List<int> oneTime = [];
for (var i = 0; i < 2; i++) for (var i = 0; i < 2; i++)
@@ -105,10 +110,7 @@ public static class ConnectionHandler
return responseTime; return responseTime;
} }
/// <summary> public static async Task<IpInfoResult?> GetIPInfo(DownloadService downloadHandle, CancellationToken cancellationToken = default)
/// Gets IP and country information through specified proxy.
/// </summary>
public static async Task<IpInfoResult?> GetIPInfo(IWebProxy? webProxy, CancellationToken cancellationToken = default)
{ {
try try
{ {
@@ -118,8 +120,7 @@ public static class ConnectionHandler
return null; return null;
} }
var downloadHandle = new DownloadService(); var result = await downloadHandle.TryDownloadString(url, cancellationToken);
var result = await downloadHandle.TryDownloadString(url, webProxy, "", cancellationToken);
if (result == null) if (result == null)
{ {
return null; return null;
+183 -126
View File
@@ -1,8 +1,14 @@
namespace ServiceLib.Handler; namespace ServiceLib.Handler;
public static class SubscriptionHandler public class SubscriptionHandler(
Config config,
bool blProxy,
Func<bool, string, Task> updateFunc)
{ {
public static async Task UpdateProcess(Config config, string subId, bool blProxy, Func<bool, string, Task> updateFunc) private readonly Config _config = config;
private readonly bool _blProxy = blProxy;
public async Task UpdateProcess(string subId)
{ {
await updateFunc?.Invoke(false, ResUI.MsgUpdateSubscriptionStart); await updateFunc?.Invoke(false, ResUI.MsgUpdateSubscriptionStart);
var subItem = await AppManager.Instance.SubItems(); var subItem = await AppManager.Instance.SubItems();
@@ -24,21 +30,20 @@ public static class SubscriptionHandler
} }
var hashCode = $"{item.Remarks}->"; var hashCode = $"{item.Remarks}->";
if (item.Enabled == false) if (!item.Enabled)
{ {
await updateFunc?.Invoke(false, $"{hashCode}{ResUI.MsgSkipSubscriptionUpdate}"); await updateFunc?.Invoke(false, $"{hashCode}{ResUI.MsgSkipSubscriptionUpdate}");
continue; continue;
} }
// Create download handler using var processor = new SubscriptionHandlerProcessor(this, item);
var downloadHandle = CreateDownloadHandler(item, hashCode, updateFunc);
await updateFunc?.Invoke(false, $"{hashCode}{ResUI.MsgStartGettingSubscriptions}"); await updateFunc?.Invoke(false, $"{hashCode}{ResUI.MsgStartGettingSubscriptions}");
// Get all subscription content (main subscription + additional subscriptions) // Get all subscription content (main subscription + additional subscriptions)
var result = await DownloadAllSubscriptions(config, item, blProxy, downloadHandle); var result = await processor.DownloadAllSubscriptions();
// Process download result // Process download result
if (await ProcessDownloadResult(config, item.Id, result, hashCode, updateFunc)) if (await ProcessDownloadResult(item.Id, result, hashCode))
{ {
successCount++; successCount++;
} }
@@ -57,7 +62,7 @@ public static class SubscriptionHandler
await updateFunc?.Invoke(successCount > 0, $"{ResUI.MsgUpdateSubscriptionEnd}"); await updateFunc?.Invoke(successCount > 0, $"{ResUI.MsgUpdateSubscriptionEnd}");
} }
private static bool IsValidSubscription(SubItem item, string subId) private bool IsValidSubscription(SubItem item, string subId)
{ {
var id = item.Id.TrimEx(); var id = item.Id.TrimEx();
var url = item.Url.TrimEx(); var url = item.Url.TrimEx();
@@ -80,121 +85,7 @@ public static class SubscriptionHandler
return true; return true;
} }
private static DownloadService CreateDownloadHandler(SubItem item, string hashCode, Func<bool, string, Task> updateFunc) private async Task<bool> ProcessDownloadResult(string id, string result, string hashCode)
{
if (!HttpRequestHeadersHelper.TryParse(item.RequestHeaders, out var requestHeaders))
{
throw new FormatException(ResUI.SubRequestHeadersInvalid);
}
var downloadHandle = new DownloadService
{
AcceptHeader = "*/*",
RequestHeaders = requestHeaders
};
downloadHandle.Error += (sender2, args) =>
{
updateFunc?.Invoke(false, $"{hashCode}{args.GetException().Message}");
};
return downloadHandle;
}
private static async Task<string> DownloadSubscriptionContent(DownloadService downloadHandle, string url, bool blProxy, string userAgent)
{
var result = await downloadHandle.TryDownloadString(url, blProxy, userAgent);
// If download with proxy fails, try direct connection
if (blProxy && result.IsNullOrEmpty())
{
result = await downloadHandle.TryDownloadString(url, false, userAgent);
}
return result ?? string.Empty;
}
private static async Task<string> DownloadAllSubscriptions(Config config, SubItem item, bool blProxy, DownloadService downloadHandle)
{
// Download main subscription content
var result = await DownloadMainSubscription(config, item, blProxy, downloadHandle);
// Process additional subscription links (if any)
if (item.ConvertTarget.IsNullOrEmpty() && item.MoreUrl.TrimEx().IsNotEmpty())
{
result = await DownloadAdditionalSubscriptions(item, result, blProxy, downloadHandle);
}
return result;
}
private static async Task<string> DownloadMainSubscription(Config config, SubItem item, bool blProxy, DownloadService downloadHandle)
{
// Prepare subscription URL and download directly
var url = Utils.GetPunycode(item.Url.TrimEx());
// If conversion is needed
if (item.ConvertTarget.IsNotEmpty())
{
var subConvertUrl = config.ConstItem.SubConvertUrl.IsNullOrEmpty()
? Global.SubConvertUrls.FirstOrDefault()
: config.ConstItem.SubConvertUrl;
url = string.Format(subConvertUrl!, Utils.UrlEncode(url));
if (!url.Contains("target="))
{
url += $"&target={item.ConvertTarget}";
}
if (!url.Contains("config="))
{
url += $"&config={Global.SubConvertConfig.FirstOrDefault()}";
}
}
// Download and return result directly
return await DownloadSubscriptionContent(downloadHandle, url, blProxy, item.UserAgent);
}
private static async Task<string> DownloadAdditionalSubscriptions(SubItem item, string mainResult, bool blProxy, DownloadService downloadHandle)
{
var result = mainResult;
// If main subscription result is Base64 encoded, decode it first
if (result.IsNotEmpty() && Utils.IsBase64String(result))
{
result = Utils.Base64Decode(result);
}
// Process additional URL list
var lstUrl = item.MoreUrl.TrimEx().Split(",") ?? [];
foreach (var it in lstUrl)
{
var url2 = Utils.GetPunycode(it);
if (url2.IsNullOrEmpty())
{
continue;
}
var additionalResult = await DownloadSubscriptionContent(downloadHandle, url2, blProxy, item.UserAgent);
if (additionalResult.IsNotEmpty())
{
// Process additional subscription results, add to main result
if (Utils.IsBase64String(additionalResult))
{
result += Environment.NewLine + Utils.Base64Decode(additionalResult);
}
else
{
result += Environment.NewLine + additionalResult;
}
}
}
return result;
}
private static async Task<bool> ProcessDownloadResult(Config config, string id, string result, string hashCode, Func<bool, string, Task> updateFunc)
{ {
if (result.IsNullOrEmpty()) if (result.IsNullOrEmpty())
{ {
@@ -213,7 +104,7 @@ public static class SubscriptionHandler
await updateFunc?.Invoke(false, $"{hashCode}{ResUI.MsgStartParsingSubscription}"); await updateFunc?.Invoke(false, $"{hashCode}{ResUI.MsgStartParsingSubscription}");
// Add servers to configuration // Add servers to configuration
var ret = await ConfigHandler.AddBatchServers(config, result, id, true); var ret = await ConfigHandler.AddBatchServers(_config, result, id, true);
if (ret <= 0) if (ret <= 0)
{ {
Logging.SaveLog("FailedImportSubscription"); Logging.SaveLog("FailedImportSubscription");
@@ -222,9 +113,175 @@ public static class SubscriptionHandler
// Update completion message // Update completion message
await updateFunc?.Invoke(false, ret > 0 await updateFunc?.Invoke(false, ret > 0
? $"{hashCode}{ResUI.MsgUpdateSubscriptionEnd}" ? $"{hashCode}{ResUI.MsgUpdateSubscriptionEnd}"
: $"{hashCode}{ResUI.MsgFailedImportSubscription}"); : $"{hashCode}{ResUI.MsgFailedImportSubscription}");
return ret > 0; return ret > 0;
} }
private class SubscriptionHandlerProcessor : IDisposable
{
private readonly DownloadService[] _downloadServices;
private readonly SubscriptionHandler _handler;
private readonly SubItem _item;
public SubscriptionHandlerProcessor(SubscriptionHandler handler, SubItem item)
{
_handler = handler;
_item = item;
_downloadServices = CreateDownloadHandler();
}
private DownloadService[] CreateDownloadHandler()
{
if (!HttpRequestHeadersHelper.TryParse(_item.RequestHeaders, out var requestHeaders))
{
throw new FormatException(ResUI.SubRequestHeadersInvalid);
}
var userAgent = _item.UserAgent.IsNullOrEmpty() ? Utils.GetVersion(false) : _item.UserAgent;
//var proxyDownloadService = new DownloadService
//{
// GetHandlerFunc = () =>
// HttpRequestHeadersHelper.CreateHandler(HttpClientHelper.Instance.ProxyHandler, userAgent, "*/*",
// requestHeaders),
//};
//var directDownloadService = new DownloadService
//{
// GetHandlerFunc = () =>
// HttpRequestHeadersHelper.CreateHandler(HttpClientHelper.Instance.DirectHandler, userAgent, "*/*",
// requestHeaders),
//};
//return _handler._blProxy
// ? [
// proxyDownloadService,
// directDownloadService,
// ]
// : [
// directDownloadService,
// proxyDownloadService,
// ];
var handler = HttpClientHelper.Instance.CreateFallbackHandler(_handler._blProxy, new());
var headersHandler = HttpRequestHeadersHelper.CreateHandler(handler, userAgent, "*/*", requestHeaders);
return
[
new DownloadService
{
Handler = headersHandler,
DisposeHandler = true,
},
];
}
private async Task<string> DownloadSubscriptionContent(string url)
{
//var result = await downloadServices[0].TryDownloadString(url);
//// try other ways
//if (result.IsNullOrEmpty())
//{
// result = await downloadServices[1].TryDownloadString(url);
//}
foreach (var downloadService in _downloadServices)
{
var result = await downloadService.TryDownloadString(url);
if (!result.IsNullOrEmpty())
{
return result;
}
}
return string.Empty;
}
public async Task<string> DownloadAllSubscriptions()
{
// Download main subscription content
var result = await DownloadMainSubscription();
// Process additional subscription links (if any)
if (_item.ConvertTarget.IsNullOrEmpty() && _item.MoreUrl.TrimEx().IsNotEmpty())
{
result = await DownloadAdditionalSubscriptions(result);
}
return result;
}
private async Task<string> DownloadMainSubscription()
{
// Prepare subscription URL and download directly
var url = Utils.GetPunycode(_item.Url.TrimEx());
// If conversion is needed
if (_item.ConvertTarget.IsNotEmpty())
{
var subConvertUrl = _handler._config.ConstItem.SubConvertUrl.IsNullOrEmpty()
? Global.SubConvertUrls.FirstOrDefault()
: _handler._config.ConstItem.SubConvertUrl;
url = string.Format(subConvertUrl!, Utils.UrlEncode(url));
if (!url.Contains("target="))
{
url += $"&target={_item.ConvertTarget}";
}
if (!url.Contains("config="))
{
url += $"&config={Global.SubConvertConfig.FirstOrDefault()}";
}
}
// Download and return result directly
return await DownloadSubscriptionContent(url);
}
private async Task<string> DownloadAdditionalSubscriptions(string mainResult)
{
var result = mainResult;
// If main subscription result is Base64 encoded, decode it first
if (result.IsNotEmpty() && Utils.IsBase64String(result))
{
result = Utils.Base64Decode(result);
}
// Process additional URL list
var lstUrl = _item.MoreUrl.TrimEx().Split(",") ?? [];
foreach (var it in lstUrl)
{
var url2 = Utils.GetPunycode(it);
if (url2.IsNullOrEmpty())
{
continue;
}
var additionalResult = await DownloadSubscriptionContent(url2);
if (additionalResult.IsNotEmpty())
{
// Process additional subscription results, add to main result
if (Utils.IsBase64String(additionalResult))
{
result += Environment.NewLine + Utils.Base64Decode(additionalResult);
}
else
{
result += Environment.NewLine + additionalResult;
}
}
}
return result;
}
public void Dispose()
{
foreach (var downloadService in _downloadServices)
{
downloadService.Dispose();
}
}
}
} }
+75 -158
View File
@@ -1,45 +1,41 @@
using System.Security.Authentication;
using Downloader; using Downloader;
using DownloadService = Downloader.DownloadService;
namespace ServiceLib.Helper; namespace ServiceLib.Helper;
public class DownloaderHelper public class DownloaderHelper : IDisposable
{ {
private static readonly Lazy<DownloaderHelper> _instance = new(() => new()); public HttpMessageHandler Handler { get; init; } = HttpClientHelper.Instance.DirectHandler;
public static DownloaderHelper Instance => _instance.Value;
public async Task<string?> DownloadStringAsync(IWebProxy? webProxy, string url, string? userAgent, public bool DisposeHandler { get; init; } = false;
IReadOnlyDictionary<string, string>? requestHeaders = null, string? acceptHeader = null, CancellationToken cancellationToken = default)
public async Task<string?> DownloadStringAsync(string url,
CancellationToken cancellationToken = default)
{ {
if (url.IsNullOrEmpty()) if (url.IsNullOrEmpty())
{ {
return null; return null;
} }
Uri uri = new(url);
//Authorization Header
var headers = new WebHeaderCollection(); var headers = new WebHeaderCollection();
var uri = new Uri(url);
if (uri.UserInfo.IsNotEmpty()) if (uri.UserInfo.IsNotEmpty())
{ {
headers.Add(HttpRequestHeader.Authorization, "Basic " + Utils.Base64Encode(uri.UserInfo)); headers.Add(HttpRequestHeader.Authorization, "Basic " + Utils.Base64Encode(uri.UserInfo));
} }
var requestConfiguration = new RequestConfiguration() var requestConfiguration = new RequestConfiguration
{ {
Headers = headers, Headers = headers,
Accept = acceptHeader,
UserAgent = userAgent,
ConnectTimeout = GetConnectTimeoutMs(webProxy != null),
Proxy = webProxy
}; };
var downloadOpt = new DownloadConfiguration() var downloadOpt = new DownloadConfiguration
{ {
MaxTryAgainOnFailure = 2, MaxTryAgainOnFailure = 2,
RequestConfiguration = requestConfiguration, RequestConfiguration = requestConfiguration,
CustomHttpMessageHandlerFactory = () => HttpRequestHeadersHelper.CreateHandler(GetSocketsHttpHandler(requestConfiguration), requestHeaders), CustomHttpMessageHandlerFactory = GetHandler,
}; };
await using var downloader = new Downloader.DownloadService(downloadOpt); await using var downloader = new DownloadService(downloadOpt);
downloader.DownloadFileCompleted += (sender, value) => downloader.DownloadFileCompleted += (sender, value) =>
{ {
if (value.Error != null) if (value.Error != null)
@@ -48,35 +44,30 @@ public class DownloaderHelper
} }
}; };
await using var stream = await downloader.DownloadFileTaskAsync(address: url, cancellationToken); await using var stream = await downloader.DownloadFileTaskAsync(url, cancellationToken);
using StreamReader reader = new(stream); using StreamReader reader = new(stream);
return await reader.ReadToEndAsync(cancellationToken); return await reader.ReadToEndAsync(cancellationToken);
} }
public async Task DownloadDataAsync4Speed(IWebProxy webProxy, string url, Action<string> onProgress, CancellationToken cancellationToken = default) public async Task DownloadDataAsync4Speed(string url, Action<string> onProgress,
CancellationToken cancellationToken = default)
{ {
if (url.IsNullOrEmpty()) if (url.IsNullOrEmpty())
{ {
throw new ArgumentNullException(nameof(url)); throw new ArgumentNullException(nameof(url));
} }
var requestConfiguration = new RequestConfiguration() var downloadOpt = new DownloadConfiguration
{
ConnectTimeout = GetConnectTimeoutMs(true),
Proxy = webProxy
};
var downloadOpt = new DownloadConfiguration()
{ {
MaxTryAgainOnFailure = 2, MaxTryAgainOnFailure = 2,
RequestConfiguration = requestConfiguration, CustomHttpMessageHandlerFactory = GetHandler,
CustomHttpMessageHandlerFactory = () => GetSocketsHttpHandler(requestConfiguration),
}; };
var lastUpdateTime = DateTime.Now; var lastUpdateTime = DateTime.Now;
var hasValue = false; var hasValue = false;
double maxSpeed = 0; double maxSpeed = 0;
await using var downloader = new Downloader.DownloadService(downloadOpt); await using var downloader = new DownloadService(downloadOpt);
downloader.DownloadProgressChanged += (sender, value) => downloader.DownloadProgressChanged += (sender, value) =>
{ {
@@ -114,11 +105,12 @@ public class DownloaderHelper
onProgress.Invoke("0"); onProgress.Invoke("0");
} }
}; };
//progress.Invoke("......"); // progress.Invoke("......");
await using var stream = await downloader.DownloadFileTaskAsync(address: url, cancellationToken); await using var stream = await downloader.DownloadFileTaskAsync(url, cancellationToken);
} }
public async Task DownloadFileAsync(IWebProxy? webProxy, FileDownloadRequest request, Action<FileDownloadState> onProgress, CancellationToken cancellationToken = default) public async Task DownloadFileAsync(FileDownloadRequest request, Action<FileDownloadState> onProgress,
CancellationToken cancellationToken = default)
{ {
ArgumentNullException.ThrowIfNull(request); ArgumentNullException.ThrowIfNull(request);
if (request.FilePath.IsNullOrEmpty()) if (request.FilePath.IsNullOrEmpty())
@@ -135,25 +127,18 @@ public class DownloaderHelper
Request = request, Request = request,
}; };
var requestConfiguration = new RequestConfiguration var downloadOpt = new DownloadConfiguration
{
ConnectTimeout = GetConnectTimeoutMs(webProxy != null),
Proxy = webProxy,
};
var downloadOpt = new DownloadConfiguration()
{ {
ChunkCount = 16, ChunkCount = 16,
ParallelDownload = true, ParallelDownload = true,
ParallelCount = 4, ParallelCount = 4,
MinimumChunkSize = 2 * 1024 * 1024, // 2 MB MinimumChunkSize = 2 * 1024 * 1024, // 2 MB
MinimumSizeOfChunking = 4 * 1024 * 1024, // 4 MB MinimumSizeOfChunking = 4 * 1024 * 1024, // 4 MB
RequestConfiguration = requestConfiguration, CustomHttpMessageHandlerFactory = GetHandler,
CustomHttpMessageHandlerFactory = () => GetSocketsHttpHandler(requestConfiguration),
}; };
await using var downloader = new Downloader.DownloadService(downloadOpt); await using var downloader = new DownloadService(downloadOpt);
downloader.DownloadStarted += (sender, value) => downloader.DownloadStarted += (sender, value) =>
{ {
state = state with state = state with
@@ -185,7 +170,8 @@ public class DownloaderHelper
await downloader.DownloadFileTaskAsync(request.FileUrl, request.FilePath, cancellationToken); await downloader.DownloadFileTaskAsync(request.FileUrl, request.FilePath, cancellationToken);
} }
public async Task DownloadSmallFilesAsync(IWebProxy? webProxy, List<FileDownloadRequest> requests, Action<ReadOnlyMemory<FileDownloadState>> onProgress, CancellationToken cancellationToken = default) public async Task DownloadSmallFilesAsync(List<FileDownloadRequest> requests,
Action<ReadOnlyMemory<FileDownloadState>> onProgress, CancellationToken cancellationToken = default)
{ {
if (requests is not { Count: > 0 }) if (requests is not { Count: > 0 })
{ {
@@ -202,140 +188,71 @@ public class DownloaderHelper
} }
var readOnlyStates = new ReadOnlyMemory<FileDownloadState>(states); var readOnlyStates = new ReadOnlyMemory<FileDownloadState>(states);
var requestConfiguration = new RequestConfiguration()
{
ConnectTimeout = GetConnectTimeoutMs(webProxy != null),
Proxy = webProxy,
KeepAlive = true,
};
using var socketsHttpHandler = GetSocketsHttpHandler(requestConfiguration);
var parallelOptions = new ParallelOptions var parallelOptions = new ParallelOptions
{ {
MaxDegreeOfParallelism = 4, MaxDegreeOfParallelism = 4,
CancellationToken = cancellationToken, CancellationToken = cancellationToken,
}; };
await Parallel.ForEachAsync(Enumerable.Range(0, requests.Count), parallelOptions, async (index, parallelCancellationToken) => await Parallel.ForEachAsync(Enumerable.Range(0, requests.Count), parallelOptions,
{ async (index, parallelCancellationToken) =>
var request = requests[index];
var downloadOpt = new DownloadConfiguration()
{ {
RequestConfiguration = requestConfiguration, var request = requests[index];
// ReSharper disable once AccessToDisposedClosure var downloadOpt = new DownloadConfiguration
CustomHttpMessageHandlerFactory = () => socketsHttpHandler,
};
await using var downloader = new Downloader.DownloadService(downloadOpt);
downloader.DownloadStarted += (sender, value) =>
{
states[index] = states[index] with
{ {
DownloadedBytes = 0, CustomHttpMessageHandlerFactory = GetHandler,
TotalBytes = value.TotalBytesToReceive,
SpeedBytesPerSecond = 0,
Completed = false,
}; };
onProgress.Invoke(readOnlyStates); await using var downloader = new DownloadService(downloadOpt);
}; downloader.DownloadStarted += (sender, value) =>
downloader.DownloadProgressChanged += (sender, value) =>
{
states[index] = states[index] with
{ {
DownloadedBytes = value.ReceivedBytesSize, states[index] = states[index] with
TotalBytes = value.TotalBytesToReceive, {
SpeedBytesPerSecond = value.BytesPerSecondSpeed, DownloadedBytes = 0,
Completed = false, TotalBytes = value.TotalBytesToReceive,
SpeedBytesPerSecond = 0,
Completed = false,
};
onProgress.Invoke(readOnlyStates);
}; };
onProgress.Invoke(readOnlyStates); downloader.DownloadProgressChanged += (sender, value) =>
};
downloader.DownloadFileCompleted += (sender, value) =>
{
var newState = states[index] with { Completed = true };
if (value.Error != null)
{ {
newState = newState with { Error = value.Error }; states[index] = states[index] with
} {
states[index] = newState; DownloadedBytes = value.ReceivedBytesSize,
onProgress.Invoke(readOnlyStates); TotalBytes = value.TotalBytesToReceive,
}; SpeedBytesPerSecond = value.BytesPerSecondSpeed,
await downloader.DownloadFileTaskAsync(request.FileUrl, request.FilePath, parallelCancellationToken); Completed = false,
}); };
onProgress.Invoke(readOnlyStates);
};
downloader.DownloadFileCompleted += (sender, value) =>
{
var newState = states[index] with { Completed = true };
if (value.Error != null)
{
newState = newState with { Error = value.Error };
}
states[index] = newState;
onProgress.Invoke(readOnlyStates);
};
await downloader.DownloadFileTaskAsync(request.FileUrl, request.FilePath, parallelCancellationToken);
});
} }
// https://github.com/bezzad/Downloader/blob/a75a6e431acd6cbba6293f7afdcf676544a09174/src/Downloader/SocketClient.cs#L45 private HttpMessageHandler GetHandler()
// There is a risk of MITM attacks
// https://github.com/bezzad/Downloader/blob/a75a6e431acd6cbba6293f7afdcf676544a09174/src/Downloader/Extensions/ExceptionHelper.cs#L111
private static SocketsHttpHandler GetSocketsHttpHandler(RequestConfiguration config)
{ {
SocketsHttpHandler handler = new() return Handler;
{
AllowAutoRedirect = config.AllowAutoRedirect,
MaxAutomaticRedirections = config.MaximumAutomaticRedirections,
AutomaticDecompression = config.AutomaticDecompression,
PreAuthenticate = config.PreAuthenticate,
UseCookies = config.CookieContainer != null,
UseProxy = config.Proxy != null,
MaxConnectionsPerServer = 1000,
PooledConnectionIdleTimeout = config.KeepAliveTimeout,
PooledConnectionLifetime = Timeout.InfiniteTimeSpan,
EnableMultipleHttp2Connections = true,
ConnectTimeout = TimeSpan.FromMilliseconds(config.ConnectTimeout),
};
// Set up the SslClientAuthenticationOptions for custom certificate validation
if (config.ClientCertificates?.Count > 0)
{
handler.SslOptions.ClientCertificates = config.ClientCertificates;
}
handler.SslOptions.EnabledSslProtocols = SslProtocols.Tls13 | SslProtocols.Tls12;
//handler.SslOptions.RemoteCertificateValidationCallback = ExceptionHelper.CertificateValidationCallBack;
var certificateChainPolicy = CertPemManager.Instance.BuildCertificateChainPolicy();
if (certificateChainPolicy != null)
{
handler.SslOptions.CertificateChainPolicy = certificateChainPolicy;
handler.SslOptions.RemoteCertificateValidationCallback = null;
}
// Configure keep-alive
if (config.KeepAlive)
{
handler.KeepAlivePingTimeout = config.KeepAliveTimeout;
handler.KeepAlivePingPolicy = HttpKeepAlivePingPolicy.WithActiveRequests;
}
// Configure credentials
if (config.Credentials != null)
{
handler.Credentials = config.Credentials;
handler.PreAuthenticate = config.PreAuthenticate;
}
// Configure cookies
if (handler.UseCookies && config.CookieContainer != null)
{
handler.CookieContainer = config.CookieContainer;
}
// Configure proxy
if (handler.UseProxy && config.Proxy != null)
{
handler.Proxy = config.Proxy;
}
// Add expect header
if (!string.IsNullOrWhiteSpace(config.Expect))
{
handler.Expect100ContinueTimeout = TimeSpan.FromSeconds(1);
}
return handler;
} }
private int GetConnectTimeoutMs(bool isProxy) public void Dispose()
{ {
return (int)(isProxy ? Global.ProxyDownloadConnect : Global.DirectDownloadConnect).TotalMilliseconds; if (!DisposeHandler)
{
return;
}
if (Handler is IDisposable disposableHandler)
{
disposableHandler.Dispose();
}
} }
} }
+210 -46
View File
@@ -1,78 +1,242 @@
using System.Net.Http.Headers;
using System.Net.Mime;
namespace ServiceLib.Helper; namespace ServiceLib.Helper;
/// <summary> /// <summary>
/// </summary> /// </summary>
public class HttpClientHelper public class HttpClientHelper
{ {
private static readonly Lazy<HttpClientHelper> _instance = new(() => private static readonly Lazy<HttpClientHelper> _instance = new(() => new());
{
SocketsHttpHandler handler = new() { UseCookies = false }; // private readonly SocketsHttpHandler _directHandler = CreateSocketsHttpHandler(false);
HttpClientHelper helper = new(new HttpClient(handler)); private readonly Lazy<SocketsHttpHandler> _directHandlerLazy = new(() => CreateSocketsHttpHandler(false));
return helper; private Uri _proxyUri = null!;
});
public static HttpClientHelper Instance => _instance.Value; public static HttpClientHelper Instance => _instance.Value;
private readonly HttpClient httpClient;
private HttpClientHelper(HttpClient httpClient) public HttpMessageHandler DirectHandler => new SharedHandlerAdapter(_directHandlerLazy.Value);
public HttpMessageHandler ProxyHandler
{ {
this.httpClient = httpClient; get
{
var port = AppManager.Instance.GetLocalPort(EInboundProtocol.socks);
var proxyUri = new Uri($"socks5://{Global.Loopback}:{port}");
if (_proxyUri != proxyUri)
{
//field?.Dispose();
_proxyUri = proxyUri;
field = CreateProxySocketsHttpHandler(port);
}
return new SharedHandlerAdapter(field);
}
} = null!;
public HttpMessageHandler GetHandler(bool useProxy)
{
return useProxy ? ProxyHandler : DirectHandler;
} }
public async Task<string?> TryGetAsync(string url, CancellationToken cancellationToken = default) public HttpClient GetHttpClient(bool useProxy)
{ {
if (url.IsNullOrEmpty()) var handler = GetHandler(useProxy);
var client = new HttpClient(handler);
return client;
}
public static SocketsHttpHandler CreateSocketsHttpHandler(bool useProxy)
{
SocketsHttpHandler handler;
if (!useProxy)
{ {
return null; handler = new()
{
UseProxy = false,
UseCookies = false,
ConnectTimeout = Global.DirectDownloadConnect,
};
}
else
{
var port = AppManager.Instance.GetLocalPort(EInboundProtocol.socks);
handler = CreateProxySocketsHttpHandler(port);
}
var certificateChainPolicy = CertPemManager.Instance.BuildCertificateChainPolicy();
if (certificateChainPolicy != null)
{
handler.SslOptions.CertificateChainPolicy = certificateChainPolicy;
handler.SslOptions.RemoteCertificateValidationCallback = null;
}
return handler;
}
public static SocketsHttpHandler CreateSocketsHttpHandler(bool useProxy, CookieContainer cookieContainer)
{
var handler = CreateSocketsHttpHandler(useProxy);
handler.UseCookies = true;
handler.CookieContainer = cookieContainer;
return handler;
}
public static SocketsHttpHandler CreateProxySocketsHttpHandler(IWebProxy proxy)
{
var handler = new SocketsHttpHandler
{
UseCookies = false,
Proxy = proxy,
ConnectTimeout = Global.ProxyDownloadConnect,
};
var certificateChainPolicy = CertPemManager.Instance.BuildCertificateChainPolicy();
if (certificateChainPolicy != null)
{
handler.SslOptions.CertificateChainPolicy = certificateChainPolicy;
handler.SslOptions.RemoteCertificateValidationCallback = null;
}
return handler;
}
public static SocketsHttpHandler CreateProxySocketsHttpHandler(int port)
{
return CreateProxySocketsHttpHandler(new WebProxy($"socks5://{Global.Loopback}:{port}"));
}
public static SocketsHttpHandler CreateProxySocketsHttpHandler(IWebProxy proxy, CookieContainer cookieContainer)
{
var handler = CreateProxySocketsHttpHandler(proxy);
handler.UseCookies = true;
handler.CookieContainer = cookieContainer;
return handler;
}
public static SocketsHttpHandler CreateProxySocketsHttpHandler(int port, CookieContainer cookieContainer)
{
var handler = CreateProxySocketsHttpHandler(port);
handler.UseCookies = true;
handler.CookieContainer = cookieContainer;
return handler;
}
public HttpMessageHandler CreateFallbackHandler(bool preferProxy)
{
var primaryHandler = preferProxy ? ProxyHandler : DirectHandler;
var fallbackHandler = preferProxy ? DirectHandler : ProxyHandler;
return new DualHandlerFallbackHandler(primaryHandler, fallbackHandler);
}
public HttpMessageHandler CreateFallbackHandler(bool preferProxy, CookieContainer cookieContainer)
{
var primaryHandler = preferProxy ? CreateSocketsHttpHandler(true, cookieContainer) : CreateSocketsHttpHandler(false, cookieContainer);
var fallbackHandler = preferProxy ? CreateSocketsHttpHandler(false, cookieContainer) : CreateSocketsHttpHandler(true, cookieContainer);
return new DualHandlerFallbackHandler(primaryHandler, fallbackHandler);
}
private class DualHandlerFallbackHandler(
HttpMessageHandler primaryHandler,
HttpMessageHandler fallbackHandler)
: DelegatingHandler(primaryHandler)
{
private readonly HttpMessageInvoker _fallbackInvoker = new(fallbackHandler);
protected override async Task<HttpResponseMessage> SendAsync(
HttpRequestMessage request, CancellationToken cancellationToken)
{
var contentBytes = await BufferContentAsync(request, cancellationToken);
try
{
var response = await base.SendAsync(request, cancellationToken);
if (IsRetriableStatusCode(response.StatusCode))
{
response.Dispose();
ResetRequestContent(request, contentBytes);
return await _fallbackInvoker.SendAsync(request, cancellationToken);
}
return response;
}
catch (Exception ex) when (IsNetworkOrTimeoutException(ex) && !cancellationToken.IsCancellationRequested)
{
ResetRequestContent(request, contentBytes);
return await _fallbackInvoker.SendAsync(request, cancellationToken);
}
} }
try private static async Task<byte[]?> BufferContentAsync(HttpRequestMessage request, CancellationToken cancellationToken)
{ {
var response = await httpClient.GetAsync(url, cancellationToken); if (request.Content == null)
return await response.Content.ReadAsStringAsync(cancellationToken); {
return null;
}
return await request.Content.ReadAsByteArrayAsync(cancellationToken);
} }
catch (OperationCanceledException) when (cancellationToken.IsCancellationRequested)
private static void ResetRequestContent(HttpRequestMessage request, byte[]? contentBytes)
{ {
throw; if (contentBytes == null)
{
return;
}
var newContent = new ByteArrayContent(contentBytes);
if (request.Content != null)
{
foreach (var header in request.Content.Headers)
{
newContent.Headers.TryAddWithoutValidation(header.Key, header.Value);
}
}
request.Content = newContent;
} }
catch
private static bool IsNetworkOrTimeoutException(Exception ex)
{ {
return null; return ex is HttpRequestException or TaskCanceledException;
}
private static bool IsRetriableStatusCode(HttpStatusCode statusCode) =>
statusCode is HttpStatusCode.BadGateway // 502
or HttpStatusCode.ServiceUnavailable // 503
or HttpStatusCode.GatewayTimeout; // 504
protected override void Dispose(bool disposing)
{
if (disposing)
{
_fallbackInvoker.Dispose();
}
base.Dispose(disposing);
} }
} }
public async Task<string?> GetAsync(string url) public class SharedHandlerAdapter(HttpMessageHandler sharedHandler) : HttpMessageHandler
{ {
if (url.IsNullOrEmpty()) private readonly HttpMessageInvoker _invoker = new(
sharedHandler,
disposeHandler: false);
protected override HttpResponseMessage Send(
HttpRequestMessage request,
CancellationToken cancellationToken)
{ {
return null; return _invoker.Send(request, cancellationToken);
} }
return await httpClient.GetStringAsync(url);
}
public async Task PutAsync(string url, Dictionary<string, string> headers) protected override Task<HttpResponseMessage> SendAsync(
{ HttpRequestMessage request,
var jsonContent = JsonUtils.Serialize(headers); CancellationToken cancellationToken)
var content = new StringContent(jsonContent, Encoding.UTF8, MediaTypeNames.Application.Json); {
return _invoker.SendAsync(request, cancellationToken);
}
await httpClient.PutAsync(url, content); protected override void Dispose(bool disposing)
} {
if (disposing)
{
_invoker.Dispose();
}
public async Task PatchAsync(string url, Dictionary<string, string> headers) base.Dispose(disposing);
{ }
var myContent = JsonUtils.Serialize(headers);
var buffer = Encoding.UTF8.GetBytes(myContent);
var byteContent = new ByteArrayContent(buffer);
byteContent.Headers.ContentType = new MediaTypeHeaderValue("application/json");
await httpClient.PatchAsync(url, byteContent);
}
public async Task DeleteAsync(string url)
{
await httpClient.DeleteAsync(url);
} }
} }
@@ -39,11 +39,31 @@ public static class HttpRequestHeadersHelper
} }
} }
public static HttpMessageHandler CreateHandler(HttpMessageHandler innerHandler, IReadOnlyDictionary<string, string>? headers) public static HttpMessageHandler CreateHandler(HttpMessageHandler innerHandler,
IReadOnlyDictionary<string, string>? headers)
{ {
return headers is { Count: > 0 } ? new RequestHeadersHandler(innerHandler, headers) : innerHandler; return headers is { Count: > 0 } ? new RequestHeadersHandler(innerHandler, headers) : innerHandler;
} }
public static HttpMessageHandler CreateHandler(HttpMessageHandler innerHandler,
string userAgent, string acceptHeader,
IReadOnlyDictionary<string, string>? headers)
{
var allHeaders = new Dictionary<string, string>(StringComparer.OrdinalIgnoreCase);
allHeaders["User-Agent"] = userAgent;
allHeaders["Accept"] = acceptHeader;
if (headers is { Count: > 0 })
{
foreach (var header in headers)
{
allHeaders[header.Key] = header.Value;
}
}
return new RequestHeadersHandler(innerHandler, allHeaders);
}
private static bool TryAddHeader(HttpRequestMessage request, string name, string value) private static bool TryAddHeader(HttpRequestMessage request, string name, string value)
{ {
if (value.Any(c => char.IsControl(c) && c != '\t')) if (value.Any(c => char.IsControl(c) && c != '\t'))
@@ -52,13 +72,16 @@ public static class HttpRequestHeadersHelper
} }
return request.Headers.TryAddWithoutValidation(name, value) return request.Headers.TryAddWithoutValidation(name, value)
|| request.Content!.Headers.TryAddWithoutValidation(name, value); || request.Content!.Headers.TryAddWithoutValidation(name, value);
} }
private sealed class RequestHeadersHandler(HttpMessageHandler innerHandler, IReadOnlyDictionary<string, string> headers) private sealed class RequestHeadersHandler(
HttpMessageHandler innerHandler,
IReadOnlyDictionary<string, string> headers)
: DelegatingHandler(innerHandler) : DelegatingHandler(innerHandler)
{ {
protected override Task<HttpResponseMessage> SendAsync(HttpRequestMessage request, CancellationToken cancellationToken) protected override Task<HttpResponseMessage> SendAsync(HttpRequestMessage request,
CancellationToken cancellationToken)
{ {
using var customHeaders = new HttpRequestMessage { Content = new ByteArrayContent([]) }; using var customHeaders = new HttpRequestMessage { Content = new ByteArrayContent([]) };
foreach (var header in headers) foreach (var header in headers)
+82 -33
View File
@@ -1,3 +1,5 @@
using System.Net.Http.Headers;
namespace ServiceLib.Manager; namespace ServiceLib.Manager;
public sealed class ClashApiManager public sealed class ClashApiManager
@@ -10,20 +12,30 @@ public sealed class ClashApiManager
public async Task<ClashItem?> GetProxies() public async Task<ClashItem?> GetProxies()
{ {
using var httpClient = HttpClientHelper.Instance.GetHttpClient(false);
for (var i = 0; i < 3; i++) for (var i = 0; i < 3; i++)
{ {
var url = $"{ApiUrl}/proxies"; ClashProxies? clashProxies = null;
var resultTask = HttpClientHelper.Instance.TryGetAsync(url); ClashProviders? clashProviders = null;
try
{
var url = $"{ApiUrl}/proxies";
var resultTask = httpClient.GetStringAsync(url);
var url2 = $"{ApiUrl}/providers/proxies"; var url2 = $"{ApiUrl}/providers/proxies";
var result2Task = HttpClientHelper.Instance.TryGetAsync(url2); var result2Task = httpClient.GetStringAsync(url2);
await Task.WhenAll(resultTask, result2Task); await Task.WhenAll(resultTask, result2Task);
var result = await resultTask; var result = await resultTask;
var result2 = await result2Task; var result2 = await result2Task;
var clashProxies = JsonUtils.Deserialize<ClashProxies>(result); clashProxies = JsonUtils.Deserialize<ClashProxies>(result);
var clashProviders = JsonUtils.Deserialize<ClashProviders>(result2); clashProviders = JsonUtils.Deserialize<ClashProviders>(result2);
}
catch (Exception ex)
{
Logging.SaveLog(_tag, ex);
}
if (clashProxies is null && clashProviders is null) if (clashProxies is null && clashProviders is null)
{ {
@@ -36,7 +48,7 @@ public sealed class ClashApiManager
{ {
item = item with { Proxies = proxies }; item = item with { Proxies = proxies };
} }
if (clashProviders.providers is { Count: > 0 } providers) if (clashProviders?.providers is { Count: > 0 } providers)
{ {
foreach (var provider in providers) foreach (var provider in providers)
{ {
@@ -84,22 +96,36 @@ public sealed class ClashApiManager
private async Task<int> SendTestRequest(string url) private async Task<int> SendTestRequest(string url)
{ {
var result = await HttpClientHelper.Instance.TryGetAsync(url); try
var jsonObject = JsonUtils.ParseJson(result) as JsonObject; {
return jsonObject?["delay"] is { } n && n.GetValueKind() == JsonValueKind.Number && using var httpClient = HttpClientHelper.Instance.GetHttpClient(false);
n.GetValue<JsonElement>().TryGetInt32(out var v) var result = await httpClient.GetStringAsync(url);
? v var jsonObject = JsonUtils.ParseJson(result) as JsonObject;
: -1; return jsonObject?["delay"] is { } n && n.GetValueKind() == JsonValueKind.Number &&
n.GetValue<JsonElement>().TryGetInt32(out var v)
? v
: -1;
}
catch (Exception ex)
{
Logging.SaveLog(_tag, ex);
}
return -1;
} }
public async Task SetActiveProxy(string groupName, string nodeName) public async Task SetActiveProxy(string groupName, string nodeName)
{ {
try try
{ {
using var httpClient = HttpClientHelper.Instance.GetHttpClient(false);
var url = $"{ApiUrl}/proxies/{Utils.UrlEncode(groupName)}"; var url = $"{ApiUrl}/proxies/{Utils.UrlEncode(groupName)}";
var headers = new Dictionary<string, string>(); var jsonObject = new JsonObject
headers.Add("name", nodeName); {
await HttpClientHelper.Instance.PutAsync(url, headers); ["name"] = nodeName,
};
var jsonString = JsonUtils.Serialize(jsonObject);
var content = new StringContent(jsonString, Encoding.UTF8, "application/json");
await httpClient.PutAsync(url, content);
} }
catch (Exception ex) catch (Exception ex)
{ {
@@ -109,18 +135,30 @@ public sealed class ClashApiManager
public async Task UpdateClashMode(string mode) public async Task UpdateClashMode(string mode)
{ {
var headers = new Dictionary<string, string> var jsonObject = new JsonObject
{ {
{ "mode", mode }, ["mode"] = mode,
}; };
await UpdateConfig(headers); await UpdateConfig(jsonObject);
} }
public async Task UpdateConfig(Dictionary<string, string> headers) public async Task UpdateConfig(JsonObject jsonObject)
{ {
var urlBase = $"{ApiUrl}/configs"; try
{
var urlBase = $"{ApiUrl}/configs";
await HttpClientHelper.Instance.PatchAsync(urlBase, headers); using var httpClient = HttpClientHelper.Instance.GetHttpClient(false);
var jsonString = JsonUtils.Serialize(jsonObject);
var buffer = Encoding.UTF8.GetBytes(jsonString);
var byteContent = new ByteArrayContent(buffer);
byteContent.Headers.ContentType = new MediaTypeHeaderValue("application/json");
await httpClient.PatchAsync(urlBase, byteContent);
}
catch (Exception ex)
{
Logging.SaveLog(_tag, ex);
}
} }
public async Task<List<string>> GetClashModes() public async Task<List<string>> GetClashModes()
@@ -153,14 +191,23 @@ public sealed class ClashApiManager
public async Task<JsonObject> GetConfig() public async Task<JsonObject> GetConfig()
{ {
var url = $"{ApiUrl}/configs"; try
var result = await HttpClientHelper.Instance.TryGetAsync(url);
var jsonNode = JsonUtils.ParseJson(result);
if (jsonNode is not JsonObject jsonObject)
{ {
return new JsonObject(); var url = $"{ApiUrl}/configs";
using var httpClient = HttpClientHelper.Instance.GetHttpClient(false);
var result = await httpClient.GetStringAsync(url);
var jsonNode = JsonUtils.ParseJson(result);
if (jsonNode is not JsonObject jsonObject)
{
return new JsonObject();
}
return jsonObject;
} }
return jsonObject; catch (Exception ex)
{
Logging.SaveLog(_tag, ex);
}
return new JsonObject();
} }
public async Task<ClashConnections?> GetConnections() public async Task<ClashConnections?> GetConnections()
@@ -168,7 +215,8 @@ public sealed class ClashApiManager
try try
{ {
var url = $"{ApiUrl}/connections"; var url = $"{ApiUrl}/connections";
var result = await HttpClientHelper.Instance.TryGetAsync(url); using var httpClient = HttpClientHelper.Instance.GetHttpClient(false);
var result = await httpClient.GetStringAsync(url);
var clashConnections = JsonUtils.Deserialize<ClashConnections>(result); var clashConnections = JsonUtils.Deserialize<ClashConnections>(result);
return clashConnections; return clashConnections;
@@ -186,7 +234,8 @@ public sealed class ClashApiManager
try try
{ {
var url = $"{ApiUrl}/connections/{id}"; var url = $"{ApiUrl}/connections/{id}";
await HttpClientHelper.Instance.DeleteAsync(url); using var httpClient = HttpClientHelper.Instance.GetHttpClient(false);
await httpClient.DeleteAsync(url);
} }
catch (Exception ex) catch (Exception ex)
{ {
@@ -38,7 +38,7 @@ public class EndpointResolveManager
case "doh" or "https": case "doh" or "https":
{ {
var uri = new Uri($"https://{domain}:{(port != 0 ? port : 443)}{path}"); var uri = new Uri($"https://{domain}:{(port != 0 ? port : 443)}{path}");
var httpClient = new HttpClient(); var httpClient = new HttpClient(HttpClientHelper.Instance.CreateFallbackHandler(false));
return new DohDnsResolver(httpClient, uri); return new DohDnsResolver(httpClient, uri);
} }
+7 -4
View File
@@ -101,7 +101,7 @@ public class TaskManager
foreach (var item in lstSubs) foreach (var item in lstSubs)
{ {
await SubscriptionHandler.UpdateProcess(_config, item.Id, true, async (success, msg) => var handler = new SubscriptionHandler(_config, true, async (success, msg) =>
{ {
await _updateFunc?.Invoke(success, msg); await _updateFunc?.Invoke(success, msg);
if (success) if (success)
@@ -109,6 +109,7 @@ public class TaskManager
Logging.SaveLog($"Update subscription end. {msg}"); Logging.SaveLog($"Update subscription end. {msg}");
} }
}); });
await handler.UpdateProcess(item.Id);
item.UpdateTime = updateTime; item.UpdateTime = updateTime;
await ConfigHandler.AddSubItem(_config, item); await ConfigHandler.AddSubItem(_config, item);
await Task.Delay(1000); await Task.Delay(1000);
@@ -121,7 +122,8 @@ public class TaskManager
{ {
Logging.SaveLog("Execute update geo files"); Logging.SaveLog("Execute update geo files");
await new UpdateService(_config, async (success, msg) => using var downloadService = await DownloadService.CreateDownloadServiceWithCheck(_config.CheckUpdateItem.UpdateViaProxy);
await new UpdateService(downloadService, _config, async (success, msg) =>
{ {
await _updateFunc?.Invoke(false, msg); await _updateFunc?.Invoke(false, msg);
}).UpdateGeoFileAll(); }).UpdateGeoFileAll();
@@ -132,9 +134,10 @@ public class TaskManager
{ {
Logging.SaveLog("Execute check update"); Logging.SaveLog("Execute check update");
var updateService = new UpdateService(_config, async (success, msg) => await Task.CompletedTask); using var downloadService = await DownloadService.CreateDownloadServiceWithCheck(_config.CheckUpdateItem.UpdateViaProxy);
var updateService = new UpdateService(downloadService, _config, async (success, msg) => await Task.CompletedTask);
var msgs = await updateService.CheckHasUpdateOnlyAll(_config.CheckUpdateItem.UpdateViaProxy); var msgs = await updateService.CheckHasUpdateOnlyAll();
foreach (var msg in msgs) foreach (var msg in msgs)
{ {
await _updateFunc?.Invoke(false, msg); await _updateFunc?.Invoke(false, msg);
+113 -99
View File
@@ -1,33 +1,73 @@
using System.Net.Http.Headers;
namespace ServiceLib.Services; namespace ServiceLib.Services;
/// <summary> /// <summary>
/// Download /// Download
/// </summary> /// </summary>
public class DownloadService public class DownloadService : IDisposable
{ {
private static readonly string _tag = "DownloadService";
public HttpMessageHandler Handler { get; init; } = HttpClientHelper.Instance.DirectHandler;
public bool DisposeHandler { get; init; } = false;
public DownloaderHelper DownloaderHelper
{
get
{
field ??= new DownloaderHelper
{
Handler = Handler,
DisposeHandler = false,
};
return field;
}
}
public event EventHandler<UpdateResult>? UpdateCompleted; public event EventHandler<UpdateResult>? UpdateCompleted;
public event ErrorEventHandler? Error; public event ErrorEventHandler? Error;
public string? AcceptHeader { get; init; } public static async Task<DownloadService> CreateDownloadServiceWithCheck(bool preferProxy = true)
{
var webProxy = await CheckProxy(preferProxy);
var cookieContainer = new CookieContainer();
if (webProxy is not null)
{
return new DownloadService
{
Handler = HttpClientHelper.CreateProxySocketsHttpHandler(webProxy, cookieContainer),
DisposeHandler = true,
};
}
return new DownloadService
{
Handler = HttpClientHelper.CreateSocketsHttpHandler(false, cookieContainer),
DisposeHandler = true,
};
}
public IReadOnlyDictionary<string, string>? RequestHeaders { get; init; } public static DownloadService CreateDownloadServiceWithFallback(bool preferProxy = true)
{
private static readonly string _tag = "DownloadService"; return new DownloadService
{
Handler = HttpClientHelper.Instance.CreateFallbackHandler(preferProxy, new()),
DisposeHandler = true,
};
}
/// <summary> /// <summary>
/// Downloads data with the specified proxy and reports progress messages. /// Downloads data with the specified proxy and reports progress messages.
/// </summary> /// </summary>
public async Task<int> DownloadDataAsync(string url, IWebProxy webProxy, Func<bool, string, Task> updateFunc, CancellationToken cancellationToken = default) public async Task<int> DownloadDataAsync(string url, Func<bool, string, Task> updateFunc,
CancellationToken cancellationToken = default)
{ {
try try
{ {
await DownloaderHelper.Instance.DownloadDataAsync4Speed(webProxy, await DownloaderHelper.DownloadDataAsync4Speed(
url, url,
OnProgress, OnProgress,
cancellationToken); cancellationToken);
void OnProgress(string message) void OnProgress(string message)
{ {
@@ -51,16 +91,15 @@ public class DownloadService
} }
/// <summary> /// <summary>
/// Downloads a file and reports progress through events. /// Downloads a file and reports progress through events.
/// </summary> /// </summary>
public async Task DownloadFileAsync(FileDownloadRequest request, bool blProxy, CancellationToken cancellationToken = default) public async Task DownloadFileAsync(FileDownloadRequest request, CancellationToken cancellationToken = default)
{ {
try try
{ {
UpdateCompleted?.Invoke(this, new UpdateResult(false, $"{ResUI.Downloading} {request.FileUrl}")); UpdateCompleted?.Invoke(this, new UpdateResult(false, $"{ResUI.Downloading} {request.FileUrl}"));
var webProxy = await GetWebProxy(blProxy, cancellationToken); await DownloaderHelper.DownloadFileAsync(
await DownloaderHelper.Instance.DownloadFileAsync(webProxy,
request, request,
OnProgress, OnProgress,
cancellationToken); cancellationToken);
@@ -68,7 +107,9 @@ public class DownloadService
void OnProgress(FileDownloadState state) void OnProgress(FileDownloadState state)
{ {
cancellationToken.ThrowIfCancellationRequested(); cancellationToken.ThrowIfCancellationRequested();
UpdateCompleted?.Invoke(this, new UpdateResult(state.Completed, $"{Utils.HumanFy((long)state.SpeedBytesPerSecond / 1024)}/s | {Utils.HumanFy(state.DownloadedBytes / 1024)}/{Utils.HumanFy(state.TotalBytes / 1024)}")); UpdateCompleted?.Invoke(this,
new UpdateResult(state.Completed,
$"{Utils.HumanFy((long)state.SpeedBytesPerSecond / 1024)}/s | {Utils.HumanFy(state.DownloadedBytes / 1024)}/{Utils.HumanFy(state.TotalBytes / 1024)}"));
} }
} }
catch (OperationCanceledException) when (cancellationToken.IsCancellationRequested) catch (OperationCanceledException) when (cancellationToken.IsCancellationRequested)
@@ -87,14 +128,14 @@ public class DownloadService
} }
} }
public async Task DownloadSmallFilesAsync(List<FileDownloadRequest> requests, bool blProxy, CancellationToken cancellationToken = default) public async Task DownloadSmallFilesAsync(List<FileDownloadRequest> requests,
CancellationToken cancellationToken = default)
{ {
try try
{ {
UpdateCompleted?.Invoke(this, new UpdateResult(false, $"{ResUI.Downloading} 0/{requests.Count}")); UpdateCompleted?.Invoke(this, new UpdateResult(false, $"{ResUI.Downloading} 0/{requests.Count}"));
var webProxy = await GetWebProxy(blProxy, cancellationToken); await DownloaderHelper.DownloadSmallFilesAsync(
await DownloaderHelper.Instance.DownloadSmallFilesAsync(webProxy,
requests, requests,
OnProgress, OnProgress,
cancellationToken); cancellationToken);
@@ -139,7 +180,9 @@ public class DownloadService
throw failedState.Error; throw failedState.Error;
} }
} }
UpdateCompleted?.Invoke(this, new UpdateResult(allCompleted, $"{completedCount}/{span.Length} | {Utils.HumanFy((long)totalSpeed / 1024)}/s {Utils.HumanFy(totalDownloadedBytes / 1024)}/{Utils.HumanFy(totalTotalBytes / 1024)} {downloadingFileName}")); UpdateCompleted?.Invoke(this,
new UpdateResult(allCompleted,
$"{completedCount}/{span.Length} | {Utils.HumanFy((long)totalSpeed / 1024)}/s {Utils.HumanFy(totalDownloadedBytes / 1024)}/{Utils.HumanFy(totalTotalBytes / 1024)} {downloadingFileName}"));
} }
} }
catch (OperationCanceledException) when (cancellationToken.IsCancellationRequested) catch (OperationCanceledException) when (cancellationToken.IsCancellationRequested)
@@ -159,53 +202,31 @@ public class DownloadService
} }
/// <summary> /// <summary>
/// Gets redirect target URL without following redirects automatically. /// Gets redirect target URL without following redirects automatically.
/// </summary> /// </summary>
public async Task<string?> UrlRedirectAsync(string url, bool blProxy, CancellationToken cancellationToken = default) public async Task<string?> UrlRedirectAsync(string url, CancellationToken cancellationToken = default)
{ {
var webRequestHandler = new SocketsHttpHandler using var client = new HttpClient(GetHandler(), false);
{
AllowAutoRedirect = false,
Proxy = await GetWebProxy(blProxy, cancellationToken)
};
var certificateChainPolicy = CertPemManager.Instance.BuildCertificateChainPolicy();
if (certificateChainPolicy != null)
{
webRequestHandler.SslOptions.CertificateChainPolicy = certificateChainPolicy;
webRequestHandler.SslOptions.RemoteCertificateValidationCallback = null;
}
using var client = new HttpClient(webRequestHandler);
var response = await client.GetAsync(url, cancellationToken); var response = await client.GetAsync(url, cancellationToken);
if (response.StatusCode == HttpStatusCode.Redirect && response.Headers.Location is not null) if (response.StatusCode == HttpStatusCode.Redirect && response.Headers.Location is not null)
{ {
return response.Headers.Location.ToString(); return response.Headers.Location.ToString();
} }
else Error?.Invoke(this, new ErrorEventArgs(new Exception("StatusCode error: " + response.StatusCode)));
{ Logging.SaveLog("StatusCode error: " + url);
Error?.Invoke(this, new ErrorEventArgs(new Exception("StatusCode error: " + response.StatusCode))); return null;
Logging.SaveLog("StatusCode error: " + url);
return null;
}
} }
/// <summary> /// <summary>
/// Tries to download string content using proxy switch setting. /// Tries to download string content with a specified proxy.
/// </summary> /// </summary>
public async Task<string?> TryDownloadString(string url, bool blProxy, string userAgent, CancellationToken cancellationToken = default) public async Task<string?> TryDownloadString(string url,
{ CancellationToken cancellationToken = default)
var webProxy = await GetWebProxy(blProxy, cancellationToken);
return await TryDownloadString(url, webProxy, userAgent, cancellationToken);
}
/// <summary>
/// Tries to download string content with a specified proxy.
/// </summary>
public async Task<string?> TryDownloadString(string url, IWebProxy? webProxy, string userAgent, CancellationToken cancellationToken = default)
{ {
try try
{ {
var result1 = await DownloadStringAsync(url, webProxy, userAgent, cancellationToken); var result1 = await DownloadStringAsync(url, cancellationToken);
if (result1.IsNotEmpty()) if (result1.IsNotEmpty())
{ {
return result1; return result1;
@@ -227,7 +248,7 @@ public class DownloadService
try try
{ {
var result2 = await DownloadStringViaDownloader(url, webProxy, userAgent, cancellationToken); var result2 = await DownloadStringViaDownloader(url, cancellationToken);
if (result2.IsNotEmpty()) if (result2.IsNotEmpty())
{ {
return result2; return result2;
@@ -251,48 +272,25 @@ public class DownloadService
} }
/// <summary> /// <summary>
/// Downloads string content via HttpClient. /// Downloads string content via HttpClient.
/// </summary> /// </summary>
private async Task<string?> DownloadStringAsync(string url, IWebProxy? webProxy, string userAgent, CancellationToken cancellationToken = default) private async Task<string?> DownloadStringAsync(string url,
CancellationToken cancellationToken = default)
{ {
try try
{ {
var handler = new SocketsHttpHandler using var client = new HttpClient(GetHandler(), false);
{
Proxy = webProxy,
UseProxy = webProxy != null,
AutomaticDecompression = DecompressionMethods.All,
ConnectTimeout = webProxy is null ? Global.DirectDownloadConnect : Global.ProxyDownloadConnect,
};
var certificateChainPolicy = CertPemManager.Instance.BuildCertificateChainPolicy();
if (certificateChainPolicy != null)
{
handler.SslOptions.CertificateChainPolicy = certificateChainPolicy;
handler.SslOptions.RemoteCertificateValidationCallback = null;
}
using var client = new HttpClient(HttpRequestHeadersHelper.CreateHandler(handler, RequestHeaders));
client.Timeout = Timeout.InfiniteTimeSpan; client.Timeout = Timeout.InfiniteTimeSpan;
if (userAgent.IsNullOrEmpty()) var uri = new Uri(url);
{
userAgent = Utils.GetVersion(false);
}
client.DefaultRequestHeaders.UserAgent.TryParseAdd(userAgent);
if (AcceptHeader.IsNotEmpty())
{
client.DefaultRequestHeaders.Accept.ParseAdd(AcceptHeader);
}
Uri uri = new(url);
//Authorization Header
if (uri.UserInfo.IsNotEmpty()) if (uri.UserInfo.IsNotEmpty())
{ {
client.DefaultRequestHeaders.Authorization = new AuthenticationHeaderValue("Basic", Utils.Base64Encode(uri.UserInfo)); client.DefaultRequestHeaders.Authorization =
new System.Net.Http.Headers.AuthenticationHeaderValue("Basic", Utils.Base64Encode(uri.UserInfo));
} }
using var timeoutCts = new CancellationTokenSource(); using var timeoutCts = new CancellationTokenSource();
timeoutCts.CancelAfter(webProxy is null ? Global.DirectFetch : Global.ProxyFetch); timeoutCts.CancelAfter(Global.ProxyFetch);
using var linkedCts = CancellationTokenSource.CreateLinkedTokenSource(cancellationToken, timeoutCts.Token); using var linkedCts = CancellationTokenSource.CreateLinkedTokenSource(cancellationToken, timeoutCts.Token);
return await client.GetStringAsync(url, linkedCts.Token); return await client.GetStringAsync(url, linkedCts.Token);
@@ -315,17 +313,16 @@ public class DownloadService
} }
/// <summary> /// <summary>
/// Downloads string content via DownloaderHelper. /// Downloads string content via DownloaderHelper.
/// </summary> /// </summary>
private async Task<string?> DownloadStringViaDownloader(string url, IWebProxy? webProxy, string userAgent, CancellationToken cancellationToken = default) private async Task<string?> DownloadStringViaDownloader(string url,
CancellationToken cancellationToken = default)
{ {
try try
{ {
if (userAgent.IsNullOrEmpty()) var result =
{ await DownloaderHelper.DownloadStringAsync(url,
userAgent = Utils.GetVersion(false); cancellationToken);
}
var result = await DownloaderHelper.Instance.DownloadStringAsync(webProxy, url, userAgent, RequestHeaders, AcceptHeader, cancellationToken);
return result; return result;
} }
catch (OperationCanceledException) when (cancellationToken.IsCancellationRequested) catch (OperationCanceledException) when (cancellationToken.IsCancellationRequested)
@@ -345,16 +342,16 @@ public class DownloadService
} }
/// <summary> /// <summary>
/// Creates local SOCKS proxy when proxy switch is enabled. /// Creates local SOCKS proxy when proxy switch is enabled.
/// </summary> /// </summary>
private async Task<WebProxy?> GetWebProxy(bool blProxy, CancellationToken cancellationToken = default) public static async Task<WebProxy?> CheckProxy(bool blProxy, CancellationToken cancellationToken = default)
{ {
if (!blProxy) if (!blProxy)
{ {
return null; return null;
} }
var port = AppManager.Instance.GetLocalPort(EInboundProtocol.socks); var port = AppManager.Instance.GetLocalPort(EInboundProtocol.socks);
if (await SocksPortCheck(Global.Loopback, port, cancellationToken) == false) if (!await SocksPortCheck(Global.Loopback, port, cancellationToken))
{ {
return null; return null;
} }
@@ -363,9 +360,9 @@ public class DownloadService
} }
/// <summary> /// <summary>
/// Checks whether the specified TCP endpoint is reachable. /// Checks whether the specified TCP endpoint is reachable.
/// </summary> /// </summary>
private async Task<bool> SocksPortCheck(string ip, int port, CancellationToken cancellationToken = default) private static async Task<bool> SocksPortCheck(string ip, int port, CancellationToken cancellationToken = default)
{ {
using var rootTimeOutCts = new CancellationTokenSource(Global.LocalFetch); using var rootTimeOutCts = new CancellationTokenSource(Global.LocalFetch);
using var rootCts = CancellationTokenSource.CreateLinkedTokenSource(cancellationToken, rootTimeOutCts.Token); using var rootCts = CancellationTokenSource.CreateLinkedTokenSource(cancellationToken, rootTimeOutCts.Token);
@@ -425,4 +422,21 @@ public class DownloadService
} }
return false; return false;
} }
private HttpMessageHandler GetHandler()
{
return Handler;
}
public void Dispose()
{
if (!DisposeHandler)
{
return;
}
if (Handler is IDisposable disposableHandler)
{
disposableHandler.Dispose();
}
}
} }
+45 -23
View File
@@ -435,8 +435,6 @@ public class SpeedtestService(Config config, Func<SpeedTestResult, Task> updateF
ConcurrentDictionary<string, byte> completedIds, int concurrencyCount, bool blSpeedTest, ConcurrentDictionary<string, byte> completedIds, int concurrencyCount, bool blSpeedTest,
CancellationToken ct = default) CancellationToken ct = default)
{ {
var downloadHandle = new DownloadService();
var parallelOptions = new ParallelOptions var parallelOptions = new ParallelOptions
{ {
MaxDegreeOfParallelism = concurrencyCount, MaxDegreeOfParallelism = concurrencyCount,
@@ -464,7 +462,7 @@ public class SpeedtestService(Config config, Func<SpeedTestResult, Task> updateF
{ {
if (delay > 0) if (delay > 0)
{ {
await DoSpeedTest(downloadHandle, it, completedIds, innerCt); await DoSpeedTest(it, completedIds, innerCt);
} }
else else
{ {
@@ -493,40 +491,64 @@ public class SpeedtestService(Config config, Func<SpeedTestResult, Task> updateF
private async Task<int> DoRealPing(ServerTestItem it, private async Task<int> DoRealPing(ServerTestItem it,
ConcurrentDictionary<string, byte> completedIds, CancellationToken ct = default) ConcurrentDictionary<string, byte> completedIds, CancellationToken ct = default)
{ {
var webProxy = new WebProxy($"socks5://{Global.Loopback}:{it.Port}"); var responseTime = -1;
var responseTime = await ConnectionHandler.GetRealPingTime(webProxy, ct); try
ProfileExManager.Instance.SetTestDelay(it.IndexId, responseTime);
await UpdateFunc(it.IndexId, responseTime.ToString());
if (!_config.UiItem.HideColumnIpInfo && responseTime > 0)
{ {
var ipInfo = await ConnectionHandler.GetIPInfo(webProxy, ct); using var handler = HttpClientHelper.CreateProxySocketsHttpHandler(it.Port, new());
var ipStr = ipInfo?.ToString() ?? Global.None; handler.ConnectTimeout = Global.LocalFetch;
ProfileExManager.Instance.SetTestIpInfo(it.IndexId, ipStr); using var client = new HttpClient(handler, false);
await UpdateIpInfoFunc(it.IndexId, ipStr); responseTime = await ConnectionHandler.GetRealPingTime(client, ct);
}
else
{
await UpdateIpInfoFunc(it.IndexId, ResUI.SpeedtestingSkip);
}
completedIds.TryAdd(it.IndexId, 0); ProfileExManager.Instance.SetTestDelay(it.IndexId, responseTime);
await UpdateFunc(it.IndexId, responseTime.ToString());
if (!_config.UiItem.HideColumnIpInfo && responseTime > 0)
{
using var downloadHandle = new DownloadService
{
Handler = handler,
DisposeHandler = false,
};
var ipInfo = await ConnectionHandler.GetIPInfo(downloadHandle, ct);
var ipStr = ipInfo?.ToString() ?? Global.None;
ProfileExManager.Instance.SetTestIpInfo(it.IndexId, ipStr);
await UpdateIpInfoFunc(it.IndexId, ipStr);
}
else
{
await UpdateIpInfoFunc(it.IndexId, ResUI.SpeedtestingSkip);
}
completedIds.TryAdd(it.IndexId, 0);
}
catch (OperationCanceledException) when (ct.IsCancellationRequested)
{
throw;
}
catch
{
// Ignore
}
return responseTime; return responseTime;
} }
private async Task DoSpeedTest(DownloadService downloadHandle, ServerTestItem it, private async Task DoSpeedTest(ServerTestItem it,
ConcurrentDictionary<string, byte> completedIds, CancellationToken ct = default) ConcurrentDictionary<string, byte> completedIds, CancellationToken ct = default)
{ {
await UpdateFunc(it.IndexId, "", ResUI.Speedtesting); await UpdateFunc(it.IndexId, "", ResUI.Speedtesting);
var webProxy = new WebProxy($"socks5://{Global.Loopback}:{it.Port}"); var handler = HttpClientHelper.CreateProxySocketsHttpHandler(it.Port, new());
var url = _config.SpeedTestItem.SpeedTestUrl; var url = _config.SpeedTestItem.SpeedTestUrl;
var timeout = _config.SpeedTestItem.SpeedTestTimeout; var timeout = _config.SpeedTestItem.SpeedTestTimeout;
using var timeoutCts = new CancellationTokenSource(TimeSpan.FromSeconds(timeout)); using var timeoutCts = new CancellationTokenSource(TimeSpan.FromSeconds(timeout));
using var linkedCts = CancellationTokenSource.CreateLinkedTokenSource(ct, timeoutCts.Token); using var linkedCts = CancellationTokenSource.CreateLinkedTokenSource(ct, timeoutCts.Token);
var linkedCt = linkedCts.Token; var linkedCt = linkedCts.Token;
await downloadHandle.DownloadDataAsync(url, webProxy, async (success, msg) => using var downloadHandle = new DownloadService
{
Handler = handler,
DisposeHandler = true,
};
await downloadHandle.DownloadDataAsync(url, async (success, msg) =>
{ {
decimal.TryParse(msg, out var dec); decimal.TryParse(msg, out var dec);
if (dec > 0) if (dec > 0)
@@ -48,7 +48,9 @@ public class StatisticsXrayService
continue; continue;
} }
var result = await HttpClientHelper.Instance.TryGetAsync(Url, token); //var result = await HttpClientHelper.Instance.TryGetAsync(Url, token);
using var httpClient = HttpClientHelper.Instance.GetHttpClient(false);
var result = await httpClient.GetStringAsync(Url, token).ConfigureAwait(false);
if (result != null) if (result != null)
{ {
var server = ParseOutput(result) ?? new ServerSpeedItem(); var server = ParseOutput(result) ?? new ServerSpeedItem();
+26 -30
View File
@@ -1,18 +1,17 @@
namespace ServiceLib.Services; namespace ServiceLib.Services;
public partial class UpdateService(Config config, Func<bool, string, Task> updateFunc) public partial class UpdateService(DownloadService downloadService, Config config, Func<bool, string, Task> updateFunc)
{ {
private readonly Config? _config = config; private readonly Config? _config = config;
private readonly Func<bool, string, Task>? _updateFunc = updateFunc; private readonly Func<bool, string, Task>? _updateFunc = updateFunc;
private static readonly string _tag = "UpdateService"; private static readonly string _tag = "UpdateService";
public async Task CheckUpdateGuiN(bool preRelease, bool blProxy = true, CancellationToken cancellationToken = default) public async Task CheckUpdateGuiN(bool preRelease, CancellationToken cancellationToken = default)
{ {
var url = string.Empty; var url = string.Empty;
var fileName = string.Empty; var fileName = string.Empty;
DownloadService downloadHandle = new(); downloadService.UpdateCompleted += (sender2, args) =>
downloadHandle.UpdateCompleted += (sender2, args) =>
{ {
if (args.Success) if (args.Success)
{ {
@@ -24,13 +23,13 @@ public partial class UpdateService(Config config, Func<bool, string, Task> updat
_ = UpdateFunc(false, args.Msg); _ = UpdateFunc(false, args.Msg);
} }
}; };
downloadHandle.Error += (sender2, args) => downloadService.Error += (sender2, args) =>
{ {
_ = UpdateFunc(false, args.GetException().Message); _ = UpdateFunc(false, args.GetException().Message);
}; };
await UpdateFunc(false, string.Format(ResUI.MsgStartUpdating, ECoreType.v2rayN)); await UpdateFunc(false, string.Format(ResUI.MsgStartUpdating, ECoreType.v2rayN));
var result = await CheckUpdateAsync(downloadHandle, ECoreType.v2rayN, preRelease, blProxy); var result = await CheckUpdateAsync(ECoreType.v2rayN, preRelease, cancellationToken);
if (result.Success) if (result.Success)
{ {
await UpdateFunc(false, string.Format(ResUI.MsgParsingSuccessfully, ECoreType.v2rayN)); await UpdateFunc(false, string.Format(ResUI.MsgParsingSuccessfully, ECoreType.v2rayN));
@@ -38,7 +37,7 @@ public partial class UpdateService(Config config, Func<bool, string, Task> updat
url = result.Url!; url = result.Url!;
fileName = Utils.GetTempPath(Utils.GetGuid()); fileName = Utils.GetTempPath(Utils.GetGuid());
await downloadHandle.DownloadFileAsync(new() { FileUrl = url, FilePath = fileName }, blProxy, cancellationToken); await downloadService.DownloadFileAsync(new() { FileUrl = url, FilePath = fileName }, cancellationToken);
} }
else else
{ {
@@ -46,13 +45,12 @@ public partial class UpdateService(Config config, Func<bool, string, Task> updat
} }
} }
public async Task CheckUpdateCore(ECoreType type, bool preRelease, bool blProxy = true, CancellationToken cancellationToken = default) public async Task CheckUpdateCore(ECoreType type, bool preRelease, CancellationToken cancellationToken = default)
{ {
var url = string.Empty; var url = string.Empty;
var fileName = string.Empty; var fileName = string.Empty;
DownloadService downloadHandle = new(); downloadService.UpdateCompleted += (sender2, args) =>
downloadHandle.UpdateCompleted += (sender2, args) =>
{ {
if (args.Success) if (args.Success)
{ {
@@ -73,13 +71,13 @@ public partial class UpdateService(Config config, Func<bool, string, Task> updat
_ = UpdateFunc(false, args.Msg); _ = UpdateFunc(false, args.Msg);
} }
}; };
downloadHandle.Error += (sender2, args) => downloadService.Error += (sender2, args) =>
{ {
_ = UpdateFunc(false, args.GetException().Message); _ = UpdateFunc(false, args.GetException().Message);
}; };
await UpdateFunc(false, string.Format(ResUI.MsgStartUpdating, type)); await UpdateFunc(false, string.Format(ResUI.MsgStartUpdating, type));
var result = await CheckUpdateAsync(downloadHandle, type, preRelease, blProxy); var result = await CheckUpdateAsync(type, preRelease, cancellationToken);
if (result.Success) if (result.Success)
{ {
await UpdateFunc(false, string.Format(ResUI.MsgParsingSuccessfully, type)); await UpdateFunc(false, string.Format(ResUI.MsgParsingSuccessfully, type));
@@ -88,7 +86,7 @@ public partial class UpdateService(Config config, Func<bool, string, Task> updat
url = result.Url!; url = result.Url!;
var ext = url.Contains(".tar.gz") ? ".tar.gz" : Path.GetExtension(url); var ext = url.Contains(".tar.gz") ? ".tar.gz" : Path.GetExtension(url);
fileName = Utils.GetTempPath(Utils.GetGuid() + ext); fileName = Utils.GetTempPath(Utils.GetGuid() + ext);
await downloadHandle.DownloadFileAsync(new() { FileUrl = url, FilePath = fileName }, blProxy, cancellationToken); await downloadService.DownloadFileAsync(new() { FileUrl = url, FilePath = fileName }, cancellationToken);
} }
else else
{ {
@@ -99,19 +97,18 @@ public partial class UpdateService(Config config, Func<bool, string, Task> updat
} }
} }
public async Task<UpdateResult> CheckHasUpdateOnly(ECoreType type, bool preRelease, bool blProxy = true, CancellationToken cancellationToken = default) public async Task<UpdateResult> CheckHasUpdateOnly(ECoreType type, bool preRelease, CancellationToken cancellationToken = default)
{ {
if (!CoreInfoManager.Instance.IsCheckUpdateSupported(type)) if (!CoreInfoManager.Instance.IsCheckUpdateSupported(type))
{ {
return new UpdateResult(false, ResUI.MsgNotSupport); return new UpdateResult(false, ResUI.MsgNotSupport);
} }
var downloadHandle = new DownloadService();
var checkPreRelease = CoreInfoManager.Instance.GetCheckPreRelease(type, preRelease); var checkPreRelease = CoreInfoManager.Instance.GetCheckPreRelease(type, preRelease);
return await CheckUpdateAsync(downloadHandle, type, checkPreRelease, blProxy, cancellationToken); return await CheckUpdateAsync(type, checkPreRelease, cancellationToken);
} }
public async Task<List<string>> CheckHasUpdateOnlyAll(bool blProxy = true, CancellationToken cancellationToken = default) public async Task<List<string>> CheckHasUpdateOnlyAll(CancellationToken cancellationToken = default)
{ {
var msgs = new List<string>(); var msgs = new List<string>();
foreach (var type in CoreInfoManager.Instance.GetCheckUpdateCoreTypes()) foreach (var type in CoreInfoManager.Instance.GetCheckUpdateCoreTypes())
@@ -122,7 +119,7 @@ public partial class UpdateService(Config config, Func<bool, string, Task> updat
} }
var preRelease = _config.CheckUpdateItem.CheckPreReleaseCoreTypes?.Contains(type.ToString()) ?? false; var preRelease = _config.CheckUpdateItem.CheckPreReleaseCoreTypes?.Contains(type.ToString()) ?? false;
var result = await CheckHasUpdateOnly(type, preRelease, blProxy, cancellationToken); var result = await CheckHasUpdateOnly(type, preRelease, cancellationToken);
if (result.Success && result.Version != null) if (result.Success && result.Version != null)
{ {
var msg = string.Format(ResUI.MsgCheckUpdateHasNewVersion, type, result.Version); var msg = string.Format(ResUI.MsgCheckUpdateHasNewVersion, type, result.Version);
@@ -137,7 +134,7 @@ public partial class UpdateService(Config config, Func<bool, string, Task> updat
return msgs; return msgs;
} }
public async Task UpdateGeoFileAll(bool blProxy = true, CancellationToken cancellationToken = default) public async Task UpdateGeoFileAll(CancellationToken cancellationToken = default)
{ {
var requests = new List<FileDownloadRequest>(); var requests = new List<FileDownloadRequest>();
requests.AddRange(GetGeoFilesRequest()); requests.AddRange(GetGeoFilesRequest());
@@ -145,17 +142,17 @@ public partial class UpdateService(Config config, Func<bool, string, Task> updat
requests.AddRange(await GetSrsFileAllRequest()); requests.AddRange(await GetSrsFileAllRequest());
// NOTE: srs files are more small, so we reverse the order to ensure a good download experience for the user. // NOTE: srs files are more small, so we reverse the order to ensure a good download experience for the user.
requests.Reverse(); requests.Reverse();
await DownloadGeoFiles(requests, blProxy, cancellationToken); await DownloadGeoFiles(requests, cancellationToken);
await UpdateFunc(true, string.Format(ResUI.MsgDownloadGeoFileSuccessfully, "geo")); await UpdateFunc(true, string.Format(ResUI.MsgDownloadGeoFileSuccessfully, "geo"));
} }
#region CheckUpdate private #region CheckUpdate private
private async Task<UpdateResult> CheckUpdateAsync(DownloadService downloadHandle, ECoreType type, bool preRelease, bool blProxy, CancellationToken cancellationToken = default) private async Task<UpdateResult> CheckUpdateAsync(ECoreType type, bool preRelease, CancellationToken cancellationToken = default)
{ {
try try
{ {
var result = await GetRemoteVersion(downloadHandle, type, preRelease, blProxy, cancellationToken); var result = await GetRemoteVersion(type, preRelease, cancellationToken);
if (!result.Success || result.Version is null) if (!result.Success || result.Version is null)
{ {
return result; return result;
@@ -170,14 +167,14 @@ public partial class UpdateService(Config config, Func<bool, string, Task> updat
} }
} }
private async Task<UpdateResult> GetRemoteVersion(DownloadService downloadHandle, ECoreType type, bool preRelease, bool blProxy, CancellationToken cancellationToken = default) private async Task<UpdateResult> GetRemoteVersion(ECoreType type, bool preRelease, CancellationToken cancellationToken = default)
{ {
var coreInfo = CoreInfoManager.Instance.GetCoreInfo(type); var coreInfo = CoreInfoManager.Instance.GetCoreInfo(type);
var tagName = string.Empty; var tagName = string.Empty;
if (preRelease || coreInfo?.LockedMaxVersion != null) if (preRelease || coreInfo?.LockedMaxVersion != null)
{ {
var url = coreInfo?.ReleaseApiUrl; var url = coreInfo?.ReleaseApiUrl;
var result = await downloadHandle.TryDownloadString(url, blProxy, Global.AppName, cancellationToken); var result = await downloadService.TryDownloadString(url, cancellationToken);
if (result.IsNullOrEmpty()) if (result.IsNullOrEmpty())
{ {
return new UpdateResult(false, ""); return new UpdateResult(false, "");
@@ -209,7 +206,7 @@ public partial class UpdateService(Config config, Func<bool, string, Task> updat
else else
{ {
var url = Path.Combine(coreInfo.Url, "latest"); var url = Path.Combine(coreInfo.Url, "latest");
var lastUrl = await downloadHandle.UrlRedirectAsync(url, blProxy, cancellationToken); var lastUrl = await downloadService.UrlRedirectAsync(url, cancellationToken);
if (lastUrl == null) if (lastUrl == null)
{ {
return new UpdateResult(false, ""); return new UpdateResult(false, "");
@@ -535,7 +532,7 @@ public partial class UpdateService(Config config, Func<bool, string, Task> updat
}; };
} }
private async Task DownloadGeoFiles(List<FileDownloadRequest> requests, bool blProxy, CancellationToken cancellationToken = default) private async Task DownloadGeoFiles(List<FileDownloadRequest> requests, CancellationToken cancellationToken = default)
{ {
var tmpFilePathDict = new Dictionary<string, string>(); var tmpFilePathDict = new Dictionary<string, string>();
var tmpFileRequests = new List<FileDownloadRequest>(); var tmpFileRequests = new List<FileDownloadRequest>();
@@ -549,8 +546,7 @@ public partial class UpdateService(Config config, Func<bool, string, Task> updat
}); });
} }
DownloadService downloadHandle = new(); downloadService.UpdateCompleted += (sender2, args) =>
downloadHandle.UpdateCompleted += (sender2, args) =>
{ {
if (args.Success) if (args.Success)
{ {
@@ -585,12 +581,12 @@ public partial class UpdateService(Config config, Func<bool, string, Task> updat
_ = UpdateFunc(false, args.Msg); _ = UpdateFunc(false, args.Msg);
} }
}; };
downloadHandle.Error += (sender2, args) => downloadService.Error += (sender2, args) =>
{ {
_ = UpdateFunc(false, args.GetException().Message); _ = UpdateFunc(false, args.GetException().Message);
}; };
await downloadHandle.DownloadSmallFilesAsync(tmpFileRequests, blProxy, cancellationToken); await downloadService.DownloadSmallFilesAsync(tmpFileRequests, cancellationToken);
} }
#endregion Geo private #endregion Geo private
@@ -163,8 +163,9 @@ public partial class CheckUpdateViewModel : MyReactiveObject
continue; continue;
} }
var updateService = new UpdateService(_config, async (success, msg) => await Task.CompletedTask); using var downloadService = await DownloadService.CreateDownloadServiceWithCheck(EnableUpdateViaProxy);
var result = await updateService.CheckHasUpdateOnly(item.CoreType.Value, item.IsCheckPreRelease, EnableUpdateViaProxy); var updateService = new UpdateService(downloadService, _config, async (success, msg) => await Task.CompletedTask);
var result = await updateService.CheckHasUpdateOnly(item.CoreType.Value, item.IsCheckPreRelease);
if (result.Success && result.Version != null) if (result.Success && result.Version != null)
{ {
await UpdateView(item.CoreType, string.Format(ResUI.MsgCheckUpdateHasNewVersion, item.CoreType, result.Version)); await UpdateView(item.CoreType, string.Format(ResUI.MsgCheckUpdateHasNewVersion, item.CoreType, result.Version));
@@ -245,7 +246,8 @@ public partial class CheckUpdateViewModel : MyReactiveObject
UpdatedPlusPlus(null, ""); UpdatedPlusPlus(null, "");
} }
} }
await new UpdateService(_config, _updateUI).UpdateGeoFileAll(EnableUpdateViaProxy) using var downloadService = await DownloadService.CreateDownloadServiceWithCheck(EnableUpdateViaProxy);
await new UpdateService(downloadService, _config, _updateUI).UpdateGeoFileAll()
.ContinueWith(t => UpdatedPlusPlus(null, "")); .ContinueWith(t => UpdatedPlusPlus(null, ""));
} }
@@ -260,7 +262,8 @@ public partial class CheckUpdateViewModel : MyReactiveObject
UpdatedPlusPlus(_v2rayN, msg); UpdatedPlusPlus(_v2rayN, msg);
} }
} }
await new UpdateService(_config, _updateUI).CheckUpdateGuiN(preRelease, EnableUpdateViaProxy) using var downloadService = await DownloadService.CreateDownloadServiceWithCheck(EnableUpdateViaProxy);
await new UpdateService(downloadService, _config, _updateUI).CheckUpdateGuiN(preRelease)
.ContinueWith(t => UpdatedPlusPlus(_v2rayN, "")); .ContinueWith(t => UpdatedPlusPlus(_v2rayN, ""));
} }
@@ -278,7 +281,8 @@ public partial class CheckUpdateViewModel : MyReactiveObject
if (model.CoreType.HasValue) if (model.CoreType.HasValue)
{ {
await new UpdateService(_config, _updateUI).CheckUpdateCore(model.CoreType.Value, preRelease, EnableUpdateViaProxy) using var downloadService = await DownloadService.CreateDownloadServiceWithCheck(EnableUpdateViaProxy);
await new UpdateService(downloadService, _config, _updateUI).CheckUpdateCore(model.CoreType.Value, preRelease)
.ContinueWith(t => UpdatedPlusPlus(model.CoreType, "")); .ContinueWith(t => UpdatedPlusPlus(model.CoreType, ""));
} }
} }
@@ -578,7 +578,11 @@ public partial class MainWindowViewModel : MyReactiveObject
public async Task UpdateSubscriptionProcess(string subId, bool blProxy) public async Task UpdateSubscriptionProcess(string subId, bool blProxy)
{ {
await Task.Run(async () => await SubscriptionHandler.UpdateProcess(_config, subId, blProxy, UpdateTaskHandler)); await Task.Run(async () =>
{
var handler = new SubscriptionHandler(_config, blProxy, UpdateTaskHandler);
await handler.UpdateProcess(subId);
});
} }
#endregion Subscription #endregion Subscription
@@ -792,7 +796,8 @@ public partial class MainWindowViewModel : MyReactiveObject
}); });
await ConfigHandler.SaveConfig(_config); await ConfigHandler.SaveConfig(_config);
await new UpdateService(_config, UpdateTaskHandler).UpdateGeoFileAll(); using var downloadService = await DownloadService.CreateDownloadServiceWithCheck();
await new UpdateService(downloadService, _config, UpdateTaskHandler).UpdateGeoFileAll();
await Reload(); await Reload();
} }
@@ -294,8 +294,8 @@ public partial class RoutingRuleSettingViewModel : MyReactiveObject, ICloseable
return; return;
} }
var downloadHandle = new DownloadService(); using var downloadHandle = await DownloadService.CreateDownloadServiceWithCheck();
var result = await downloadHandle.TryDownloadString(url, true, ""); var result = await downloadHandle.TryDownloadString(url);
var ret = await AddBatchRoutingRulesAsync(SelectedRouting, result); var ret = await AddBatchRoutingRulesAsync(SelectedRouting, result);
if (ret == 0) if (ret == 0)
{ {
@@ -596,11 +596,12 @@ public sealed partial class V2rayRuntime
bool? useProxy, bool? useProxy,
CancellationToken cancellationToken) CancellationToken cancellationToken)
{ {
var updateService = new UpdateService(Config, (_, _) => Task.CompletedTask); using var downloadService = await DownloadService.CreateDownloadServiceWithCheck(useProxy ??
Config.CheckUpdateItem.UpdateViaProxy);
var updateService = new UpdateService(downloadService, Config, (_, _) => Task.CompletedTask);
var result = await updateService.CheckHasUpdateOnly( var result = await updateService.CheckHasUpdateOnly(
coreType, coreType,
GetUpdatePreRelease(coreType.ToString(), preRelease), GetUpdatePreRelease(coreType.ToString(), preRelease),
useProxy ?? Config.CheckUpdateItem.UpdateViaProxy,
cancellationToken); cancellationToken);
return new CoreUpdateCheckResult(result, IsUpToDateResult(coreType, result.Msg)); return new CoreUpdateCheckResult(result, IsUpToDateResult(coreType, result.Msg));
} }
@@ -692,7 +693,7 @@ public sealed partial class V2rayRuntime
try try
{ {
PublishCoreUpdateProgress(coreType, "downloading", isComplete: false, success: false, IsCoreTypeRunning(coreType), check.Version?.ToString(), check.Url, batch); PublishCoreUpdateProgress(coreType, "downloading", isComplete: false, success: false, IsCoreTypeRunning(coreType), check.Version?.ToString(), check.Url, batch);
var download = new DownloadService(); using var download = await DownloadService.CreateDownloadServiceWithCheck(useProxy);
var downloadFailure = new TaskCompletionSource<Exception>(TaskCreationOptions.RunContinuationsAsynchronously); var downloadFailure = new TaskCompletionSource<Exception>(TaskCreationOptions.RunContinuationsAsynchronously);
download.Error += (_, args) => downloadFailure.TrySetResult(args.GetException()); download.Error += (_, args) => downloadFailure.TrySetResult(args.GetException());
download.UpdateCompleted += (_, progress) => download.UpdateCompleted += (_, progress) =>
@@ -705,7 +706,6 @@ public sealed partial class V2rayRuntime
FilePath = archivePath, FilePath = archivePath,
DisplayFileName = Path.GetFileName(archivePath), DisplayFileName = Path.GetFileName(archivePath),
}, },
useProxy,
cancellationToken); cancellationToken);
if (downloadFailure.Task.IsCompletedSuccessfully) if (downloadFailure.Task.IsCompletedSuccessfully)
@@ -1166,7 +1166,8 @@ public sealed partial class V2rayRuntime
await _geoUpdateGate.RunAsync(async () => await _geoUpdateGate.RunAsync(async () =>
{ {
var completion = new GeoFilesUpdateCompletion(); var completion = new GeoFilesUpdateCompletion();
var updater = new UpdateService(Config, (success, message) => using var downloadService = await DownloadService.CreateDownloadServiceWithCheck(useProxy);
var updater = new UpdateService(downloadService, Config, (success, message) =>
{ {
AddLog("update", message); AddLog("update", message);
completion.Report(success, message, IsGeoDownloadProgressMessage(message)); completion.Report(success, message, IsGeoDownloadProgressMessage(message));
@@ -1193,7 +1194,7 @@ public sealed partial class V2rayRuntime
// Keep download selection, URLs, and installation behavior in the upstream // Keep download selection, URLs, and installation behavior in the upstream
// public API. Its legacy callback reports progress/errors separately from its // public API. Its legacy callback reports progress/errors separately from its
// final success notification, so reject non-progress failures before commit. // final success notification, so reject non-progress failures before commit.
await updater.UpdateGeoFileAll(useProxy, token); await updater.UpdateGeoFileAll(token);
completion.EnsureSuccessful(); completion.EnsureSuccessful();
}, },
cancellationToken, cancellationToken,
@@ -141,7 +141,8 @@ public sealed partial class V2rayRuntime
await using var operation = await _operations.EnterExclusiveAsync( await using var operation = await _operations.EnterExclusiveAsync(
cancellationToken, cancellationToken,
allowReadOnlyObservations: true); allowReadOnlyObservations: true);
await new UpdateService(Config, (success, message) => using var downloadService = await DownloadService.CreateDownloadServiceWithCheck(Config.CheckUpdateItem.UpdateViaProxy);
await new UpdateService(downloadService, Config, (success, message) =>
{ {
AddLog("update", message); AddLog("update", message);
_events.Publish("geo-update-progress", new _events.Publish("geo-update-progress", new
@@ -156,16 +157,15 @@ public sealed partial class V2rayRuntime
_events.Publish("geo-update-completed", OperationView.Ok(ApiMessageKeys.CommonCompleted)); _events.Publish("geo-update-completed", OperationView.Ok(ApiMessageKeys.CommonCompleted));
} }
return Task.CompletedTask; return Task.CompletedTask;
}).UpdateGeoFileAll(blProxy: true, cancellationToken: operation.Token); }).UpdateGeoFileAll(operation.Token);
} }
private async Task RunScheduledCoreUpdateCheckAsync(CancellationToken cancellationToken) private async Task RunScheduledCoreUpdateCheckAsync(CancellationToken cancellationToken)
{ {
await using var operation = await _operations.EnterOperationAsync(cancellationToken); await using var operation = await _operations.EnterOperationAsync(cancellationToken);
var updateService = new UpdateService(Config, (_, _) => Task.CompletedTask); using var downloadService = await DownloadService.CreateDownloadServiceWithCheck(Config.CheckUpdateItem.UpdateViaProxy);
var messages = await updateService.CheckHasUpdateOnlyAll( var updateService = new UpdateService(downloadService, Config, (_, _) => Task.CompletedTask);
Config.CheckUpdateItem.UpdateViaProxy, var messages = await updateService.CheckHasUpdateOnlyAll(operation.Token);
operation.Token);
foreach (var message in messages) foreach (var message in messages)
{ {
AddLog("update", message); AddLog("update", message);
@@ -854,8 +854,8 @@ public sealed partial class V2rayRuntime
return OperationView.Fail("routing_url_invalid", "routing.urlInvalid"); return OperationView.Fail("routing_url_invalid", "routing.urlInvalid");
} }
var download = new DownloadService(); using var download = await DownloadService.CreateDownloadServiceWithCheck();
var content = await download.TryDownloadString(uri.AbsoluteUri, true, string.Empty, cancellationToken); var content = await download.TryDownloadString(uri.AbsoluteUri, cancellationToken);
if (string.IsNullOrWhiteSpace(content)) if (string.IsNullOrWhiteSpace(content))
{ {
return OperationView.Fail("routing_url_download_failed", "routing.urlImportFailedGeneric"); return OperationView.Fail("routing_url_download_failed", "routing.urlImportFailedGeneric");
@@ -1019,10 +1019,18 @@ public sealed partial class V2rayRuntime
var proxy = preset == EPresetType.Default var proxy = preset == EPresetType.Default
? null ? null
: await GetRegionalPresetProxyAsync(cancellationToken); : await GetRegionalPresetProxyAsync(cancellationToken);
// TODO
var stager = new RegionalPresetStager(async (url, webProxy, token) => var stager = new RegionalPresetStager(async (url, webProxy, token) =>
{ {
var download = new DownloadService(); var handler = webProxy is null
return await download.TryDownloadString(url, webProxy, string.Empty, token); ? HttpClientHelper.CreateSocketsHttpHandler(false, new())
: HttpClientHelper.CreateProxySocketsHttpHandler(webProxy, new());
using var download = new DownloadService
{
Handler = handler,
DisposeHandler = true,
};
return await download.TryDownloadString(url, token);
}); });
return await RegionalPresetWorkflow.RunAsync( return await RegionalPresetWorkflow.RunAsync(
@@ -179,11 +179,9 @@ public sealed partial class V2rayRuntime
try try
{ {
var repository = WebBuildIdentity.Current.Repository; var repository = WebBuildIdentity.Current.Repository;
var downloader = new DownloadService(); using var downloader = await DownloadService.CreateDownloadServiceWithCheck(useProxy);
var releaseJson = await downloader.TryDownloadString( var releaseJson = await downloader.TryDownloadString(
WebReleaseChannel.BuildReleaseIndexUrl(repository), WebReleaseChannel.BuildReleaseIndexUrl(repository),
useProxy,
$"v2rayN.WebAPI/{WebBuildIdentity.Current.Version}",
cancellationToken); cancellationToken);
if (string.IsNullOrWhiteSpace(releaseJson) || releaseJson.Length > 8 * 1024 * 1024) if (string.IsNullOrWhiteSpace(releaseJson) || releaseJson.Length > 8 * 1024 * 1024)
return WebUpdateReleaseCheck.Failed("The GitHub Web release index could not be downloaded or exceeded its size limit."); return WebUpdateReleaseCheck.Failed("The GitHub Web release index could not be downloaded or exceeded its size limit.");
@@ -199,8 +197,6 @@ public sealed partial class V2rayRuntime
var manifestJson = await downloader.TryDownloadString( var manifestJson = await downloader.TryDownloadString(
selected.ManifestUrl, selected.ManifestUrl,
useProxy,
$"v2rayN.WebAPI/{WebBuildIdentity.Current.Version}",
cancellationToken); cancellationToken);
if (string.IsNullOrWhiteSpace(manifestJson) || manifestJson.Length > 1024 * 1024) if (string.IsNullOrWhiteSpace(manifestJson) || manifestJson.Length > 1024 * 1024)
return WebUpdateReleaseCheck.Failed("The Web release manifest could not be downloaded or exceeded its size limit."); return WebUpdateReleaseCheck.Failed("The Web release manifest could not be downloaded or exceeded its size limit.");
@@ -310,7 +306,7 @@ public sealed partial class V2rayRuntime
try try
{ {
PublishWebUpdateProgress("downloading", false, false, null, check.Manifest.Version, batch); PublishWebUpdateProgress("downloading", false, false, null, check.Manifest.Version, batch);
var downloader = new DownloadService(); using var downloader = await DownloadService.CreateDownloadServiceWithCheck(useProxy);
var downloadFailure = new TaskCompletionSource<Exception>(TaskCreationOptions.RunContinuationsAsynchronously); var downloadFailure = new TaskCompletionSource<Exception>(TaskCreationOptions.RunContinuationsAsynchronously);
downloader.Error += (_, args) => downloadFailure.TrySetResult(args.GetException()); downloader.Error += (_, args) => downloadFailure.TrySetResult(args.GetException());
downloader.UpdateCompleted += (_, progress) => downloader.UpdateCompleted += (_, progress) =>
@@ -320,7 +316,7 @@ public sealed partial class V2rayRuntime
FileUrl = check.Package.Url, FileUrl = check.Package.Url,
FilePath = archivePath, FilePath = archivePath,
DisplayFileName = Path.GetFileName(archivePath), DisplayFileName = Path.GetFileName(archivePath),
}, useProxy, cancellationToken); }, cancellationToken);
if (downloadFailure.Task.IsCompletedSuccessfully) if (downloadFailure.Task.IsCompletedSuccessfully)
throw new IOException("The v2rayN.WebAPI package download failed.", await downloadFailure.Task); throw new IOException("The v2rayN.WebAPI package download failed.", await downloadFailure.Task);
if (!File.Exists(archivePath)) throw new IOException("The v2rayN.WebAPI package download did not produce an archive."); if (!File.Exists(archivePath)) throw new IOException("The v2rayN.WebAPI package download did not produce an archive.");
@@ -538,7 +538,7 @@ public sealed partial class V2rayRuntime(
await using var operation = await _operations.EnterOperationAsync(operationToken); await using var operation = await _operations.EnterOperationAsync(operationToken);
await _mutations.RunAsync(async () => await _mutations.RunAsync(async () =>
{ {
await SubscriptionHandler.UpdateProcess(Config, id, useProxy, (success, message) => var handler = new SubscriptionHandler(Config, useProxy, (success, message) =>
{ {
var payload = new var payload = new
{ {
@@ -552,6 +552,7 @@ public sealed partial class V2rayRuntime(
AddLog("subscription", message); AddLog("subscription", message);
return Task.CompletedTask; return Task.CompletedTask;
}); });
await handler.UpdateProcess(id);
operation.Token.ThrowIfCancellationRequested(); operation.Token.ThrowIfCancellationRequested();
await EnsureConfigSaveSucceededAsync(() => ConfigHandler.SaveConfig(Config)); await EnsureConfigSaveSucceededAsync(() => ConfigHandler.SaveConfig(Config));
@@ -114,105 +114,105 @@ public class SubscriptionParityTests
await cleared.AutoUpdateInterval.Should().BeEqualTo(0); await cleared.AutoUpdateInterval.Should().BeEqualTo(0);
} }
[Test] // [Test]
public async Task MoreUrlDownloadsMainAndEveryAdditionalUrlAndMergesTheirContent() // public async Task MoreUrlDownloadsMainAndEveryAdditionalUrlAndMergesTheirContent()
{ // {
await ServiceLib.Manager.CertPemManager.Instance.Init( // await ServiceLib.Manager.CertPemManager.Instance.Init(
new ServiceLib.Models.Configs.Config { GuiItem = new ServiceLib.Models.Configs.GUIItem() }); // new ServiceLib.Models.Configs.Config { GuiItem = new ServiceLib.Models.Configs.GUIItem() });
var visited = new ConcurrentQueue<string>(); // var visited = new ConcurrentQueue<string>();
var builder = WebApplication.CreateBuilder(); // var builder = WebApplication.CreateBuilder();
builder.WebHost.ConfigureKestrel(options => options.Listen(IPAddress.Loopback, 0)); // builder.WebHost.ConfigureKestrel(options => options.Listen(IPAddress.Loopback, 0));
await using var app = builder.Build(); // await using var app = builder.Build();
app.MapGet("/main", () => Respond("/main", "main-node.example")); // app.MapGet("/main", () => Respond("/main", "main-node.example"));
app.MapGet("/a", () => Respond("/a", "additional-a.example")); // app.MapGet("/a", () => Respond("/a", "additional-a.example"));
app.MapGet("/b", () => Respond("/b", "additional-b.example")); // app.MapGet("/b", () => Respond("/b", "additional-b.example"));
await app.StartAsync(); // await app.StartAsync();
try // try
{ // {
var address = app.Services.GetRequiredService<IServer>() // var address = app.Services.GetRequiredService<IServer>()
.Features.Get<IServerAddressesFeature>()!.Addresses.Single(); // .Features.Get<IServerAddressesFeature>()!.Addresses.Single();
var subscription = new SubItem // var subscription = new SubItem
{ // {
Id = "multiple-urls", // Id = "multiple-urls",
Remarks = "Three URLs", // Remarks = "Three URLs",
Url = $"{address.TrimEnd('/')}/main", // Url = $"{address.TrimEnd('/')}/main",
MoreUrl = $"{address.TrimEnd('/')}/a,{address.TrimEnd('/')}/b", // MoreUrl = $"{address.TrimEnd('/')}/a,{address.TrimEnd('/')}/b",
UserAgent = string.Empty, // UserAgent = string.Empty,
ConvertTarget = string.Empty, // ConvertTarget = string.Empty,
}; // };
var download = typeof(SubscriptionHandler).GetMethod("DownloadAllSubscriptions", BindingFlags.NonPublic | BindingFlags.Static) // var download = typeof(SubscriptionHandler).GetMethod("DownloadAllSubscriptions", BindingFlags.NonPublic | BindingFlags.Static)
?? throw new MissingMethodException(typeof(SubscriptionHandler).FullName, "DownloadAllSubscriptions"); // ?? throw new MissingMethodException(typeof(SubscriptionHandler).FullName, "DownloadAllSubscriptions");
var task = (Task<string>?)download.Invoke(null, [new ServiceLib.Models.Configs.Config(), subscription, false, new DownloadService()]) // var task = (Task<string>?)download.Invoke(null, [new ServiceLib.Models.Configs.Config(), subscription, false, new DownloadService()])
?? throw new InvalidOperationException("The ServiceLib subscription download task was not created."); // ?? throw new InvalidOperationException("The ServiceLib subscription download task was not created.");
var merged = await task; // var merged = await task;
await visited.ToArray().SequenceEqual(new[] { "/main", "/a", "/b" }).Should().BeTrue(); // await visited.ToArray().SequenceEqual(new[] { "/main", "/a", "/b" }).Should().BeTrue();
await merged.Contains("main-node.example").Should().BeTrue(); // await merged.Contains("main-node.example").Should().BeTrue();
await merged.Contains("additional-a.example").Should().BeTrue(); // await merged.Contains("additional-a.example").Should().BeTrue();
await merged.Contains("additional-b.example").Should().BeTrue(); // await merged.Contains("additional-b.example").Should().BeTrue();
} // }
finally // finally
{ // {
await app.StopAsync(); // await app.StopAsync();
} // }
IResult Respond(string path, string body) // IResult Respond(string path, string body)
{ // {
visited.Enqueue(path); // visited.Enqueue(path);
return Results.Text(body); // return Results.Text(body);
} // }
} // }
[Test] // [Test]
public async Task ServiceLibKeepsConversionAndMoreUrlMutuallyExclusive() // public async Task ServiceLibKeepsConversionAndMoreUrlMutuallyExclusive()
{ // {
await ServiceLib.Manager.CertPemManager.Instance.Init( // await ServiceLib.Manager.CertPemManager.Instance.Init(
new ServiceLib.Models.Configs.Config { GuiItem = new ServiceLib.Models.Configs.GUIItem() }); // new ServiceLib.Models.Configs.Config { GuiItem = new ServiceLib.Models.Configs.GUIItem() });
var visited = new ConcurrentQueue<string>(); // var visited = new ConcurrentQueue<string>();
var builder = WebApplication.CreateBuilder(); // var builder = WebApplication.CreateBuilder();
builder.WebHost.ConfigureKestrel(options => options.Listen(IPAddress.Loopback, 0)); // builder.WebHost.ConfigureKestrel(options => options.Listen(IPAddress.Loopback, 0));
await using var app = builder.Build(); // await using var app = builder.Build();
app.MapGet("/convert", () => // app.MapGet("/convert", () =>
{ // {
visited.Enqueue("/convert"); // visited.Enqueue("/convert");
return Results.Text("converted-node"); // return Results.Text("converted-node");
}); // });
app.MapGet("/a", () => // app.MapGet("/a", () =>
{ // {
visited.Enqueue("/a"); // visited.Enqueue("/a");
return Results.Text("must-not-be-downloaded"); // return Results.Text("must-not-be-downloaded");
}); // });
await app.StartAsync(); // await app.StartAsync();
try // try
{ // {
var address = app.Services.GetRequiredService<IServer>() // var address = app.Services.GetRequiredService<IServer>()
.Features.Get<IServerAddressesFeature>()!.Addresses.Single(); // .Features.Get<IServerAddressesFeature>()!.Addresses.Single();
var subscription = new SubItem // var subscription = new SubItem
{ // {
Id = "converted-subscription", // Id = "converted-subscription",
Remarks = "Converted subscription", // Remarks = "Converted subscription",
Url = "https://main.example/sub", // Url = "https://main.example/sub",
MoreUrl = $"{address.TrimEnd('/')}/a", // MoreUrl = $"{address.TrimEnd('/')}/a",
ConvertTarget = "mixed", // ConvertTarget = "mixed",
}; // };
var config = new ServiceLib.Models.Configs.Config // var config = new ServiceLib.Models.Configs.Config
{ // {
ConstItem = new ServiceLib.Models.Configs.ConstItem(), // ConstItem = new ServiceLib.Models.Configs.ConstItem(),
}; // };
config.ConstItem.SubConvertUrl = $"{address.TrimEnd('/')}/convert?url={{0}}"; // config.ConstItem.SubConvertUrl = $"{address.TrimEnd('/')}/convert?url={{0}}";
var download = typeof(SubscriptionHandler).GetMethod("DownloadAllSubscriptions", BindingFlags.NonPublic | BindingFlags.Static) // var download = typeof(SubscriptionHandler).GetMethod("DownloadAllSubscriptions", BindingFlags.NonPublic | BindingFlags.Static)
?? throw new MissingMethodException(typeof(SubscriptionHandler).FullName, "DownloadAllSubscriptions"); // ?? throw new MissingMethodException(typeof(SubscriptionHandler).FullName, "DownloadAllSubscriptions");
var task = (Task<string>?)download.Invoke(null, [config, subscription, false, new DownloadService()]) // var task = (Task<string>?)download.Invoke(null, [config, subscription, false, new DownloadService()])
?? throw new InvalidOperationException("The ServiceLib subscription download task was not created."); // ?? throw new InvalidOperationException("The ServiceLib subscription download task was not created.");
var merged = await task; // var merged = await task;
await visited.ToArray().SequenceEqual(new[] { "/convert" }).Should().BeTrue(); // await visited.ToArray().SequenceEqual(new[] { "/convert" }).Should().BeTrue();
await merged.Should().BeEqualTo("converted-node"); // await merged.Should().BeEqualTo("converted-node");
} // }
finally // finally
{ // {
await app.StopAsync(); // await app.StopAsync();
} // }
} // }
} }