Add custom HTTP headers for subscription updates (#10118)

* Add custom HTTP headers for subscription updates

* Res

---------

Co-authored-by: 2dust <31833384+2dust@users.noreply.github.com>
This commit is contained in:
稔君
2026-09-09 14:24:05 +08:00
committed by GitHub
parent 143ada1358
commit b10c16a94a
17 changed files with 485 additions and 35 deletions

View File

@@ -0,0 +1,77 @@
namespace ServiceLib.Tests.Helper;
public class HttpRequestHeadersHelperTests
{
[Test]
public async Task TryParse_ShouldAcceptEmptySettingsForExistingSubscriptions()
{
foreach (var json in new string?[] { null, "", " \r\n ", "{}" })
{
await HttpRequestHeadersHelper.TryParse(json, out var headers).Should().BeTrue();
await headers.Count.Should().BeEqualTo(0);
}
}
[Test]
public async Task TryParse_ShouldPreserveValuesAndUseCaseInsensitiveNames()
{
const string json = """
{
"X-hwid": "my_test_device",
"Authorization": "Bearer test:token",
"accept": "application/json",
"Content-Type": "application/json",
"X-Empty": ""
}
""";
await HttpRequestHeadersHelper.TryParse(json, out var headers).Should().BeTrue();
await headers["x-HWID"].Should().BeEqualTo("my_test_device");
await headers["AUTHORIZATION"].Should().BeEqualTo("Bearer test:token");
await headers["Accept"].Should().BeEqualTo("application/json");
await headers["Content-Type"].Should().BeEqualTo("application/json");
await headers["X-Empty"].Should().BeEqualTo("");
}
[Test]
[Arguments("not-json")]
[Arguments("null")]
[Arguments("[]")]
[Arguments("{\"X-Test\": 1}")]
[Arguments("{\"X-Test\": null}")]
[Arguments("{\"X-Test\": [\"one\", \"two\"]}")]
[Arguments("{\"X-Test\": \"one\", \"X-Test\": \"two\"}")]
[Arguments("{\"Accept\": \"one\", \"accept\": \"two\"}")]
[Arguments("{\"Bad Header\": \"value\"}")]
[Arguments("{\"Bad:Header\": \"value\"}")]
[Arguments("{\"\": \"value\"}")]
[Arguments("{\"X-Test\": \"one\\r\\nInjected: two\"}")]
[Arguments("{\"X-Test\": \"one\\nInjected: two\"}")]
[Arguments("{\"X-Test\": \"one\\u0000two\"}")]
public async Task TryParse_ShouldRejectInvalidHeadersWithoutReturningPartialSettings(string json)
{
await HttpRequestHeadersHelper.TryParse(json, out var headers).Should().BeFalse();
await headers.Count.Should().BeEqualTo(0);
}
[Test]
public async Task RequestHeaders_ShouldSurviveDatabaseMigrationAndEditing()
{
using var database = new SQLiteConnection(":memory:", false);
database.Execute("CREATE TABLE SubItem (Id TEXT PRIMARY KEY, Remarks TEXT, Url TEXT)");
database.Execute("INSERT INTO SubItem (Id, Remarks, Url) VALUES (?, ?, ?)", "existing", "Existing", "https://example.com/sub");
database.CreateTable<SubItem>();
var item = database.Find<SubItem>("existing");
await HttpRequestHeadersHelper.TryParse(item.RequestHeaders, out var oldHeaders).Should().BeTrue();
await oldHeaders.Count.Should().BeEqualTo(0);
item.RequestHeaders = "{\"X-hwid\":\"my_device\"}";
database.Update(item);
await database.Find<SubItem>(item.Id).RequestHeaders.Should().BeEqualTo(item.RequestHeaders);
item.RequestHeaders = "";
database.Update(item);
await database.Find<SubItem>(item.Id).RequestHeaders.Should().BeEqualTo("");
}
}

View File

@@ -0,0 +1,159 @@
namespace ServiceLib.Tests.Services;
public class DownloadServiceHeadersTests
{
[Test]
[Arguments(false, false)]
[Arguments(false, true)]
[Arguments(true, false)]
[Arguments(true, true)]
public async Task TryDownloadString_ShouldSendCustomHeadersThroughBothDownloaders(bool useProxy, bool failFirstRequest)
{
await CertPemManager.Instance.Init(new Config { GuiItem = new GUIItem() });
await using var server = new SubscriptionHttpServer(failFirstRequest);
const string json = """
{
"accept": "application/json",
"user-agent": "CustomSubscriptionClient/1.0",
"authorization": "Bearer test-token",
"X-hwid": "test-device",
"Cookie": "session=test",
"Content-Type": "application/json"
}
""";
await HttpRequestHeadersHelper.TryParse(json, out var headers).Should().BeTrue();
var service = new DownloadService { AcceptHeader = "*/*", RequestHeaders = headers };
var uri = new UriBuilder(useProxy ? "http://subscription.invalid/sub" : server.Url)
{
UserName = "user",
Password = "password"
}.Uri;
IWebProxy? proxy = useProxy ? new WebProxy(server.Url) : null;
var content = await service.TryDownloadString(uri.AbsoluteUri, proxy, "OriginalClient/1.0").WaitAsync(TimeSpan.FromSeconds(20));
await content.Should().BeEqualTo(SubscriptionHttpServer.Body);
await (server.Requests.Count >= (failFirstRequest ? 2 : 1)).Should().BeTrue();
foreach (var request in server.Requests)
{
await request["Accept"].Should().BeEqualTo("application/json");
await request["User-Agent"].Should().BeEqualTo("CustomSubscriptionClient/1.0");
await request["Authorization"].Should().BeEqualTo("Bearer test-token");
await request["X-hwid"].Should().BeEqualTo("test-device");
await request["Cookie"].Should().BeEqualTo("session=test");
await request["Content-Type"].Should().BeEqualTo("application/json");
}
}
[Test]
[Arguments(false)]
[Arguments(true)]
public async Task TryDownloadString_ShouldKeepDefaultAcceptUserAgentAndBasicAuth(bool failFirstRequest)
{
await CertPemManager.Instance.Init(new Config { GuiItem = new GUIItem() });
await using var server = new SubscriptionHttpServer(failFirstRequest);
var service = new DownloadService { AcceptHeader = "*/*" };
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 content.Should().BeEqualTo(SubscriptionHttpServer.Body);
foreach (var request in server.Requests)
{
await request["Accept"].Should().BeEqualTo("*/*");
await request["User-Agent"].Should().BeEqualTo("ExistingClient/1.0");
await request["Authorization"].Should().BeEqualTo("Basic dXNlcjpwYXNzd29yZA==");
await request.ContainsKey("X-hwid").Should().BeFalse();
}
}
[Test]
public async Task TryDownloadString_ShouldNotShareHeadersWithOtherDownloads()
{
await CertPemManager.Instance.Init(new Config { GuiItem = new GUIItem() });
await using var server = new SubscriptionHttpServer();
var subscription = new DownloadService
{
AcceptHeader = "*/*",
RequestHeaders = new Dictionary<string, string> { ["X-hwid"] = "first-device" }
};
var ordinaryDownload = new DownloadService();
await (await subscription.TryDownloadString(server.Url, (IWebProxy?)null, "TestClient/1.0")).Should().BeEqualTo(SubscriptionHttpServer.Body);
await (await ordinaryDownload.TryDownloadString(server.Url, (IWebProxy?)null, "TestClient/1.0")).Should().BeEqualTo(SubscriptionHttpServer.Body);
var requests = server.Requests.ToArray();
await requests.Length.Should().BeEqualTo(2);
await requests[0]["X-hwid"].Should().BeEqualTo("first-device");
await requests[1].ContainsKey("X-hwid").Should().BeFalse();
await requests[1].ContainsKey("Accept").Should().BeFalse();
}
private sealed class SubscriptionHttpServer : IAsyncDisposable
{
public const string Body = "subscription-test-content";
private readonly TcpListener _listener = new(IPAddress.Loopback, 0);
private readonly CancellationTokenSource _cancellation = new();
private readonly Task _serverTask;
private readonly bool _failFirstRequest;
public string Url { get; }
public ConcurrentQueue<Dictionary<string, string>> Requests { get; } = new();
public SubscriptionHttpServer(bool failFirstRequest = false)
{
_failFirstRequest = failFirstRequest;
_listener.Start();
Url = $"http://127.0.0.1:{((IPEndPoint)_listener.LocalEndpoint).Port}/subscription";
_serverTask = ServeAsync();
}
private async Task ServeAsync()
{
var cancellationToken = _cancellation.Token;
while (!cancellationToken.IsCancellationRequested)
{
using var client = await _listener.AcceptTcpClientAsync(cancellationToken);
await using var stream = client.GetStream();
using var reader = new StreamReader(stream, Encoding.ASCII, leaveOpen: true);
var requestLine = await reader.ReadLineAsync(cancellationToken);
if (requestLine == null)
{
continue;
}
var headers = new Dictionary<string, string>(StringComparer.OrdinalIgnoreCase);
while (await reader.ReadLineAsync(cancellationToken) is { Length: > 0 } line)
{
var separator = line.IndexOf(':');
var name = line.Substring(0, separator);
var value = line.Substring(separator + 1).Trim();
headers[name] = headers.TryGetValue(name, out var previous) ? $"{previous}, {value}" : value;
}
Requests.Enqueue(headers);
var status = _failFirstRequest && Requests.Count == 1 ? "503 Service Unavailable" : "200 OK";
var body = requestLine.StartsWith("HEAD ") ? "" : Body;
var response = $"HTTP/1.1 {status}\r\nContent-Length: {Body.Length}\r\nConnection: close\r\n\r\n{body}";
await stream.WriteAsync(Encoding.ASCII.GetBytes(response), cancellationToken);
}
}
public async ValueTask DisposeAsync()
{
await _cancellation.CancelAsync();
_listener.Stop();
try
{
await _serverTask;
}
catch (OperationCanceledException)
{
}
finally
{
_cancellation.Dispose();
}
}
}
}

View File

@@ -2202,6 +2202,7 @@ public static class ConfigHandler
item.Enabled = subItem.Enabled;
item.AutoUpdateInterval = subItem.AutoUpdateInterval;
item.UserAgent = subItem.UserAgent;
item.RequestHeaders = subItem.RequestHeaders;
item.Sort = subItem.Sort;
item.Filter = subItem.Filter;
item.UpdateTime = subItem.UpdateTime;

View File

@@ -31,7 +31,7 @@ public static class SubscriptionHandler
}
// Create download handler
var downloadHandle = CreateDownloadHandler(hashCode, updateFunc);
var downloadHandle = CreateDownloadHandler(item, hashCode, updateFunc);
await updateFunc?.Invoke(false, $"{hashCode}{ResUI.MsgStartGettingSubscriptions}");
// Get all subscription content (main subscription + additional subscriptions)
@@ -80,9 +80,18 @@ public static class SubscriptionHandler
return true;
}
private static DownloadService CreateDownloadHandler(string hashCode, Func<bool, string, Task> updateFunc)
private static DownloadService CreateDownloadHandler(SubItem item, string hashCode, Func<bool, string, Task> updateFunc)
{
var downloadHandle = new DownloadService { AcceptHeader = "*/*" };
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}");

