feat: add subscription decryption support (AES-128-CBC, Subscription-Encryption header)

Co-authored-by: Cursor <cursoragent@cursor.com>
This commit is contained in:
fqfqgo 2026-02-22 20:58:31 +08:00
parent b5800f7dfc
commit 0dac5fde9c
15 changed files with 377 additions and 69 deletions

View file

@ -7,6 +7,7 @@ public static class AppEvents
public static readonly EventChannel<Unit> AddServerViaScanRequested = new();
public static readonly EventChannel<Unit> AddServerViaClipboardRequested = new();
public static readonly EventChannel<bool> SubscriptionsUpdateRequested = new();
public static readonly EventChannel<string> SubscriptionDecryptFailedRequested = new();
public static readonly EventChannel<Unit> ProfilesRefreshRequested = new();
public static readonly EventChannel<Unit> SubscriptionsRefreshRequested = new();

View file

@ -1669,6 +1669,7 @@ public static class ConfigHandler
item.Remarks = subItem.Remarks;
item.Url = subItem.Url;
item.MoreUrl = subItem.MoreUrl;
item.LoginPassword = subItem.LoginPassword;
item.Enabled = subItem.Enabled;
item.AutoUpdateInterval = subItem.AutoUpdateInterval;
item.UserAgent = subItem.UserAgent;

View file

