mirror of
https://github.com/2dust/v2rayN.git
synced 2026-02-28 05:03:02 +00:00
feat: add subscription decryption support (AES-128-CBC, Subscription-Encryption header)
Co-authored-by: Cursor <cursoragent@cursor.com>
This commit is contained in:
parent
b5800f7dfc
commit
0dac5fde9c
15 changed files with 377 additions and 69 deletions
|
|
@ -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();
|
||||
|
|
|
|||
|
|
@ -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;
|
||||
|
|
|
|||
|
|
@ -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)
|
||||
|
|
|
|||
|
|
@ -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;
|
||||
|
|
|
|||
26
v2rayN/ServiceLib/Resx/ResUI.Designer.cs
generated
26
v2rayN/ServiceLib/Resx/ResUI.Designer.cs
generated
|
|
@ -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);
|
||||
|
|
|
|||
|
|
@ -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>
|
||||
|
|
|
|||
|
|
@ -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>支持填写 DnsObject,JSON 格式,点击查看文档</value>
|
||||
</data>
|
||||
<data name="SubLoginPasswordTips" xml:space="preserve">
|
||||
<value>请输入网站登录密码</value>
|
||||
</data>
|
||||
<data name="SubUrlTips" xml:space="preserve">
|
||||
<value>普通分组此处请留空</value>
|
||||
</data>
|
||||
|
|
|
|||
|
|
@ -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>
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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)
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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)
|
||||
{
|
||||
|
|
|
|||
|
|
@ -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)
|
||||
|
|
|
|||
|
|
@ -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"
|
||||
|
|
|
|||
|
|
@ -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)
|
||||
{
|
||||
|
|
|
|||
Loading…
Reference in a new issue