View File

@@ -8,7 +8,8 @@ public class DownloaderHelper
private static readonly Lazy<DownloaderHelper> _instance = new(() => new());
public static DownloaderHelper Instance => _instance.Value;
public async Task<string?> DownloadStringAsync(IWebProxy? webProxy, string url, string? userAgent, int timeout)
public async Task<string?> DownloadStringAsync(IWebProxy? webProxy, string url, string? userAgent, int timeout,
IReadOnlyDictionary<string, string>? requestHeaders = null, string? acceptHeader = null)
{
if (url.IsNullOrEmpty())
{
@@ -28,6 +29,7 @@ public class DownloaderHelper
var requestConfiguration = new RequestConfiguration()
{
Headers = headers,
Accept = acceptHeader,
UserAgent = userAgent,
ConnectTimeout = connectTimeout * 1000,
Proxy = webProxy
@@ -37,7 +39,7 @@ public class DownloaderHelper
BlockTimeout = timeout * 1000,
MaxTryAgainOnFailure = 2,
RequestConfiguration = requestConfiguration,
CustomHttpMessageHandlerFactory = () => GetSocketsHttpHandler(requestConfiguration),
CustomHttpMessageHandlerFactory = () => HttpRequestHeadersHelper.CreateHandler(GetSocketsHttpHandler(requestConfiguration), requestHeaders),
};
await using var downloader = new Downloader.DownloadService(downloadOpt);

View File

@@ -0,0 +1,88 @@
namespace ServiceLib.Helper;
public static class HttpRequestHeadersHelper
{
public static bool TryParse(string? json, out Dictionary<string, string> headers)
{
headers = new(StringComparer.OrdinalIgnoreCase);
if (string.IsNullOrWhiteSpace(json))
{
return true;
}
try
{
using var document = JsonDocument.Parse(json);
if (document.RootElement.ValueKind != JsonValueKind.Object)
{
return false;
}
var parsed = new Dictionary<string, string>(StringComparer.OrdinalIgnoreCase);
using var request = new HttpRequestMessage { Content = new ByteArrayContent([]) };
foreach (var property in document.RootElement.EnumerateObject())
{
if (property.Value.ValueKind != JsonValueKind.String
|| !parsed.TryAdd(property.Name, property.Value.GetString()!)
|| !TryAddHeader(request, property.Name, parsed[property.Name]))
{
return false;
}
}
headers = parsed;
return true;
}
catch (Exception ex) when (ex is JsonException or ArgumentException or FormatException)
{
return false;
}
}
public static HttpMessageHandler CreateHandler(HttpMessageHandler innerHandler, IReadOnlyDictionary<string, string>? headers)
{
return headers is { Count: > 0 } ? new RequestHeadersHandler(innerHandler, headers) : innerHandler;
}
private static bool TryAddHeader(HttpRequestMessage request, string name, string value)
{
if (value.Any(c => char.IsControl(c) && c != '\t'))
{
return false;
}
return request.Headers.TryAddWithoutValidation(name, value)
|| request.Content!.Headers.TryAddWithoutValidation(name, value);
}
private sealed class RequestHeadersHandler(HttpMessageHandler innerHandler, IReadOnlyDictionary<string, string> headers)
: DelegatingHandler(innerHandler)
{
protected override Task<HttpResponseMessage> SendAsync(HttpRequestMessage request, CancellationToken cancellationToken)
{
using var customHeaders = new HttpRequestMessage { Content = new ByteArrayContent([]) };
foreach (var header in headers)
{
if (!TryAddHeader(customHeaders, header.Key, header.Value))
{
throw new FormatException(ResUI.SubRequestHeadersInvalid);
}
}
// Apply after each downloader's defaults, replacing headers without changing their values.
foreach (var header in customHeaders.Headers.NonValidated)
{
request.Headers.Remove(header.Key);
request.Headers.TryAddWithoutValidation(header.Key, header.Value);
}
foreach (var header in customHeaders.Content.Headers.NonValidated)
{
request.Content ??= new ByteArrayContent([]);
request.Content.Headers.Remove(header.Key);
request.Content.Headers.TryAddWithoutValidation(header.Key, header.Value);
}
return base.SendAsync(request, cancellationToken);
}
}
}

View File

@@ -16,6 +16,8 @@ public class SubItem
public string UserAgent { get; set; } = string.Empty;
public string? RequestHeaders { get; set; }
public int Sort { get; set; }
public string? Filter { get; set; }

View File

@@ -555,6 +555,15 @@ namespace ServiceLib.Resx {
}
}
/// <summary>
/// 查找类似 HTTP headers (JSON) 的本地化字符串。
/// </summary>
public static string LvRequestHeaders {
get {
return ResourceManager.GetString("LvRequestHeaders", resourceCulture);
}
}
/// <summary>
/// 查找类似 Type 的本地化字符串。
/// </summary>
@@ -2634,6 +2643,24 @@ namespace ServiceLib.Resx {
}
}
/// <summary>
/// 查找类似 Invalid HTTP headers. Use a JSON object with unique header names and string values. Header names and values must not contain line breaks. 的本地化字符串。
/// </summary>
public static string SubRequestHeadersInvalid {
get {
return ResourceManager.GetString("SubRequestHeadersInvalid", resourceCulture);
}
}
/// <summary>
/// 查找类似 Optional. Example: {&quot;X-hwid&quot;: &quot;my_device&quot;}. Values override default headers for all URLs in this group, including the subscription conversion service. 的本地化字符串。
/// </summary>
public static string SubRequestHeadersTips {
get {
return ResourceManager.GetString("SubRequestHeadersTips", resourceCulture);
}
}
/// <summary>
/// 查找类似 For group please leave blank here 的本地化字符串。
/// </summary>