@ -1,8 +1,17 @@
using System.Net.Http.Headers;
using System.Security.Cryptography;
using System.Text;
namespace ServiceLib.Handler;
public static class SubscriptionHandler
{
public static async Task UpdateProcess(Config config, string subId, bool blProxy, Func<bool, string, Task> updateFunc)
public static async Task UpdateProcess(
Config config,
string subId,
bool blProxy,
Func<bool, string, Task> updateFunc,
Func<SubItem, Task>? decryptFailedFunc = null)
{
await updateFunc?.Invoke(false, ResUI.MsgUpdateSubscriptionStart);
var subItem = await AppManager.Instance.SubItems();
@ -35,7 +44,13 @@ public static class SubscriptionHandler
await updateFunc?.Invoke(false, $"{hashCode}{ResUI.MsgStartGettingSubscriptions}");
// Get all subscription content (main subscription + additional subscriptions)
var result = await DownloadAllSubscriptions(config, item, blProxy, downloadHandle);
var (result, decryptFailed) = await DownloadAllSubscriptions(config, item, blProxy, downloadHandle);
if (decryptFailed)
{
await updateFunc?.Invoke(false, $"{hashCode}{ResUI.MsgSubscriptionDecryptFailed}");
await decryptFailedFunc?.Invoke(item);
continue;
}
// Process download result
if (await ProcessDownloadResult(config, item.Id, result, hashCode, updateFunc))
@ -90,34 +105,69 @@ public static class SubscriptionHandler
return downloadHandle;
}
private static async Task<string> DownloadSubscriptionContent(DownloadService downloadHandle, string url, bool blProxy, string userAgent)
private const string SubscriptionEncryptionHeader = "Subscription-Encryption";
private const string AesEncryptionValue = "true";
private static bool IsAesEncrypted(HttpHeaders? headers)
{
var result = await downloadHandle.TryDownloadString(url, blProxy, userAgent);
if (headers == null || !headers.TryGetValues(SubscriptionEncryptionHeader, out var values))
return false;
return values?.Any(v => string.Equals(v.Trim(), AesEncryptionValue, StringComparison.OrdinalIgnoreCase)) == true;
}
private static async Task<(string Content, HttpHeaders? Headers)> DownloadSubscriptionContentWithHeaders(DownloadService downloadHandle, string url, bool blProxy, string userAgent)
{
var (content, headers) = await downloadHandle.TryDownloadStringWithHeaders(url, blProxy, userAgent);
// If download with proxy fails, try direct connection
if (blProxy && result.IsNullOrEmpty())
if (blProxy && content.IsNullOrEmpty())
{
result = await downloadHandle.TryDownloadString(url, false, userAgent);
(content, headers) = await downloadHandle.TryDownloadStringWithHeaders(url, false, userAgent);
}
return result ?? string.Empty;
return (content ?? string.Empty, headers);
}
private static async Task<string> DownloadAllSubscriptions(Config config, SubItem item, bool blProxy, DownloadService downloadHandle)
private static async Task<(string Result, bool DecryptFailed)> DownloadAllSubscriptions(Config config, SubItem item, bool blProxy, DownloadService downloadHandle)
{
// Download main subscription content
var result = await DownloadMainSubscription(config, item, blProxy, downloadHandle);
var decryptFailed = false;
// Download main subscription content (with headers for encryption detection)
var (result, mainHeaders) = await DownloadMainSubscriptionWithHeaders(config, item, blProxy, downloadHandle);
if (IsAesEncrypted(mainHeaders))
{
if (item.LoginPassword.IsNullOrEmpty())
{
return (string.Empty, true);
}
if (!TryDecryptSubscription(item, result, out var decrypted))
{
return (string.Empty, true);
}
result = decrypted;
}
else
{
if (result.IsNotEmpty() && Utils.IsBase64String(result))
{
result = Utils.Base64Decode(result);
}
}
// Process additional subscription links (if any)
if (item.ConvertTarget.IsNullOrEmpty() && item.MoreUrl.TrimEx().IsNotEmpty())
{
result = await DownloadAdditionalSubscriptions(item, result, blProxy, downloadHandle);
var additional = await DownloadAdditionalSubscriptions(item, result, blProxy, downloadHandle);
if (additional.DecryptFailed)
{
return (string.Empty, true);
}
result = additional.Result;
}
return result;
return (result, decryptFailed);
}
private static async Task<string> DownloadMainSubscription(Config config, SubItem item, bool blProxy, DownloadService downloadHandle)
private static async Task<(string Content, HttpHeaders? Headers)> DownloadMainSubscriptionWithHeaders(Config config, SubItem item, bool blProxy, DownloadService downloadHandle)
{
// Prepare subscription URL and download directly
var url = Utils.GetPunycode(item.Url.TrimEx());
@ -142,20 +192,14 @@ public static class SubscriptionHandler
}
}
// Download and return result directly
return await DownloadSubscriptionContent(downloadHandle, url, blProxy, item.UserAgent);
// Download and return result with headers
return await DownloadSubscriptionContentWithHeaders(downloadHandle, url, blProxy, item.UserAgent);
}
private static async Task<string> DownloadAdditionalSubscriptions(SubItem item, string mainResult, bool blProxy, DownloadService downloadHandle)
private static async Task<(string Result, bool DecryptFailed)> 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)
@ -166,23 +210,110 @@ public static class SubscriptionHandler
continue;
}
var additionalResult = await DownloadSubscriptionContent(downloadHandle, url2, blProxy, item.UserAgent);
var (additionalContent, additionalHeaders) = await DownloadSubscriptionContentWithHeaders(downloadHandle, url2, blProxy, item.UserAgent);
if (additionalResult.IsNotEmpty())
if (additionalContent.IsNotEmpty())
{
// Process additional subscription results, add to main result
if (Utils.IsBase64String(additionalResult))
// Check header for encryption; decrypt only when Subscription-Encryption present
if (IsAesEncrypted(additionalHeaders))
{
result += Environment.NewLine + Utils.Base64Decode(additionalResult);
if (item.LoginPassword.IsNullOrEmpty())
{
return (string.Empty, true);
}
if (!TryDecryptSubscription(item, additionalContent, out var decrypted))
{
return (string.Empty, true);
}
result += Environment.NewLine + decrypted;
}
else if (Utils.IsBase64String(additionalContent))
{
result += Environment.NewLine + Utils.Base64Decode(additionalContent);
}
else
{
result += Environment.NewLine + additionalResult;
result += Environment.NewLine + additionalContent;
}
}
}
return result;
return (result, false);
}
private static bool TryDecryptSubscription(SubItem item, string base64Data, out string decrypted)
{
decrypted = string.Empty;
if (base64Data.IsNullOrEmpty())
{
return false;
}
var pass = Utils.GetMd5(item.LoginPassword ?? string.Empty);
if (pass.Length != 32)
{
return false;
}
try
{
var key = Convert.FromHexString(pass);
var raw = TryBase64DecodeBytes(base64Data);
if (raw == null || raw.Length <= 16)
{
return false;
}
var iv = raw[..16];
var cipher = raw[16..];
using var aes = Aes.Create();
aes.Key = key;
aes.IV = iv;
aes.Mode = CipherMode.CBC;
aes.Padding = PaddingMode.PKCS7;
using var decryptor = aes.CreateDecryptor();
var plainBytes = decryptor.TransformFinalBlock(cipher, 0, cipher.Length);
decrypted = Encoding.UTF8.GetString(plainBytes);
return decrypted.IsNotEmpty();
}
catch (Exception ex)
{
Logging.SaveLog("SubscriptionDecryptFailed", ex);
return false;
}
}
private static byte[]? TryBase64DecodeBytes(string plainText)
{
try
{
if (plainText.IsNullOrEmpty())
{
return null;
}
plainText = plainText.Trim()
.Replace(Environment.NewLine, "")
.Replace("\n", "")
.Replace("\r", "")
.Replace('_', '/')
.Replace('-', '+')
.Replace(" ", "");
if (plainText.Length % 4 > 0)
{
plainText = plainText.PadRight(plainText.Length + 4 - (plainText.Length % 4), '=');
}
return Convert.FromBase64String(plainText);
}
catch (Exception ex)
{
Logging.SaveLog("SubscriptionBase64DecodeFailed", ex);
return null;
}
}
private static async Task<bool> ProcessDownloadResult(Config config, string id, string result, string hashCode, Func<bool, string, Task> updateFunc)