View File

@@ -1887,7 +1887,16 @@ The "Get Certificate" action may fail if a self-signed certificate is used or if
<data name="TbSettingsMux4Ray" xml:space="preserve">
<value>Xray Mux setting</value>
</data>
<data name="LvRequestHeaders" xml:space="preserve">
<value>HTTP headers (JSON)</value>
</data>
<data name="SubRequestHeadersTips" xml:space="preserve">
<value>Optional. Example: {"X-hwid": "my_device"}. Values override default headers for all URLs in this group, including the subscription conversion service.</value>
</data>
<data name="SubRequestHeadersInvalid" xml:space="preserve">
<value>Invalid HTTP headers. Use a JSON object with unique header names and string values. Header names and values must not contain line breaks.</value>
</data>
<data name="TbSortingProcess" xml:space="preserve">
<value>Process</value>
</data>
</root>
</root>

View File

@@ -1879,7 +1879,16 @@
<data name="TbSettingsMux4Ray" xml:space="preserve">
<value>Xray Mux 设置</value>
</data>
<data name="LvRequestHeaders" xml:space="preserve">
<value>HTTP 请求头 (JSON)</value>
</data>
<data name="SubRequestHeadersTips" xml:space="preserve">
<value>可选,示例:{"X-hwid": "my_device"}。自定义值覆盖默认请求头,应用于本组所有订阅地址(包括订阅转换服务)。</value>
</data>
<data name="SubRequestHeadersInvalid" xml:space="preserve">
<value>HTTP 请求头无效。请使用 JSON 对象,键名不能重复,值必须为字符串;请求头名称和值不能包含换行符。</value>
</data>
<data name="TbSortingProcess" xml:space="preserve">
<value>进程</value>
</data>
</root>
</root>

View File

@@ -1864,4 +1864,16 @@
<data name="LvCustomCoreType" xml:space="preserve">
<value>自訂設定核心</value>
</data>
<data name="LvRequestHeaders" xml:space="preserve">
<value>HTTP 請求標頭 (JSON)</value>
</data>
<data name="SubRequestHeadersTips" xml:space="preserve">
<value>可選,範例:{"X-hwid": "my_device"}。自訂值會覆寫預設標頭,套用於本組所有訂閱位址(包括訂閱轉換服務)。</value>
</data>
<data name="SubRequestHeadersInvalid" xml:space="preserve">
<value>HTTP 請求標頭無效。請使用 JSON 物件,鍵名不能重複,值必須為字串;標頭名稱和值不能包含換行字元。</value>
</data>
<data name="TbSortingProcess" xml:space="preserve">
<value>进程</value>
</data>
</root>

View File

@@ -13,6 +13,8 @@ public class DownloadService
public string? AcceptHeader { get; init; }
public IReadOnlyDictionary<string, string>? RequestHeaders { get; init; }
private static readonly string _tag = "DownloadService";
/// <summary>
@@ -246,7 +248,7 @@ public class DownloadService
handler.SslOptions.RemoteCertificateValidationCallback = null;
}
using var client = new HttpClient(handler)
using var client = new HttpClient(HttpRequestHeadersHelper.CreateHandler(handler, RequestHeaders))
{
Timeout = Timeout.InfiniteTimeSpan
};
@@ -297,7 +299,7 @@ public class DownloadService
{
userAgent = Utils.GetVersion(false);
}
var result = await DownloaderHelper.Instance.DownloadStringAsync(webProxy, url, userAgent, timeout);
var result = await DownloaderHelper.Instance.DownloadStringAsync(webProxy, url, userAgent, timeout, RequestHeaders, AcceptHeader);
return result;
}
catch (Exception ex)