View file

@ -12,6 +12,8 @@ public class SubItem
public string MoreUrl { get; set; }
public string? LoginPassword { get; set; }
public bool Enabled { get; set; } = true;
public string UserAgent { get; set; } = string.Empty;

View file

@ -1,4 +1,4 @@
//------------------------------------------------------------------------------
//------------------------------------------------------------------------------
// <auto-generated>
// 此代码由工具生成。
// 运行时版本:4.0.30319.42000
@ -510,6 +510,15 @@ namespace ServiceLib.Resx {
}
}
/// <summary>
/// 查找类似 Login password 的本地化字符串。
/// </summary>
public static string LvLoginPassword {
get {
return ResourceManager.GetString("LvLoginPassword", resourceCulture);
}
}
/// <summary>
/// 查找类似 Remarks Memo 的本地化字符串。
/// </summary>
@ -2049,6 +2058,15 @@ namespace ServiceLib.Resx {
}
}
/// <summary>
/// 查找类似 Subscription decryption failed 的本地化字符串。
/// </summary>
public static string MsgSubscriptionDecryptFailed {
get {
return ResourceManager.GetString("MsgSubscriptionDecryptFailed", resourceCulture);
}
}
/// <summary>
/// 查找类似 Unpacking... 的本地化字符串。
/// </summary>
@ -2430,6 +2448,12 @@ namespace ServiceLib.Resx {
/// <summary>
/// 查找类似 For group please leave blank here 的本地化字符串。
/// </summary>
public static string SubLoginPasswordTips {
get {
return ResourceManager.GetString("SubLoginPasswordTips", resourceCulture);
}
}
public static string SubUrlTips {
get {
return ResourceManager.GetString("SubUrlTips", resourceCulture);

View file

@ -228,6 +228,9 @@
<data name="MsgSubscriptionDecodingFailed" xml:space="preserve">
<value>Invalid subscription content</value>
</data>
<data name="MsgSubscriptionDecryptFailed" xml:space="preserve">
<value>Subscription decryption failed</value>
</data>
<data name="MsgUnpacking" xml:space="preserve">
<value>Unpacking...</value>
</data>
@ -309,6 +312,9 @@
<data name="LvRemarks" xml:space="preserve">
<value>Remarks</value>
</data>
<data name="LvLoginPassword" xml:space="preserve">
<value>Login password</value>
</data>
<data name="LvUrl" xml:space="preserve">
<value>URL (optional)</value>
</data>
@ -867,6 +873,9 @@
<data name="TbDnsObjectDoc" xml:space="preserve">
<value>Supports DNS Object; Click to view documentation</value>
</data>
<data name="SubLoginPasswordTips" xml:space="preserve">
<value>Please enter the website login password</value>
</data>
<data name="SubUrlTips" xml:space="preserve">
<value>For group please leave blank here</value>
</data>

View file

@ -228,6 +228,9 @@
<data name="MsgSubscriptionDecodingFailed" xml:space="preserve">
<value>无效的订阅内容</value>
</data>
<data name="MsgSubscriptionDecryptFailed" xml:space="preserve">
<value>订阅解密失败</value>
</data>
<data name="MsgUnpacking" xml:space="preserve">
<value>正在解压......</value>
</data>
@ -309,6 +312,9 @@
<data name="LvRemarks" xml:space="preserve">
<value>别名</value>
</data>
<data name="LvLoginPassword" xml:space="preserve">
<value>登录密码</value>
</data>
<data name="LvUrl" xml:space="preserve">
<value>可选地址 (Url)</value>
</data>
@ -867,6 +873,9 @@
<data name="TbDnsObjectDoc" xml:space="preserve">
<value>支持填写 DnsObjectJSON 格式,点击查看文档</value>
</data>
<data name="SubLoginPasswordTips" xml:space="preserve">
<value>请输入网站登录密码</value>
</data>
<data name="SubUrlTips" xml:space="preserve">
<value>普通分组此处请留空</value>
</data>

View file

@ -87,13 +87,22 @@ public class DownloadService
}
public async Task<string?> TryDownloadString(string url, bool blProxy, string userAgent)
{
var (content, _) = await TryDownloadStringWithHeaders(url, blProxy, userAgent);
return content;
}
/// <summary>
/// Download string and return response headers (for subscription encryption detection).
/// </summary>
public async Task<(string? Content, HttpHeaders? ResponseHeaders)> TryDownloadStringWithHeaders(string url, bool blProxy, string userAgent)
{
try
{
var result1 = await DownloadStringAsync(url, blProxy, userAgent, 15);
if (result1.IsNotEmpty())
var (content, headers) = await DownloadStringWithHeadersAsync(url, blProxy, userAgent, 15);
if (content.IsNotEmpty())
{
return result1;
return (content, headers);
}
}
catch (Exception ex)
@ -111,7 +120,7 @@ public class DownloadService
var result2 = await DownloadStringViaDownloader(url, blProxy, userAgent, 15);
if (result2.IsNotEmpty())
{
return result2;
return (result2, null);
}
}
catch (Exception ex)
@ -124,7 +133,7 @@ public class DownloadService
}
}
return null;
return (null, null);
}
/// <summary>
@ -132,6 +141,12 @@ public class DownloadService
/// </summary>
/// <param name="url"></param>
private async Task<string?> DownloadStringAsync(string url, bool blProxy, string userAgent, int timeout)
{
var (content, _) = await DownloadStringWithHeadersAsync(url, blProxy, userAgent, timeout);
return content;
}
private async Task<(string? Content, HttpHeaders? ResponseHeaders)> DownloadStringWithHeadersAsync(string url, bool blProxy, string userAgent, int timeout)
{
try
{
@ -156,8 +171,10 @@ public class DownloadService
}
using var cts = new CancellationTokenSource();
var result = await client.GetStringAsync(url, cts.Token).WaitAsync(TimeSpan.FromSeconds(timeout), cts.Token);
return result;
using var response = await client.GetAsync(url, cts.Token).WaitAsync(TimeSpan.FromSeconds(timeout), cts.Token);
response.EnsureSuccessStatusCode();
var content = await response.Content.ReadAsStringAsync(cts.Token);
return (content, response.Headers);
}
catch (Exception ex)
{
@ -168,7 +185,7 @@ public class DownloadService
Error?.Invoke(this, new ErrorEventArgs(ex.InnerException));
}
}
return null;
return (null, null);
}
/// <summary>