View File

@@ -77,6 +77,12 @@ public partial class SubEditViewModel : MyReactiveObject, ICloseable
}
}
if (!HttpRequestHeadersHelper.TryParse(SelectedSource.RequestHeaders, out _))
{
NoticeManager.Instance.Enqueue(ResUI.SubRequestHeadersInvalid);
return;
}
SelectedSource.CustomCoreType = Enum.TryParse<ECoreType>(CustomCoreType, out var coreType) ? coreType : null;
SelectedSource.PrevProfile = PrevProfile;
SelectedSource.NextProfile = NextProfile;

View File

@@ -186,79 +186,101 @@
Grid.Row="8"
Grid.Column="0"
Margin="{StaticResource Margin4}"
VerticalAlignment="Top"
Text="{x:Static resx:ResUI.LvRequestHeaders}" />
<StackPanel Grid.Row="8" Grid.Column="1">
<TextBox
x:Name="txtRequestHeaders"
Margin="{StaticResource Margin4}"
AcceptsReturn="True"
MinLines="4"
MaxLines="8"
Classes="TextArea"
TextWrapping="Wrap"
ScrollViewer.VerticalScrollBarVisibility="Auto" />
<TextBlock
Margin="{StaticResource Margin4}"
Text="{x:Static resx:ResUI.SubRequestHeadersTips}"
TextWrapping="Wrap" />
</StackPanel>
<TextBlock
Grid.Row="9"
Grid.Column="0"
Margin="{StaticResource Margin4}"
VerticalAlignment="Center"
Text="{x:Static resx:ResUI.LvSort}" />
<TextBox
x:Name="txtSort"
Grid.Row="8"
Grid.Row="9"
Grid.Column="1"
Width="100"
Margin="{StaticResource Margin4}"
HorizontalAlignment="Left" />
<TextBlock
Grid.Row="9"
Grid.Row="10"
Grid.Column="0"
Margin="{StaticResource Margin4}"
VerticalAlignment="Center"
Text="{x:Static resx:ResUI.LvPrevProfile}" />
<TextBox
x:Name="txtPrevProfile"
Grid.Row="9"
Grid.Row="10"
Grid.Column="1"
Margin="{StaticResource Margin4}"
VerticalAlignment="Center"
PlaceholderText="{x:Static resx:ResUI.LvPrevProfileTip}" />
<Button
x:Name="btnSelectPrevProfile"
Grid.Row="9"
Grid.Row="10"
Grid.Column="2"
Margin="{StaticResource Margin4}"
Content="{x:Static resx:ResUI.TbSelectProfile}" />
<TextBlock
Grid.Row="10"
Grid.Row="11"
Grid.Column="0"
Margin="{StaticResource Margin4}"
VerticalAlignment="Center"
Text="{x:Static resx:ResUI.LvNextProfile}" />
<TextBox
x:Name="txtNextProfile"
Grid.Row="10"
Grid.Row="11"
Grid.Column="1"
Margin="{StaticResource Margin4}"
VerticalAlignment="Center"
PlaceholderText="{x:Static resx:ResUI.LvPrevProfileTip}" />
<Button
x:Name="btnSelectNextProfile"
Grid.Row="10"
Grid.Row="11"
Grid.Column="2"
Margin="{StaticResource Margin4}"
Content="{x:Static resx:ResUI.TbSelectProfile}" />
<TextBlock
Grid.Row="11"
Grid.Row="12"
Grid.Column="0"
Margin="{StaticResource Margin4}"
VerticalAlignment="Center"
Text="{x:Static resx:ResUI.LvCustomCoreType}" />
<ComboBox
x:Name="cmbCustomCoreType"
Grid.Row="11"
Grid.Row="12"
Grid.Column="1"
Width="200"
Margin="{StaticResource Margin4}"
HorizontalAlignment="Left" />
<TextBlock
Grid.Row="12"
Grid.Row="13"
Grid.Column="0"
Margin="{StaticResource Margin4}"
VerticalAlignment="Center"
Text="{x:Static resx:ResUI.TbPreSocksPort4Sub}" />
<TextBox
x:Name="txtPreSocksPort"
Grid.Row="12"
Grid.Row="13"
Grid.Column="1"
Width="200"
Margin="{StaticResource Margin4}"
@@ -267,14 +289,14 @@
ToolTip.Tip="{x:Static resx:ResUI.TipPreSocksPort}" />
<TextBlock
Grid.Row="13"
Grid.Row="14"
Grid.Column="0"
Margin="{StaticResource Margin4}"
VerticalAlignment="Center"
Text="{x:Static resx:ResUI.LvMemo}" />
<TextBox
x:Name="txtMemo"
Grid.Row="13"
Grid.Row="14"
Grid.Column="1"
Margin="{StaticResource Margin4}"
VerticalAlignment="Center"

View File

@@ -22,6 +22,7 @@ public partial class SubEditWindow : WindowBase<SubEditViewModel>
this.Bind(ViewModel, vm => vm.SelectedSource.Enabled, v => v.togEnable.IsChecked).DisposeWith(disposables);
this.Bind(ViewModel, vm => vm.SelectedSource.AutoUpdateInterval, v => v.txtAutoUpdateInterval.Text).DisposeWith(disposables);
this.Bind(ViewModel, vm => vm.SelectedSource.UserAgent, v => v.txtUserAgent.Text).DisposeWith(disposables);
this.Bind(ViewModel, vm => vm.SelectedSource.RequestHeaders, v => v.txtRequestHeaders.Text).DisposeWith(disposables);
this.Bind(ViewModel, vm => vm.SelectedSource.Sort, v => v.txtSort.Text).DisposeWith(disposables);
this.Bind(ViewModel, vm => vm.SelectedSource.Filter, v => v.txtFilter.Text).DisposeWith(disposables);
this.Bind(ViewModel, vm => vm.SelectedSource.ConvertTarget, v => v.cmbConvertTarget.SelectedValue).DisposeWith(disposables);

View File

@@ -65,6 +65,7 @@
<RowDefinition Height="Auto" />
<RowDefinition Height="Auto" />
<RowDefinition Height="Auto" />
<RowDefinition Height="Auto" />
</Grid.RowDefinitions>
<Grid.ColumnDefinitions>
<ColumnDefinition Width="Auto" />
@@ -230,12 +231,34 @@
Grid.Row="8"
Grid.Column="0"
Margin="{StaticResource Margin4}"
VerticalAlignment="Top"
Text="{x:Static resx:ResUI.LvRequestHeaders}" />
<StackPanel Grid.Row="8" Grid.Column="1">
<TextBox
x:Name="txtRequestHeaders"
Margin="{StaticResource Margin4}"
AcceptsReturn="True"
MinLines="4"
MaxLines="8"
Style="{StaticResource MyOutlinedTextBox}"
TextWrapping="Wrap"
VerticalScrollBarVisibility="Auto" />
<TextBlock
Margin="{StaticResource Margin4}"
Text="{x:Static resx:ResUI.SubRequestHeadersTips}"
TextWrapping="Wrap" />
</StackPanel>
<TextBlock
Grid.Row="9"
Grid.Column="0"
Margin="{StaticResource Margin4}"
VerticalAlignment="Center"
Style="{StaticResource ToolbarTextBlock}"
Text="{x:Static resx:ResUI.LvSort}" />
<TextBox
x:Name="txtSort"
Grid.Row="8"
Grid.Row="9"
Grid.Column="1"
Width="100"
Margin="{StaticResource Margin4}"
@@ -245,7 +268,7 @@
Style="{StaticResource MyOutlinedTextBox}" />
<TextBlock
Grid.Row="9"
Grid.Row="10"
Grid.Column="0"
Margin="{StaticResource Margin4}"
VerticalAlignment="Center"
@@ -253,7 +276,7 @@
Text="{x:Static resx:ResUI.LvPrevProfile}" />
<TextBox
x:Name="txtPrevProfile"
Grid.Row="9"
Grid.Row="10"
Grid.Column="1"
Margin="{StaticResource Margin4}"
VerticalAlignment="Center"
@@ -262,7 +285,7 @@
Style="{StaticResource MyOutlinedTextBox}" />
<Button
x:Name="btnSelectPrevProfile"
Grid.Row="9"
Grid.Row="10"
Grid.Column="2"
Margin="{StaticResource Margin4}"
VerticalAlignment="Center"
@@ -270,7 +293,7 @@
Style="{StaticResource DefButton}" />
<TextBlock
Grid.Row="10"
Grid.Row="11"
Grid.Column="0"
Margin="{StaticResource Margin4}"
VerticalAlignment="Center"
@@ -278,7 +301,7 @@
Text="{x:Static resx:ResUI.LvNextProfile}" />
<TextBox
x:Name="txtNextProfile"
Grid.Row="10"
Grid.Row="11"
Grid.Column="1"
Margin="{StaticResource Margin4}"
VerticalAlignment="Center"
@@ -287,7 +310,7 @@
Style="{StaticResource MyOutlinedTextBox}" />
<Button
x:Name="btnSelectNextProfile"
Grid.Row="10"
Grid.Row="11"
Grid.Column="2"
Margin="{StaticResource Margin4}"
VerticalAlignment="Center"
@@ -295,7 +318,7 @@
Style="{StaticResource DefButton}" />
<TextBlock
Grid.Row="11"
Grid.Row="12"
Grid.Column="0"
Margin="{StaticResource Margin4}"
VerticalAlignment="Center"
@@ -303,14 +326,14 @@
Text="{x:Static resx:ResUI.LvCustomCoreType}" />
<ComboBox
x:Name="cmbCustomCoreType"
Grid.Row="11"
Grid.Row="12"
Grid.Column="1"
Margin="{StaticResource Margin4}"
MaxDropDownHeight="1000"
Style="{StaticResource MyOutlinedTextComboBox}" />
<TextBlock
Grid.Row="12"
Grid.Row="13"
Grid.Column="0"
Margin="{StaticResource Margin4}"
VerticalAlignment="Center"
@@ -318,7 +341,7 @@
Text="{x:Static resx:ResUI.TbPreSocksPort4Sub}" />
<TextBox
x:Name="txtPreSocksPort"
Grid.Row="12"
Grid.Row="13"
Grid.Column="1"
Margin="{StaticResource Margin4}"
HorizontalAlignment="Left"
@@ -328,7 +351,7 @@
ToolTip="{x:Static resx:ResUI.TipPreSocksPort}" />
<TextBlock
Grid.Row="13"
Grid.Row="14"
Grid.Column="0"
Margin="{StaticResource Margin4}"
VerticalAlignment="Center"
@@ -336,7 +359,7 @@
Text="{x:Static resx:ResUI.LvMemo}" />
<TextBox
x:Name="txtMemo"
Grid.Row="13"
Grid.Row="14"
Grid.Column="1"
Margin="{StaticResource Margin4}"
VerticalAlignment="Center"

View File

@@ -19,6 +19,7 @@ public partial class SubEditWindow
this.Bind(ViewModel, vm => vm.SelectedSource.Enabled, v => v.togEnable.IsChecked).DisposeWith(disposables);
this.Bind(ViewModel, vm => vm.SelectedSource.AutoUpdateInterval, v => v.txtAutoUpdateInterval.Text).DisposeWith(disposables);
this.Bind(ViewModel, vm => vm.SelectedSource.UserAgent, v => v.txtUserAgent.Text).DisposeWith(disposables);
this.Bind(ViewModel, vm => vm.SelectedSource.RequestHeaders, v => v.txtRequestHeaders.Text).DisposeWith(disposables);
this.Bind(ViewModel, vm => vm.SelectedSource.Sort, v => v.txtSort.Text).DisposeWith(disposables);
this.Bind(ViewModel, vm => vm.SelectedSource.Filter, v => v.txtFilter.Text).DisposeWith(disposables);
this.Bind(ViewModel, vm => vm.SelectedSource.ConvertTarget, v => v.cmbConvertTarget.Text).DisposeWith(disposables);