View file

@ -450,7 +450,22 @@ public class MainWindowViewModel : MyReactiveObject
public async Task UpdateSubscriptionProcess(string subId, bool blProxy)
{
await Task.Run(async () => await SubscriptionHandler.UpdateProcess(_config, subId, blProxy, UpdateTaskHandler));
var decryptPromptShown = false;
await Task.Run(async () => await SubscriptionHandler.UpdateProcess(
_config,
subId,
blProxy,
UpdateTaskHandler,
async subItem =>
{
if (decryptPromptShown || subItem?.Id.IsNullOrEmpty() != false)
{
return;
}
decryptPromptShown = true;
AppEvents.SubscriptionDecryptFailedRequested.Publish(subItem.Id);
await Task.CompletedTask;
}));
}
#endregion Subscription

View file

@ -149,6 +149,12 @@ public partial class MainWindow : WindowBase<MainWindowViewModel>
.ObserveOn(RxApp.MainThreadScheduler)
.Subscribe(blShow => ShowHideWindow(blShow))
.DisposeWith(disposables);
AppEvents.SubscriptionDecryptFailedRequested
.AsObservable()
.ObserveOn(RxApp.MainThreadScheduler)
.Subscribe(async subId => await OpenSubEditForDecryptFailed(subId))
.DisposeWith(disposables);
});
if (Utils.IsWindows())
@ -187,6 +193,21 @@ public partial class MainWindow : WindowBase<MainWindowViewModel>
await Task.CompletedTask;
}
private async Task OpenSubEditForDecryptFailed(string subId)
{
if (subId.IsNullOrEmpty())
{
return;
}
var lst = await AppManager.Instance.SubItems();
var item = lst?.FirstOrDefault(x => x.Id == subId);
if (item == null)
{
return;
}
await new SubEditWindow(item, focusLoginPassword: true).ShowDialog<bool>(this);
}
private async Task<bool> UpdateViewHandler(EViewAction action, object? obj)
{
switch (action)

View file

@ -69,6 +69,21 @@
VerticalAlignment="Center"
TextWrapping="Wrap"
Watermark="{x:Static resx:ResUI.SubUrlTips}" />
<TextBlock
Grid.Row="3"
Grid.Column="0"
Margin="{StaticResource Margin4}"
VerticalAlignment="Center"
Text="{x:Static resx:ResUI.LvLoginPassword}" />
<TextBox
x:Name="txtLoginPassword"
Grid.Row="3"
Grid.Column="1"
Margin="{StaticResource Margin4}"
VerticalAlignment="Center"
PasswordChar="*"
Watermark="{x:Static resx:ResUI.SubLoginPasswordTips}" />
<Button
Grid.Row="2"
Grid.Column="2"
@ -106,14 +121,14 @@
</Button>
<TextBlock
Grid.Row="3"
Grid.Row="4"
Grid.Column="0"
Margin="{StaticResource Margin4}"
VerticalAlignment="Center"
Text="{x:Static resx:ResUI.LvEnabled}" />
<DockPanel
Grid.Row="3"
Grid.Row="4"
Grid.Column="1"
Margin="{StaticResource Margin4}">
<ToggleSwitch

View file

@ -4,14 +4,17 @@ namespace v2rayN.Desktop.Views;
public partial class SubEditWindow : WindowBase<SubEditViewModel>
{
private readonly bool _focusLoginPassword;
public SubEditWindow()
{
InitializeComponent();
}
public SubEditWindow(SubItem subItem)
public SubEditWindow(SubItem subItem, bool focusLoginPassword = false)
{
InitializeComponent();
_focusLoginPassword = focusLoginPassword;
Loaded += Window_Loaded;
btnCancel.Click += (s, e) => Close();
@ -24,6 +27,7 @@ public partial class SubEditWindow : WindowBase<SubEditViewModel>
{
this.Bind(ViewModel, vm => vm.SelectedSource.Remarks, v => v.txtRemarks.Text).DisposeWith(disposables);
this.Bind(ViewModel, vm => vm.SelectedSource.Url, v => v.txtUrl.Text).DisposeWith(disposables);
this.Bind(ViewModel, vm => vm.SelectedSource.LoginPassword, v => v.txtLoginPassword.Text).DisposeWith(disposables);
this.Bind(ViewModel, vm => vm.SelectedSource.MoreUrl, v => v.txtMoreUrl.Text).DisposeWith(disposables);
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);
@ -52,9 +56,17 @@ public partial class SubEditWindow : WindowBase<SubEditViewModel>
}
private void Window_Loaded(object? sender, RoutedEventArgs e)
{
if (_focusLoginPassword)
{
txtLoginPassword.Focus();
txtLoginPassword.SelectAll();
}
else
{
txtRemarks.Focus();
}
}
private async void BtnSelectPrevProfile_Click(object? sender, RoutedEventArgs e)
{

View file

@ -148,6 +148,12 @@ public partial class MainWindow
.ObserveOn(RxApp.MainThreadScheduler)
.Subscribe(blShow => ShowHideWindow(blShow))
.DisposeWith(disposables);
AppEvents.SubscriptionDecryptFailedRequested
.AsObservable()
.ObserveOn(RxApp.MainThreadScheduler)
.Subscribe(async subId => await OpenSubEditForDecryptFailed(subId))
.DisposeWith(disposables);
});
Title = $"{Utils.GetVersion()} - {(Utils.IsAdministrator() ? ResUI.RunAsAdmin : ResUI.NotRunAsAdmin)}";
@ -181,6 +187,22 @@ public partial class MainWindow
await Task.CompletedTask;
}
private async Task OpenSubEditForDecryptFailed(string subId)
{
if (subId.IsNullOrEmpty())
{
return;
}
var lst = await AppManager.Instance.SubItems();
var item = lst?.FirstOrDefault(x => x.Id == subId);
if (item == null)
{
return;
}
new SubEditWindow(item, focusLoginPassword: true).ShowDialog();
await Task.CompletedTask;
}
private async Task<bool> UpdateViewHandler(EViewAction action, object? obj)
{
switch (action)

View file

@ -64,6 +64,7 @@
<RowDefinition Height="Auto" />
<RowDefinition Height="Auto" />
<RowDefinition Height="Auto" />
<RowDefinition Height="Auto" />
</Grid.RowDefinitions>
<Grid.ColumnDefinitions>
<ColumnDefinition Width="Auto" />
@ -145,10 +146,26 @@
Margin="{StaticResource Margin4}"
VerticalAlignment="Center"
Style="{StaticResource ToolbarTextBlock}"
Text="{x:Static resx:ResUI.LvLoginPassword}" />
<TextBox
x:Name="txtLoginPassword"
Grid.Row="3"
Grid.Column="1"
Margin="{StaticResource Margin4}"
VerticalAlignment="Center"
materialDesign:HintAssist.Hint="{x:Static resx:ResUI.SubLoginPasswordTips}"
Style="{StaticResource MyOutlinedTextBox}" />
<TextBlock
Grid.Row="4"
Grid.Column="0"
Margin="{StaticResource Margin4}"
VerticalAlignment="Center"
Style="{StaticResource ToolbarTextBlock}"
Text="{x:Static resx:ResUI.LvEnabled}" />
<DockPanel
Grid.Row="3"
Grid.Row="4"
Grid.Column="1"
Margin="{StaticResource Margin4}">
<ToggleButton
@ -176,7 +193,7 @@
</DockPanel>
<TextBlock
Grid.Row="5"
Grid.Row="6"
Grid.Column="0"
Margin="{StaticResource Margin4}"
VerticalAlignment="Center"
@ -184,7 +201,7 @@
Text="{x:Static resx:ResUI.LvFilter}" />
<TextBox
x:Name="txtFilter"
Grid.Row="5"
Grid.Row="6"
Grid.Column="1"
Margin="{StaticResource Margin4}"
VerticalAlignment="Center"
@ -193,7 +210,7 @@
Style="{StaticResource MyOutlinedTextBox}" />
<TextBlock
Grid.Row="6"
Grid.Row="7"
Grid.Column="0"
Margin="{StaticResource Margin4}"
VerticalAlignment="Center"
@ -201,7 +218,7 @@
Text="{x:Static resx:ResUI.LvConvertTarget}" />
<ComboBox
x:Name="cmbConvertTarget"
Grid.Row="6"
Grid.Row="7"
Grid.Column="1"
Margin="{StaticResource Margin4}"
materialDesign:HintAssist.Hint="{x:Static resx:ResUI.LvConvertTargetTip}"
@ -209,14 +226,14 @@
Style="{StaticResource MyOutlinedTextComboBox}" />
<TextBlock
Grid.Row="7"
Grid.Row="8"
Grid.Column="0"
Margin="{StaticResource Margin4}"
VerticalAlignment="Center"
Text="{x:Static resx:ResUI.LvUserAgent}" />
<TextBox
x:Name="txtUserAgent"
Grid.Row="7"
Grid.Row="8"
Grid.Column="1"
Margin="{StaticResource Margin4}"
VerticalAlignment="Center"
@ -226,7 +243,7 @@
TextWrapping="Wrap" />
<TextBlock
Grid.Row="8"
Grid.Row="9"
Grid.Column="0"
Margin="{StaticResource Margin4}"
VerticalAlignment="Center"
@ -234,7 +251,7 @@
Text="{x:Static resx:ResUI.LvSort}" />
<TextBox
x:Name="txtSort"
Grid.Row="8"
Grid.Row="9"
Grid.Column="1"
Width="100"
Margin="{StaticResource Margin4}"
@ -244,7 +261,7 @@
Style="{StaticResource MyOutlinedTextBox}" />
<TextBlock
Grid.Row="9"
Grid.Row="10"
Grid.Column="0"
Margin="{StaticResource Margin4}"
VerticalAlignment="Center"
@ -252,7 +269,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"
@ -260,7 +277,7 @@
AcceptsReturn="True"
Style="{StaticResource MyOutlinedTextBox}" />
<Button
Grid.Row="9"
Grid.Row="10"
Grid.Column="2"
Margin="{StaticResource Margin4}"
VerticalAlignment="Center"
@ -269,7 +286,7 @@
Style="{StaticResource DefButton}" />
<TextBlock
Grid.Row="10"
Grid.Row="11"
Grid.Column="0"
Margin="{StaticResource Margin4}"
VerticalAlignment="Center"
@ -277,7 +294,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"
@ -285,7 +302,7 @@
AcceptsReturn="True"
Style="{StaticResource MyOutlinedTextBox}" />
<Button
Grid.Row="10"
Grid.Row="11"
Grid.Column="2"
Margin="{StaticResource Margin4}"
VerticalAlignment="Center"
@ -294,7 +311,7 @@
Style="{StaticResource DefButton}" />
<TextBlock
Grid.Row="11"
Grid.Row="12"
Grid.Column="0"
Margin="{StaticResource Margin4}"
VerticalAlignment="Center"
@ -302,7 +319,7 @@
Text="{x:Static resx:ResUI.TbPreSocksPort4Sub}" />
<TextBox
x:Name="txtPreSocksPort"
Grid.Row="11"
Grid.Row="12"
Grid.Column="1"
Margin="{StaticResource Margin4}"
HorizontalAlignment="Left"
@ -312,7 +329,7 @@
ToolTip="{x:Static resx:ResUI.TipPreSocksPort}" />
<TextBlock
Grid.Row="12"
Grid.Row="13"
Grid.Column="0"
Margin="{StaticResource Margin4}"
VerticalAlignment="Center"
@ -320,7 +337,7 @@
Text="{x:Static resx:ResUI.LvMemo}" />
<TextBox
x:Name="txtMemo"
Grid.Row="12"
Grid.Row="13"
Grid.Column="1"
Margin="{StaticResource Margin4}"
VerticalAlignment="Center"

View file

@ -2,9 +2,12 @@ namespace v2rayN.Views;
public partial class SubEditWindow
{
public SubEditWindow(SubItem subItem)
private readonly bool _focusLoginPassword;
public SubEditWindow(SubItem subItem, bool focusLoginPassword = false)
{
InitializeComponent();
_focusLoginPassword = focusLoginPassword;
Owner = Application.Current.MainWindow;
Loaded += Window_Loaded;
@ -17,6 +20,7 @@ public partial class SubEditWindow
{
this.Bind(ViewModel, vm => vm.SelectedSource.Remarks, v => v.txtRemarks.Text).DisposeWith(disposables);
this.Bind(ViewModel, vm => vm.SelectedSource.Url, v => v.txtUrl.Text).DisposeWith(disposables);
this.Bind(ViewModel, vm => vm.SelectedSource.LoginPassword, v => v.txtLoginPassword.Text).DisposeWith(disposables);
this.Bind(ViewModel, vm => vm.SelectedSource.MoreUrl, v => v.txtMoreUrl.Text).DisposeWith(disposables);
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);
@ -46,9 +50,17 @@ public partial class SubEditWindow
}
private void Window_Loaded(object sender, RoutedEventArgs e)
{
if (_focusLoginPassword)
{
txtLoginPassword.Focus();
txtLoginPassword.SelectAll();
}
else
{
txtRemarks.Focus();
}
}
private async void BtnSelectPrevProfile_Click(object sender, RoutedEventArgs e)
{