Add custom outbound support (#9817)

* Add custom outbound support

* Add test

* Add inner fmt support

* Full config to outbounds

* Rename to `Outbound`

* AI optimized

* Fix

* Add bind interface placeholder

---------

Co-authored-by: 2dust <31833384+2dust@users.noreply.github.com>
This commit is contained in:
DHR60
2026-08-05 06:25:16 +00:00
committed by GitHub
parent ccf18ba0fb
commit eff584597f
43 changed files with 1364 additions and 367 deletions

View File

@@ -150,6 +150,23 @@ internal static class CoreConfigTestFactory
};
}
public static ProfileItem CreateCustomOutboundNode(ECoreType coreType, string indexId = "node-custom-1",
string remarks = "demo-custom-outbound", string address = "custom_outbound.json")
{
return new ProfileItem
{
IndexId = indexId,
ConfigType = EConfigType.Outbound,
CoreType = coreType,
Remarks = remarks,
Address = address,
Port = 0,
Network = nameof(ETransport.raw),
StreamSecurity = string.Empty,
Subid = string.Empty,
};
}
public static ProfileItem CreatePolicyGroupNode(ECoreType coreType, string indexId, string remarks,
IEnumerable<string> childIndexIds)
{

View File

@@ -626,71 +626,40 @@ public class CoreConfigSingboxServiceTests
}
}
[Fact]
public void GenerateClientConfigContent_TunEnabled_ShouldKeepEmbeddedTunRules()
{
// The embedded tun rules reject local-network noise (NetBIOS/mDNS, multicast).
// They are deserialized into List<Rule4Sbox>, so a schema mismatch in the
// embedded template makes JsonUtils.Deserialize return null and silently
// drops every one of them.
var config = CoreConfigTestFactory.CreateConfig(ECoreType.sing_box);
config.TunModeItem.EnableTun = true;
CoreConfigTestFactory.BindAppManagerConfig(config);
var node = CoreConfigTestFactory.CreateVmessNode(ECoreType.sing_box);
var context = CoreConfigTestFactory.CreateContext(config, node, ECoreType.sing_box) with
{
IsTunEnabled = true,
};
var result = new CoreConfigSingboxService(context).GenerateClientConfigContent();
result.Success.Should().BeTrue($"ret msg: {result.Msg}");
var cfg = JsonUtils.Deserialize<SingboxConfig>(result.Data!.ToString())!;
cfg.route.rules.Should().Contain(
r => r.action == "reject"
&& r.network != null && r.network.Contains("udp")
&& r.port != null && r.port.Contains(5353),
"the embedded tun rules must reject mDNS/NetBIOS noise");
cfg.route.rules.Should().Contain(
r => r.action == "reject"
&& r.ip_cidr != null && r.ip_cidr.Contains("224.0.0.0/3"),
"the embedded tun rules must reject multicast traffic");
}
[Fact]
public void GenerateClientConfigContent_TunEnabled_ShouldRejectTrafficToTunOwnAddresses()
public void GenerateClientConfigContent_CustomOutbound_ShouldReplaceWithUserCustomOutboundJson()
{
// Regression test: traffic addressed to the TUN interface's own addresses must
// never reach an outbound. auto_route hijacks the default route, so `direct`
// writes such a packet straight back into the TUN, which routes it to the
// outbound again - an infinite loop that pins a CPU core. Observed in the wild
// with WebRTC ICE connectivity checks against the TUN's own fc00::/7 ULA
// address, sustaining ~8k packets/s out of the TUN interface.
var config = CoreConfigTestFactory.CreateConfig(ECoreType.sing_box);
config.TunModeItem.EnableTun = true;
config.TunModeItem.EnableIPv6Address = true;
CoreConfigTestFactory.BindAppManagerConfig(config);
var node = CoreConfigTestFactory.CreateVmessNode(ECoreType.sing_box);
var context = CoreConfigTestFactory.CreateContext(config, node, ECoreType.sing_box) with
var customNode = CoreConfigTestFactory.CreateCustomOutboundNode(ECoreType.sing_box, "n-custom", "custom-singbox");
var customJsonContent = """
{
IsTunEnabled = true,
};
var result = new CoreConfigSingboxService(context).GenerateClientConfigContent();
result.Success.Should().BeTrue($"ret msg: {result.Msg}");
var cfg = JsonUtils.Deserialize<SingboxConfig>(result.Data!.ToString())!;
var tun = cfg.inbounds.First(i => i.type == "tun");
tun.address.Should().NotBeNullOrEmpty();
foreach (var address in tun.address!)
{
cfg.route.rules.Should().Contain(
r => r.action == "reject" && r.ip_cidr != null && r.ip_cidr.Contains(address),
$"traffic to the TUN's own address '{address}' must be rejected, not routed");
"type": "shadowsocks",
"server": "1.2.3.4",
"server_port": 8388,
"method": "aes-128-gcm",
"password": "custom_password"
}
""";
var context = CoreConfigTestFactory.CreateContext(config, customNode, ECoreType.sing_box);
context.CustomOutboundContent[customNode.IndexId] = customJsonContent;
var result = new CoreConfigSingboxService(context).GenerateClientConfigContent();
result.Success.Should().BeTrue($"ret msg: {result.Msg}");
result.Data.Should().NotBeNull();
var cfg = JsonUtils.Deserialize<SingboxConfig>(result.Data!.ToString());
cfg.Should().NotBeNull();
var proxyOutbound = cfg!.outbounds.FirstOrDefault(o => o.tag == Global.ProxyTag);
proxyOutbound.Should().NotBeNull();
proxyOutbound!.type.Should().Be("shadowsocks");
proxyOutbound.server.Should().Be("1.2.3.4");
proxyOutbound.server_port.Should().Be(8388);
proxyOutbound.method.Should().Be("aes-128-gcm");
proxyOutbound.password.Should().Be("custom_password");
}
}

View File

@@ -592,4 +592,43 @@ public class CoreConfigV2rayServiceTests
tunInbound!.settings.autoSystemRoutingTable.Should().Contain("10.0.0.0/32");
tunInbound!.settings.autoSystemRoutingTable.Should().Contain("10.0.0.2/31");
}
[Fact]
public void GenerateClientConfigContent_CustomOutbound_ShouldReplaceWithUserCustomOutboundJson()
{
var config = CoreConfigTestFactory.CreateConfig(ECoreType.Xray);
CoreConfigTestFactory.BindAppManagerConfig(config);
var customNode = CoreConfigTestFactory.CreateCustomOutboundNode(ECoreType.Xray, "n-custom", "custom-xray");
var customJsonContent = """
{
"protocol": "shadowsocks",
"settings": {
"servers": [
{
"address": "1.2.3.4",
"port": 8388,
"method": "aes-128-gcm",
"password": "custom_password"
}
]
}
}
""";
var context = CoreConfigTestFactory.CreateContext(config, customNode, ECoreType.Xray);
context.CustomOutboundContent[customNode.IndexId] = customJsonContent;
var result = new CoreConfigV2rayService(context).GenerateClientConfigContent();
result.Success.Should().BeTrue($"ret msg: {result.Msg}");
result.Data.Should().NotBeNull();
var cfg = JsonUtils.Deserialize<V2rayConfig>(result.Data!.ToString());
cfg.Should().NotBeNull();
var proxyOutbound = cfg!.outbounds.FirstOrDefault(o => o.tag == Global.ProxyTag);
proxyOutbound.Should().NotBeNull();
proxyOutbound!.protocol.Should().Be("shadowsocks");
proxyOutbound.settings.servers.Should().NotBeNull();
}
}

View File

@@ -92,7 +92,7 @@ public static class Extension
public static bool IsComplexType(this EConfigType configType)
{
return configType is EConfigType.Custom or EConfigType.PolicyGroup or EConfigType.ProxyChain;
return configType is EConfigType.Custom or EConfigType.Outbound or EConfigType.PolicyGroup or EConfigType.ProxyChain;
}
/// <summary>

View File

@@ -14,6 +14,7 @@ public enum EConfigType
HTTP = 10,
Anytls = 11,
Naive = 12,
Outbound = 13,
PolicyGroup = 101,
ProxyChain = 102,
}

View File

@@ -1,3 +1,5 @@
using System.ComponentModel.DataAnnotations;
namespace ServiceLib.Handler.Builder;
public record CoreConfigContextBuilderResult(CoreConfigContext Context, NodeValidatorResult ValidatorResult)
@@ -95,6 +97,7 @@ public class CoreConfigContextBuilder
context.AllProxiesMap[$"remark:{ruleItem.OutboundTag}"] = actRuleNode;
}
}
if (context.IsTunEnabled && context.AppConfig.TunModeItem.RouteExcludeAddress is { Count: > 0 })
{
var appConfig = JsonUtils.DeepCopy(config);
@@ -322,14 +325,14 @@ public class CoreConfigContextBuilder
{
return await RegisterGroupNodeAsync(context, node);
}
return RegisterSingleNodeAsync(context, node);
return await RegisterSingleNodeAsync(context, node);
}
/// <summary>
/// Validates a single (non-group) node and, on success, adds it to the proxy map
/// and records any domain addresses that should bypass the proxy.
/// </summary>
private static NodeValidatorResult RegisterSingleNodeAsync(CoreConfigContext context, ProfileItem node)
private static async Task<NodeValidatorResult> RegisterSingleNodeAsync(CoreConfigContext context, ProfileItem node)
{
if (node.ConfigType.IsGroupType())
{
@@ -337,6 +340,29 @@ public class CoreConfigContextBuilder
}
var nodeValidatorResult = NodeValidator.Validate(node, context.RunCoreType);
if (node.ConfigType == EConfigType.Outbound)
{
var addressFileName = node.Address;
if (!File.Exists(addressFileName))
{
addressFileName = Utils.GetConfigPath(addressFileName);
}
if (!File.Exists(addressFileName))
{
nodeValidatorResult.Errors.Add(string.Format(ResUI.MsgCustomOutboundFileNotFound, node.Remarks, addressFileName));
}
try
{
var fileContent = await File.ReadAllTextAsync(addressFileName);
context.CustomOutboundContent[node.IndexId] = fileContent;
}
catch
{
nodeValidatorResult.Errors.Add(string.Format(ResUI.MsgCustomOutboundFileNotFound, node.Remarks, addressFileName));
}
}
var msgs = new List<string>([.. nodeValidatorResult.Errors, .. nodeValidatorResult.Warnings]);
if (msgs.Count > 0)
{
@@ -438,7 +464,7 @@ public class CoreConfigContextBuilder
if (!childNode.ConfigType.IsGroupType())
{
var childNodeResult = RegisterSingleNodeAsync(context, childNode);
var childNodeResult = await RegisterSingleNodeAsync(context, childNode);
childNodeValidatorResult.Warnings.AddRange(childNodeResult.Warnings.Select(w =>
string.Format(ResUI.MsgGroupChildNodeWarning, node.Remarks, childNode.Remarks, w)));
childNodeValidatorResult.Errors.AddRange(childNodeResult.Errors.Select(e =>

View File

@@ -36,6 +36,15 @@ public class NodeValidator
return;
}
if (item.ConfigType is EConfigType.Outbound)
{
if (item.CoreType != coreType)
{
v.Error(string.Format(ResUI.MsgCoreNotSupportProtocol, coreType.ToString(), item.ConfigType));
}
return;
}
if (item.ConfigType.IsGroupType())
{
// Group logic is handled in ValidateGroupNode

View File

@@ -382,6 +382,13 @@ public static class ConfigHandler
{
}
}
else if (profileItem.ConfigType == EConfigType.Outbound)
{
profileItem.Address = Utils.GetConfigPath(profileItem.Address);
if (await AddCustomOutboundServer(config, profileItem, false) == 0)
{
}
}
else
{
await AddServerCommon(config, profileItem, true);
@@ -579,6 +586,44 @@ public static class ConfigHandler
return 0;
}
public static async Task<int> AddCustomOutboundServer(Config config, ProfileItem profileItem, bool blDelete, bool toFile = true)
{
var fileName = profileItem.Address;
if (!File.Exists(fileName))
{
return -1;
}
var ext = Path.GetExtension(fileName);
var newFileName = $"{Utils.GetGuid()}{ext}";
//newFileName = Path.Combine(Utile.GetTempPath(), newFileName);
try
{
File.Copy(fileName, Utils.GetConfigPath(newFileName));
if (blDelete)
{
File.Delete(fileName);
}
}
catch (Exception ex)
{
Logging.SaveLog(_tag, ex);
return -1;
}
profileItem.Address = newFileName;
profileItem.ConfigType = EConfigType.Outbound;
if (profileItem.Remarks.IsNullOrEmpty())
{
profileItem.Remarks = $"import custom outbound@{DateTime.Now.ToString("yyyy/MM/dd HH:mm:ss")}";
}
await AddServerCommon(config, profileItem, toFile);
return 0;
}
/// <summary>
/// Edit an existing custom server configuration
/// Updates the server's properties without changing the file
@@ -600,6 +645,8 @@ public static class ConfigHandler
item.CoreType = profileItem.CoreType;
item.DisplayLog = profileItem.DisplayLog;
item.PreSocksPort = profileItem.PreSocksPort;
item.ProtoExtra = profileItem.ProtoExtra;
}
if (await SQLiteHelper.Instance.UpdateAsync(item) > 0)
@@ -1479,7 +1526,7 @@ public static class ConfigHandler
var matchedChildProfiles = childProfiles?.Where(p =>
p != null &&
p.IsValid() &&
!p.ConfigType.IsComplexType() &&
(!p.ConfigType.IsComplexType() || p.ConfigType == EConfigType.Outbound) &&
(extraItem.Filter.IsNullOrEmpty() || Regex.IsMatch(p.Remarks, extraItem.Filter))
)
.ToList() ?? [];
@@ -1675,68 +1722,179 @@ public static class ConfigHandler
}
var subItem = await AppManager.Instance.GetSubItem(subid);
if (subItem?.CustomCoreType is null)
{
return await AddBatchServersDefaultCustom(config, strData, subid, isSub, subItem);
}
return await AddBatchServersSpecificCustom(config, strData, subid, isSub, subItem);
}
private static async Task<int> AddBatchServersDefaultCustom(
Config config,
string strData,
string subid,
bool isSub,
SubItem? subItem)
{
var subRemarks = subItem?.Remarks;
var preSocksPort = subItem?.PreSocksPort;
List<ProfileItem>? lstProfiles = null;
//Is sing-box array configuration
if (lstProfiles is null || lstProfiles.Count <= 0)
// Safe Mode: Only allow full configuration if it's not from a subscription
var lstProfiles = V2rayFmt.ResolveToCustomOutbound(strData, subRemarks);
if (lstProfiles.Count == 0)
{
lstProfiles = SingboxFmt.ResolveFullArray(strData, subRemarks);
lstProfiles = SingboxFmt.ResolveToCustomOutbound(strData, subRemarks);
}
//Is v2ray array configuration
if (lstProfiles is null || lstProfiles.Count <= 0)
{
lstProfiles = V2rayFmt.ResolveFullArray(strData, subRemarks);
}
if (lstProfiles is { Count: > 0 })
{
var count = 0;
foreach (var it in lstProfiles)
{
it.Subid = subid;
it.IsSub = isSub;
it.PreSocksPort = preSocksPort;
if (await AddCustomServer(config, it, true) == 0)
{
count++;
}
}
if (count > 0)
{
return count;
}
}
ProfileItem? profileItem = null;
//Is sing-box configuration
profileItem ??= SingboxFmt.ResolveFull(strData, subRemarks);
//Is v2ray configuration
profileItem ??= V2rayFmt.ResolveFull(strData, subRemarks);
//Is Html Page
if (profileItem is null && HtmlPageFmt.IsHtmlPage(strData))
if (lstProfiles.Count == 0)
{
return -1;
}
//Is Clash configuration
profileItem ??= ClashFmt.ResolveFull(strData, subRemarks);
//Is hysteria configuration
profileItem ??= Hysteria2Fmt.ResolveFull2(strData, subRemarks);
if (profileItem is null || profileItem.Address.IsNullOrEmpty())
var count = await AddCustomOutboundServers(config, lstProfiles, subid, isSub);
if (count > 0)
{
return count;
}
if (HtmlPageFmt.IsHtmlPage(strData))
{
return -1;
}
var profileItem = ClashFmt.ResolveFull(strData, subRemarks)
?? Hysteria2Fmt.ResolveFull2(strData, subRemarks);
if (profileItem == null)
{
return -1;
}
profileItem.Subid = subid;
profileItem.IsSub = isSub;
profileItem.PreSocksPort = preSocksPort;
if (await AddCustomServer(config, profileItem, true) == 0)
profileItem.PreSocksPort = subItem?.PreSocksPort;
return await AddCustomServer(config, profileItem, true) == 0 ? 1 : -1;
}
private static async Task<int> AddBatchServersSpecificCustom(
Config config,
string strData,
string subid,
bool isSub,
SubItem subItem)
{
var subRemarks = subItem.Remarks;
var customCoreType = subItem.CustomCoreType!.Value;
List<ProfileItem>? lstProfiles = customCoreType switch
{
return 1;
ECoreType.Xray => V2rayFmt.ResolveToCustom(strData, subRemarks),
ECoreType.sing_box => SingboxFmt.ResolveToCustom(strData, subRemarks),
_ => null
};
if (lstProfiles is not null)
{
if (lstProfiles.Count == 0)
{
return -1;
}
var count = await AddCustomOutboundServers(config, lstProfiles, subid, isSub);
if (count > 0)
{
return count;
}
}
else
return await SaveCustomRawFileServer(config, strData, subid, isSub, subItem, customCoreType);
}
private static async Task<int> AddCustomOutboundServers(
Config config,
List<ProfileItem> lstProfiles,
string subid,
bool isSub)
{
var count = 0;
foreach (var it in lstProfiles)
{
return -1;
it.Subid = subid;
it.IsSub = isSub;
if (await AddCustomOutboundServer(config, it, true) == 0)
{
count++;
}
}
return count;
}
private static async Task<int> SaveCustomRawFileServer(
Config config,
string strData,
string subid,
bool isSub,
SubItem subItem,
ECoreType customCoreType)
{
var ext = DetectFileExtension(strData);
var fileName = Utils.GetTempPath($"{Utils.GetGuid(false)}{ext}");
await File.WriteAllTextAsync(fileName, strData);
var profileItem = new ProfileItem
{
CoreType = customCoreType,
ConfigType = EConfigType.Custom,
Address = fileName,
Remarks = subItem.Remarks ?? customCoreType.ToString(),
Subid = subid,
IsSub = isSub,
PreSocksPort = subItem.PreSocksPort,
};
return await AddCustomServer(config, profileItem, true) == 0 ? 1 : -1;
static string DetectFileExtension(string data)
{
var trimmed = data.AsSpan().TrimStart();
if (trimmed.IsEmpty)
{
return string.Empty;
}
if (trimmed[0] is '{' or '[')
{
return ".json";
}
if (trimmed.StartsWith("---"))
{
return ".yaml";
}
foreach (var line in trimmed.EnumerateLines())
{
var lineTrimmed = line.TrimStart();
if (lineTrimmed.IsEmpty || lineTrimmed.StartsWith("#"))
{
continue;
}
var colonIndex = lineTrimmed.IndexOf(':');
if (colonIndex > 0)
{
var keySpan = lineTrimmed[..colonIndex];
if (!keySpan.Contains(' ') && !keySpan.Contains('\t'))
{
if (colonIndex == lineTrimmed.Length - 1 || lineTrimmed[colonIndex + 1] is ' ' or '\t' or '\r' or '\n')
{
return ".yaml";
}
}
}
}
return string.Empty;
}
}
@@ -1837,6 +1995,7 @@ public static class ConfigHandler
EConfigType.Anytls => await AddAnytlsServer(config, profileItem, false),
EConfigType.Naive => await AddNaiveServer(config, profileItem, false),
EConfigType.PolicyGroup or EConfigType.ProxyChain => await AddServerCommon(config, profileItem, false),
EConfigType.Outbound => await AddCustomOutboundServer(config, profileItem, true, false),
_ => -1,
};
if (addStatus == 0)
@@ -2038,6 +2197,7 @@ public static class ConfigHandler
item.NextProfile = subItem.NextProfile;
item.PreSocksPort = subItem.PreSocksPort;
item.Memo = subItem.Memo;
item.CustomCoreType = subItem.CustomCoreType;
}
if (item.Id.IsNullOrEmpty())
@@ -2078,7 +2238,7 @@ public static class ConfigHandler
{
return -1;
}
var customProfile = await SQLiteHelper.Instance.TableAsync<ProfileItem>().Where(t => t.Subid == subid && t.ConfigType == EConfigType.Custom).ToListAsync();
var customProfile = await SQLiteHelper.Instance.TableAsync<ProfileItem>().Where(t => t.Subid == subid && (t.ConfigType == EConfigType.Custom || t.ConfigType == EConfigType.Outbound)).ToListAsync();
if (isSub)
{
await SQLiteHelper.Instance.ExecuteAsync($"delete from ProfileItem where isSub = 1 and subid = '{subid}'");

View File

@@ -1,6 +1,6 @@
namespace ServiceLib.Handler.Fmt;
public class InnerFmt
public class InnerFmt : BaseFmt
{
private static readonly Lazy<string> SessionSalt = new(() => Utils.GetGuid(false));
@@ -50,19 +50,19 @@ public class InnerFmt
var protocolExtra = item.GetProtocolExtra();
// Only allow "self" as a special value for SubChildItems to avoid possible sources of attacks,
// which means it will be replaced with the subid, otherwise set it to null
//if (!protocolExtra.SubChildItems.IsNullOrEmpty())
// if (!protocolExtra.SubChildItems.IsNullOrEmpty())
if (protocolExtra.SubChildItems == "self")
{
protocolExtra = protocolExtra with
{
SubChildItems = subid
SubChildItems = subid,
};
}
else
{
protocolExtra = protocolExtra with
{
SubChildItems = null
SubChildItems = null,
};
}
if (Utils.String2List(protocolExtra.ChildItems) is { Count: > 0 } childIndexIds)
@@ -73,14 +73,14 @@ public class InnerFmt
.ToList();
protocolExtra = protocolExtra with
{
ChildItems = Utils.List2String(newChildIndexIds)
ChildItems = Utils.List2String(newChildIndexIds),
};
}
else
{
protocolExtra = protocolExtra with
{
ChildItems = null
ChildItems = null,
};
}
item.SetProtocolExtra(protocolExtra);
@@ -120,7 +120,7 @@ public class InnerFmt
{
protocolExtra = protocolExtra with
{
SubChildItems = "self"
SubChildItems = "self",
};
}
if (Utils.String2List(protocolExtra.ChildItems) is { Count: > 0 } childIndexIds)
@@ -131,7 +131,7 @@ public class InnerFmt
.ToList();
protocolExtra = protocolExtra with
{
ChildItems = Utils.List2String(newChildIndexIds)
ChildItems = Utils.List2String(newChildIndexIds),
};
}
itemClone.SetProtocolExtra(protocolExtra);
@@ -175,6 +175,19 @@ public class InnerFmt
jsonObj["TransportExtra"] = JsonUtils.Serialize(transportExtraObj, false);
jsonObj.Remove("TransportExtraObj");
}
var customOutboundFilePath = string.Empty;
if (jsonObj.TryGetPropertyValue("CustomOutboundObj", out var customOutboundNode)
&& customOutboundNode is JsonObject customOutboundObj)
{
var customOutboundContent = JsonUtils.Serialize(customOutboundObj, new JsonSerializerOptions
{
WriteIndented = true,
DefaultIgnoreCondition = JsonIgnoreCondition.Never,
Encoder = JavaScriptEncoder.UnsafeRelaxedJsonEscaping
});
customOutboundFilePath = WriteAllText(customOutboundContent);
jsonObj.Remove("CustomOutboundObj");
}
var profileItem = JsonUtils.Deserialize<ProfileItem>(JsonUtils.Serialize(jsonObj, false));
if (profileItem is null)
{
@@ -193,6 +206,14 @@ public class InnerFmt
{
return null;
}
if (profileItem.ConfigType is EConfigType.Outbound)
{
if (customOutboundFilePath.IsNullOrEmpty())
{
return null;
}
profileItem.Address = customOutboundFilePath;
}
var protocolExtra = profileItem.GetProtocolExtra();
var multipleLoad = protocolExtra.MultipleLoad;
if (multipleLoad is not null && !Enum.IsDefined(typeof(EMultipleLoad), multipleLoad))
@@ -209,6 +230,26 @@ public class InnerFmt
{
return null;
}
if (item.ConfigType is EConfigType.Outbound)
{
var customOutboundFilePath = item.Address;
if (!File.Exists(customOutboundFilePath))
{
customOutboundFilePath = Utils.GetConfigPath(customOutboundFilePath);
}
if (!File.Exists(customOutboundFilePath))
{
return null;
}
if (!customOutboundFilePath.IsNullOrEmpty()
&& File.Exists(customOutboundFilePath)
&& File.ReadAllText(customOutboundFilePath) is { Length: > 0 } customOutboundContent
&& JsonUtils.ParseJson(customOutboundContent) is JsonObject customOutboundObj)
{
jsonObj["CustomOutboundObj"] = customOutboundObj;
jsonObj.Remove("Address");
}
}
// unflatten
// move jsonObj.ProtoExtra (string) to jsonObj.ProtoExtraObj
// move jsonObj.TransportExtra (string) to jsonObj.TransportExtraObj
@@ -296,7 +337,7 @@ public class InnerFmt
JsonValue value when value.TryGetValue<string>(out var str) => string.IsNullOrEmpty(str),
JsonObject obj => obj.Count == 0,
JsonArray arr => arr.Count == 0,
_ => false
_ => false,
};
}
}

View File

@@ -2,19 +2,102 @@ namespace ServiceLib.Handler.Fmt;
public class SingboxFmt : BaseFmt
{
public static List<ProfileItem>? ResolveFullArray(string strData, string? subRemarks)
public static List<ProfileItem> ResolveToCustom(string strData, string? subRemarks)
{
var configObjects = JsonUtils.Deserialize<object[]>(strData);
if (configObjects is not { Length: > 0 })
var jsonNode = JsonUtils.ParseJson(strData);
return ResolveCommon(jsonNode, subRemarks, false);
}
public static List<ProfileItem> ResolveToCustomOutbound(string strData, string? subRemarks)
{
var jsonNode = JsonUtils.ParseJson(strData);
return ResolveCommon(jsonNode, subRemarks, true);
}
private static List<ProfileItem> ResolveCommon(JsonNode? jsonNode, string? subRemarks, bool isOutbound)
{
if (jsonNode is JsonArray jsonArray)
{
return
[
.. jsonArray.Select(item => ResolveCommon(item, subRemarks, isOutbound))
.Where(list => list is { Count: > 0 })
.SelectMany(list => list),
];
}
if (jsonNode is not JsonObject jsonObject)
{
return [];
}
// Process the individual JSON object
var profileList = new List<ProfileItem>();
if (!isOutbound)
{
var fullProfile = ResolveFull(jsonObject, subRemarks);
profileList.Add(fullProfile);
if (fullProfile is not null)
{
return profileList;
}
}
profileList.AddRange(ResolveFullToOutbound(jsonObject, subRemarks));
if (profileList.Count != 0)
{
return profileList;
}
var outboundProfile = ResolveOutbound(jsonObject, subRemarks);
if (outboundProfile is not null)
{
profileList.Add(outboundProfile);
}
return profileList;
}
private static ProfileItem? ResolveFull(JsonObject jsonObject, string? subRemarks)
{
if (jsonObject?["inbounds"] == null
|| jsonObject["outbounds"] == null)
{
return null;
}
List<ProfileItem> lstResult = [];
foreach (var configObject in configObjects)
if (jsonObject["outbounds"] is JsonArray outboundsArray)
{
var objectString = JsonUtils.Serialize(configObject);
var profileIt = ResolveFull(objectString, subRemarks);
if (!outboundsArray.Any(IsValidSingboxOutbound))
{
return null;
}
}
else
{
return null;
}
var fileName = WriteAllText(JsonUtils.Serialize(jsonObject));
var profileItem = new ProfileItem
{
CoreType = ECoreType.sing_box,
Address = fileName,
Remarks = subRemarks ?? "singbox_custom",
};
return profileItem;
}
private static List<ProfileItem> ResolveFullToOutbound(JsonObject jsonObject, string? subRemarks)
{
if (jsonObject?["outbounds"] is not JsonArray outboundsArray)
{
return [];
}
List<ProfileItem> lstResult = [];
foreach (var outbound in outboundsArray)
{
if (outbound is not JsonObject outboundObj)
{
continue;
}
var profileIt = ResolveOutbound(outboundObj, subRemarks);
if (profileIt != null)
{
lstResult.Add(profileIt);
@@ -23,25 +106,58 @@ public class SingboxFmt : BaseFmt
return lstResult;
}
public static ProfileItem? ResolveFull(string strData, string? subRemarks)
private static ProfileItem? ResolveOutbound(JsonObject jsonObject, string? subRemarks)
{
var config = JsonUtils.ParseJson(strData);
if (config?["inbounds"] == null
|| config["outbounds"] == null
|| config["route"] == null
|| config["dns"] == null)
if (!IsValidSingboxOutbound(jsonObject))
{
return null;
}
var fileName = WriteAllText(strData);
var type = jsonObject["type"]?.ToString();
if (type is null or "direct" or "block" or "dns" or "selector" or "urltest")
{
return null;
}
var tag = jsonObject["tag"]?.ToString();
var remarks = $"{type}_{tag}";
var fileName = WriteAllText(JsonUtils.Serialize(jsonObject));
var profileItem = new ProfileItem
{
ConfigType = EConfigType.Outbound,
CoreType = ECoreType.sing_box,
Address = fileName,
Remarks = subRemarks ?? "singbox_custom"
Remarks = remarks,
};
return profileItem;
}
private static bool IsValidSingboxOutbound(JsonNode? jsonNode)
{
if (jsonNode is not JsonObject jsonObject)
{
return false;
}
var matchedCounter = 0;
if (string.IsNullOrEmpty(jsonObject["type"]?.ToString()))
{
return false;
}
matchedCounter += 1;
if (!string.IsNullOrEmpty(jsonObject["tag"]?.ToString()))
{
matchedCounter += 1;
}
if (!string.IsNullOrEmpty(jsonObject["server"]?.ToString()))
{
matchedCounter += 1;
}
if (!string.IsNullOrEmpty(jsonObject["server_port"]?.ToString()))
{
matchedCounter += 1;
}
if (!string.IsNullOrEmpty(jsonObject["tls"]?.ToString()))
{
matchedCounter += 1;
}
return matchedCounter >= 2;
}
}

View File

@@ -2,47 +2,165 @@ namespace ServiceLib.Handler.Fmt;
public class V2rayFmt : BaseFmt
{
public static List<ProfileItem>? ResolveFullArray(string strData, string? subRemarks)
public static List<ProfileItem> ResolveToCustom(string strData, string? subRemarks)
{
var configObjects = JsonUtils.Deserialize<object[]>(strData);
if (configObjects is not { Length: > 0 })
{
return null;
}
List<ProfileItem> lstResult = [];
foreach (var configObject in configObjects)
{
var objectString = JsonUtils.Serialize(configObject);
var profileIt = ResolveFull(objectString, subRemarks);
if (profileIt != null)
{
lstResult.Add(profileIt);
}
}
return lstResult;
var jsonNode = JsonUtils.ParseJson(strData);
return ResolveCommon(jsonNode, subRemarks, false);
}
public static ProfileItem? ResolveFull(string strData, string? subRemarks)
public static List<ProfileItem> ResolveToCustomOutbound(string strData, string? subRemarks)
{
var config = JsonUtils.ParseJson(strData);
if (config?["inbounds"] == null
|| config["outbounds"] == null
|| config["routing"] == null)
var jsonNode = JsonUtils.ParseJson(strData);
return ResolveCommon(jsonNode, subRemarks, true);
}
private static List<ProfileItem> ResolveCommon(JsonNode? jsonNode, string? subRemarks, bool isOutbound)
{
if (jsonNode is JsonArray jsonArray)
{
return
[
.. jsonArray.Select(item => ResolveCommon(item, subRemarks, isOutbound))
.Where(list => list is { Count: > 0 })
.SelectMany(list => list),
];
}
if (jsonNode is not JsonObject jsonObject)
{
return [];
}
// Process the individual JSON object
var profileList = new List<ProfileItem>();
if (!isOutbound)
{
var fullProfile = ResolveFull(jsonObject, subRemarks);
profileList.Add(fullProfile);
if (fullProfile is not null)
{
return profileList;
}
}
profileList.AddRange(ResolveFullToOutbound(jsonObject, subRemarks));
if (profileList.Count != 0)
{
return profileList;
}
var outboundProfile = ResolveOutbound(jsonObject, subRemarks);
if (outboundProfile is not null)
{
profileList.Add(outboundProfile);
}
return profileList;
}
private static ProfileItem? ResolveFull(JsonObject jsonObject, string? subRemarks)
{
if (jsonObject?["inbounds"] == null
|| jsonObject["outbounds"] == null)
{
return null;
}
var fileName = WriteAllText(strData);
if (jsonObject["outbounds"] is JsonArray outboundsArray)
{
if (!outboundsArray.Any(IsValidV2rayOutbound))
{
return null;
}
}
else
{
return null;
}
var fileName = WriteAllText(JsonUtils.Serialize(jsonObject));
var profileItem = new ProfileItem
{
CoreType = ECoreType.Xray,
Address = fileName,
Remarks = config?["remarks"]?.ToString() ?? subRemarks ?? "v2ray_custom"
Remarks = jsonObject["remarks"]?.ToString() ?? subRemarks ?? "v2ray_custom",
};
return profileItem;
}
public static List<ProfileItem> ResolveFullToOutbound(JsonObject jsonObject, string? subRemarks)
{
if (jsonObject["outbounds"] is not JsonArray outboundsArray)
{
return [];
}
List<ProfileItem> lstResult = [];
foreach (var outbound in outboundsArray)
{
if (outbound is not JsonObject outboundObj)
{
continue;
}
var profileIt = ResolveOutbound(outboundObj, subRemarks);
if (profileIt != null)
{
lstResult.Add(profileIt);
}
}
return lstResult;
}
public static ProfileItem? ResolveOutbound(JsonObject jsonObject, string? subRemarks)
{
if (!IsValidV2rayOutbound(jsonObject))
{
return null;
}
var protocol = jsonObject["protocol"]?.ToString();
if (protocol is null or "freedom" or "blackhole" or "dns" or "loopback")
{
return null;
}
var tag = jsonObject["tag"]?.ToString();
var remarks = $"{protocol}_{tag}";
var fileName = WriteAllText(JsonUtils.Serialize(jsonObject));
var profileItem = new ProfileItem
{
ConfigType = EConfigType.Outbound,
CoreType = ECoreType.Xray,
Address = fileName,
Remarks = remarks,
};
return profileItem;
}
private static bool IsValidV2rayOutbound(JsonNode? jsonNode)
{
if (jsonNode is not JsonObject jsonObject)
{
return false;
}
var matchedCounter = 0;
if (string.IsNullOrEmpty(jsonObject["protocol"]?.ToString()))
{
return false;
}
matchedCounter += 1;
if (!string.IsNullOrEmpty(jsonObject["settings"]?.ToString()))
{
matchedCounter += 1;
}
if (!string.IsNullOrEmpty(jsonObject["streamSettings"]?.ToString()))
{
matchedCounter += 1;
}
if (!string.IsNullOrEmpty(jsonObject["tag"]?.ToString()))
{
matchedCounter += 1;
}
if (!string.IsNullOrEmpty(jsonObject["mux"]?.ToString()))
{
matchedCounter += 1;
}
return matchedCounter >= 3;
}
}

View File

@@ -118,7 +118,7 @@ public class GroupProfileManager
return childProfiles?.Where(p =>
p != null &&
p.IsValid() &&
!p.ConfigType.IsComplexType() &&
(!p.ConfigType.IsComplexType() || p.ConfigType == EConfigType.Outbound) &&
(extra.Filter.IsNullOrEmpty() || Regex.IsMatch(p.Remarks, extra.Filter))
)
.ToList() ?? [];

View File

@@ -11,6 +11,8 @@ public record CoreConfigContext
public Config AppConfig { get; init; } = new();
public FullConfigTemplateItem? FullConfigTemplate { get; init; } = new();
public Dictionary<string, string> CustomOutboundContent { get; init; } = new();
// Test ServerTestItem Map
public Dictionary<string, string> ServerTestItemMap { get; init; } = new();
@@ -22,4 +24,7 @@ public record CoreConfigContext
public bool IsWindows { get; init; }
public bool IsMacOS { get; init; }
// Generation Context
public Dictionary<object, string> CustomOutboundMap { get; init; } = new();
}

View File

@@ -66,7 +66,7 @@ public class ProfileItem
public bool IsValid()
{
if (IsComplex())
if (IsComplex() || ConfigType == EConfigType.Outbound)
{
return true;
}

View File

@@ -51,4 +51,7 @@ public record ProtocolExtraItem
public string? SubChildItems { get; init; }
public string? Filter { get; init; }
public EMultipleLoad? MultipleLoad { get; init; }
// custom outbound
public bool? IsSingboxEndpoint { get; init; }
}

View File

@@ -33,4 +33,6 @@ public class SubItem
public int? PreSocksPort { get; set; }
public string? Memo { get; set; }
public ECoreType? CustomCoreType { get; set; }
}

View File

@@ -438,6 +438,15 @@ namespace ServiceLib.Resx {
}
}
/// <summary>
/// 查找类似 Custom config core 的本地化字符串。
/// </summary>
public static string LvCustomCoreType {
get {
return ResourceManager.GetString("LvCustomCoreType", resourceCulture);
}
}
/// <summary>
/// 查找类似 Custom icon 的本地化字符串。
/// </summary>
@@ -735,6 +744,15 @@ namespace ServiceLib.Resx {
}
}
/// <summary>
/// 查找类似 Add a custom outbound 的本地化字符串。
/// </summary>
public static string menuAddCustomOutboundServer {
get {
return ResourceManager.GetString("menuAddCustomOutboundServer", resourceCulture);
}
}
/// <summary>
/// 查找类似 Add a custom configuration 的本地化字符串。
/// </summary>
@@ -1933,7 +1951,7 @@ namespace ServiceLib.Resx {
}
/// <summary>
/// 查找类似 Warning: Xray will disable allowInsecure (skip certificate verification) in August 2026. Please switch to pinnedPeerCertSha256 (fixed certificate fingerprint) as soon as possible. allowInsecure will not be usable after its expiration. 的本地化字符串。
/// 查找类似 The current node uses an unencrypted connection, meaning your communications could be directly monitored by network intermediaries controlled by authoritarian governments. For security reasons, nodes of this type cannot connect via Xray-core versions 26.2.6 or higher. If this is a self-built node, please enable TLS or other secure encryption, or pin the certificate using pinSHA256. If this is an airport/provider node, please contact your service provider for a technical upgrade. If the provider refuses to c [字符串的其余部分被截断]&quot;; 的本地化字符串。
/// </summary>
public static string MsgAllowInsecureDeprecated {
get {
@@ -1977,6 +1995,15 @@ namespace ServiceLib.Resx {
}
}
/// <summary>
/// 查找类似 Custom outbound {0} file not found: {1} 的本地化字符串。
/// </summary>
public static string MsgCustomOutboundFileNotFound {
get {
return ResourceManager.GetString("MsgCustomOutboundFileNotFound", resourceCulture);
}
}
/// <summary>
/// 查找类似 Downloaded GeoFile: {0} successfully 的本地化字符串。
/// </summary>
@@ -2925,6 +2952,15 @@ namespace ServiceLib.Resx {
}
}
/// <summary>
/// 查找类似 Only single outbound/endpoint supported for xray/sing-box 的本地化字符串。
/// </summary>
public static string TbCustomOutboundTip {
get {
return ResourceManager.GetString("TbCustomOutboundTip", resourceCulture);
}
}
/// <summary>
/// 查找类似 Direct Target Resolution Strategy 的本地化字符串。
/// </summary>

View File

@@ -1848,4 +1848,16 @@ The "Get Certificate" action may fail if a self-signed certificate is used or if
<data name="TbIpv4Address" xml:space="preserve">
<value>Ipv4 Address</value>
</data>
<data name="menuAddCustomOutboundServer" xml:space="preserve">
<value>Add a custom outbound</value>
</data>
<data name="MsgCustomOutboundFileNotFound" xml:space="preserve">
<value>Custom outbound {0} file not found: {1}</value>
</data>
<data name="TbCustomOutboundTip" xml:space="preserve">
<value>Only single outbound/endpoint supported for xray/sing-box</value>
</data>
<data name="LvCustomCoreType" xml:space="preserve">
<value>Custom config core</value>
</data>
</root>

View File

@@ -1849,4 +1849,16 @@
<data name="TbIpv4Address" xml:space="preserve">
<value>Ipv4 地址</value>
</data>
<data name="menuAddCustomOutboundServer" xml:space="preserve">
<value>添加自定义出站</value>
</data>
<data name="MsgCustomOutboundFileNotFound" xml:space="preserve">
<value>自定义出站 {0} 的文件未找到:{1}</value>
</data>
<data name="TbCustomOutboundTip" xml:space="preserve">
<value>仅支持 xray/sing-box 的单个 outbound/endpoin</value>
</data>
<data name="LvCustomCoreType" xml:space="preserve">
<value>自定义配置核心</value>
</data>
</root>

View File

@@ -57,13 +57,10 @@ public partial class CoreConfigSingboxService(CoreConfigContext context)
ConvertGeo2Ruleset();
ApplyOutboundBindInterface();
ApplyOutboundSendThrough();
ret.Msg = string.Format(ResUI.SuccessfulConfiguration, "");
ret.Success = true;
ret.Data = ApplyFullConfigTemplate();
ret.Data = ApplyFinalConfigModifiers();
return ret;
}
catch (Exception ex)
@@ -107,7 +104,7 @@ public partial class CoreConfigSingboxService(CoreConfigContext context)
foreach (var it in selecteds)
{
if (!(Global.SingboxSupportConfigType.Contains(it.ConfigType) || it.ConfigType.IsGroupType()))
if (!(Global.SingboxSupportConfigType.Contains(it.ConfigType) || it.ConfigType.IsGroupType() || it.ConfigType is EConfigType.Outbound))
{
continue;
}
@@ -174,7 +171,7 @@ public partial class CoreConfigSingboxService(CoreConfigContext context)
ApplyOutboundBindInterface();
ApplyOutboundSendThrough();
ret.Success = true;
ret.Data = JsonUtils.Serialize(_coreConfig);
ret.Data = ApplyCustomOutboundReplace();
return ret;
}
catch (Exception ex)
@@ -236,7 +233,7 @@ public partial class CoreConfigSingboxService(CoreConfigContext context)
ret.Msg = string.Format(ResUI.SuccessfulConfiguration, "");
ret.Success = true;
ret.Data = JsonUtils.Serialize(_coreConfig);
ret.Data = ApplyCustomOutboundReplace();
return ret;
}
catch (Exception ex)

View File

@@ -2,54 +2,137 @@ namespace ServiceLib.Services.CoreConfig;
public partial class CoreConfigSingboxService
{
private string ApplyFullConfigTemplate()
private string ApplyFinalConfigModifiers()
{
ApplyOutboundBindInterface();
ApplyOutboundSendThrough();
var coreConfigContent = ApplyCustomOutboundReplace();
return ApplyFullConfigTemplate(coreConfigContent);
}
private string ApplyCustomOutboundReplace()
{
var coreConfigContent = JsonUtils.Serialize(_coreConfig);
if (context.CustomOutboundMap.Count == 0)
{
return coreConfigContent;
}
var coreConfigNode = JsonNode.Parse(coreConfigContent) as JsonObject;
var coreConfigOutboundsNode = coreConfigNode?["outbounds"] as JsonArray ?? [];
ReplaceCustomOutbounds(_coreConfig.outbounds, coreConfigOutboundsNode);
coreConfigNode!["outbounds"] = coreConfigOutboundsNode;
var coreConfigEndpointsNode = coreConfigNode?["endpoints"] as JsonArray ?? [];
ReplaceCustomOutbounds(_coreConfig.endpoints, coreConfigEndpointsNode);
if (coreConfigEndpointsNode.Count > 0)
{
coreConfigNode!["endpoints"] = coreConfigEndpointsNode;
}
else
{
coreConfigNode?.Remove("endpoints");
}
return JsonUtils.Serialize(coreConfigNode);
void ReplaceCustomOutbounds(IReadOnlyList<BaseServer4Sbox>? source, JsonArray jsonArrayOutbounds)
{
foreach (var outbound in source ?? [])
{
if (!context.CustomOutboundMap.TryGetValue(outbound, out var customOutboundIndex))
{
continue;
}
var outboundTag = outbound.tag;
var outboundDetour = outbound.detour ?? string.Empty;
var outboundBindInterface = outbound.bind_interface ?? string.Empty;
var customOutboundContent = context.CustomOutboundContent[customOutboundIndex];
var containTagPlaceholder = customOutboundContent.Contains("{{tag}}");
var containDetourPlaceholder = customOutboundContent.Contains("{{detour}}");
var containBindInterfacePlaceholder = customOutboundContent.Contains("{{interface}}");
customOutboundContent = customOutboundContent.Replace("{{tag}}", outboundTag);
customOutboundContent = customOutboundContent.Replace("{{detour}}", outboundDetour);
customOutboundContent = customOutboundContent.Replace("{{interface}}", outboundBindInterface);
var customOutboundObj = JsonUtils.ParseJson(customOutboundContent) as JsonObject;
if (!containTagPlaceholder)
{
customOutboundObj?["tag"] = outboundTag;
}
if (!containDetourPlaceholder && !outboundDetour.IsNullOrEmpty())
{
customOutboundObj?["detour"] = outboundDetour;
}
else if (outboundDetour.IsNullOrEmpty())
{
customOutboundObj?.Remove("detour");
}
if (!containBindInterfacePlaceholder && !outboundBindInterface.IsNullOrEmpty())
{
customOutboundObj?["bind_interface"] = outboundBindInterface;
}
var index = jsonArrayOutbounds
.Select((node, idx) => new { node, idx })
.FirstOrDefault(x => x.node?["tag"]?.ToString() == outboundTag)?.idx ?? -1;
if (index != -1)
{
jsonArrayOutbounds[index] = customOutboundObj;
}
}
}
}
private string ApplyFullConfigTemplate(string coreConfigContent)
{
var fullConfigTemplate = context.FullConfigTemplate;
if (fullConfigTemplate is not { Enabled: true })
{
return JsonUtils.Serialize(_coreConfig);
return coreConfigContent;
}
var fullConfigTemplateItem = context.IsTunEnabled ? fullConfigTemplate.TunConfig : fullConfigTemplate.Config;
if (fullConfigTemplateItem.IsNullOrEmpty())
{
return JsonUtils.Serialize(_coreConfig);
return coreConfigContent;
}
var fullConfigTemplateNode = JsonNode.Parse(fullConfigTemplateItem);
if (fullConfigTemplateNode == null)
{
return JsonUtils.Serialize(_coreConfig);
return coreConfigContent;
}
// Process outbounds
var customOutboundsNode = fullConfigTemplateNode["outbounds"] as JsonArray ?? [];
foreach (var outbound in _coreConfig.outbounds)
var coreConfigNode = JsonNode.Parse(coreConfigContent);
var coreConfigOutboundsNode = coreConfigNode?["outbounds"] as JsonArray ?? [];
foreach (var outbound in coreConfigOutboundsNode)
{
if (outbound.type.ToLower() is "direct" or "block")
if (outbound["type"]?.ToString()?.ToLower() is "direct" or "block")
{
if (fullConfigTemplate.AddProxyOnly == true)
{
continue;
}
}
else if (outbound.detour.IsNullOrEmpty() && !fullConfigTemplate.ProxyDetour.IsNullOrEmpty() && !Utils.IsPrivateNetwork(outbound.server ?? string.Empty))
if (outbound["detour"] is null && !fullConfigTemplate.ProxyDetour.IsNullOrEmpty() && !Utils.IsPrivateNetwork(outbound["server"]?.ToString() ?? string.Empty))
{
outbound.detour = fullConfigTemplate.ProxyDetour;
outbound["detour"] = fullConfigTemplate.ProxyDetour;
}
customOutboundsNode.Add(JsonUtils.DeepCopy(outbound));
}
fullConfigTemplateNode["outbounds"] = customOutboundsNode;
// Process endpoints
if (_coreConfig.endpoints is { Count: > 0 })
if (fullConfigTemplateNode["endpoints"] is JsonArray { Count: > 0 } coreConfigEndpointsNode)
{
var customEndpointsNode = fullConfigTemplateNode["endpoints"] as JsonArray ?? [];
foreach (var endpoint in _coreConfig.endpoints)
foreach (var endpoint in coreConfigEndpointsNode)
{
if (endpoint.detour.IsNullOrEmpty() && !fullConfigTemplate.ProxyDetour.IsNullOrEmpty())
if (endpoint["detour"] is null && !fullConfigTemplate.ProxyDetour.IsNullOrEmpty())
{
endpoint.detour = fullConfigTemplate.ProxyDetour;
endpoint["detour"] = fullConfigTemplate.ProxyDetour;
}
customEndpointsNode.Add(JsonUtils.DeepCopy(endpoint));
}

View File

@@ -11,7 +11,7 @@ public partial class CoreConfigSingboxService
private List<BaseServer4Sbox> BuildAllProxyOutbounds(string baseTagName = Global.ProxyTag, bool withSelector = true)
{
var proxyOutboundList = new List<BaseServer4Sbox>();
if (!_node.ConfigType.IsComplexType())
if (!_node.ConfigType.IsGroupType())
{
var outbound = BuildProxyOutbound(baseTagName);
proxyOutboundList.Add(outbound);
@@ -35,6 +35,10 @@ public partial class CoreConfigSingboxService
{
var outbound = BuildProxyServer();
outbound.tag = baseTagName;
if (_node.ConfigType == EConfigType.Outbound)
{
context.CustomOutboundMap[outbound] = _node.IndexId;
}
return outbound;
}
@@ -59,6 +63,20 @@ public partial class CoreConfigSingboxService
try
{
var txtOutbound = EmbedUtils.GetEmbedText(Global.SingboxSampleOutbound);
if (_node.ConfigType == EConfigType.Outbound)
{
if (_node.GetProtocolExtra().IsSingboxEndpoint == true)
{
var endpoint = JsonUtils.Deserialize<Endpoints4Sbox>(txtOutbound);
return endpoint;
}
else
{
var outbound = JsonUtils.Deserialize<Outbound4Sbox>(txtOutbound);
return outbound;
}
}
if (_node.ConfigType == EConfigType.WireGuard)
{
var endpoint = JsonUtils.Deserialize<Endpoints4Sbox>(txtOutbound);
@@ -593,7 +611,7 @@ public partial class CoreConfigSingboxService
{
type = "selector",
tag = baseTagName,
outbounds = JsonUtils.DeepCopy(proxyTags),
outbounds = [.. proxyTags],
interrupt_exist_connections = false,
};
outSelector.outbounds.Insert(0, outUrltest.tag);
@@ -721,9 +739,9 @@ public partial class CoreConfigSingboxService
return resultOutbounds;
}
private static List<BaseServer4Sbox> CloneOutbounds(List<BaseServer4Sbox> source)
private List<BaseServer4Sbox> CloneOutbounds(List<BaseServer4Sbox> source)
{
if (source is null || source.Count == 0)
if (source is not { Count: > 0 })
{
return [];
}
@@ -740,9 +758,14 @@ public partial class CoreConfigSingboxService
{
clone = JsonUtils.DeepCopy(endpoint);
}
if (clone is not null)
if (clone is null)
{
result.Add(clone);
continue;
}
result.Add(clone);
if (context.CustomOutboundMap.ContainsKey(item))
{
context.CustomOutboundMap[clone] = context.CustomOutboundMap[item];
}
}
return result;

View File

@@ -574,7 +574,8 @@ public partial class CoreConfigSingboxService
if (node == null
|| (!Global.SingboxSupportConfigType.Contains(node.ConfigType)
&& !node.ConfigType.IsGroupType()))
&& !node.ConfigType.IsGroupType()
&& node.ConfigType is not EConfigType.Outbound))
{
return Global.ProxyTag;
}

View File

@@ -64,8 +64,6 @@ public partial class CoreConfigV2rayService(CoreConfigContext context)
{
ApplyFinalFragment();
}
ApplyOutboundBindInterface();
ApplyOutboundSendThrough();
var finalRule = BuildFinalRule();
if (!string.IsNullOrEmpty(finalRule?.balancerTag))
@@ -75,7 +73,7 @@ public partial class CoreConfigV2rayService(CoreConfigContext context)
ret.Msg = string.Format(ResUI.SuccessfulConfiguration, "");
ret.Success = true;
ret.Data = ApplyFullConfigTemplate();
ret.Data = ApplyFinalConfigModifiers();
return ret;
}
catch (Exception ex)
@@ -119,7 +117,7 @@ public partial class CoreConfigV2rayService(CoreConfigContext context)
foreach (var it in selecteds)
{
if (!(Global.XraySupportConfigType.Contains(it.ConfigType) || it.ConfigType.IsGroupType()))
if (!(Global.XraySupportConfigType.Contains(it.ConfigType) || it.ConfigType.IsGroupType() || it.ConfigType is EConfigType.Outbound))
{
continue;
}
@@ -216,7 +214,7 @@ public partial class CoreConfigV2rayService(CoreConfigContext context)
ApplyOutboundSendThrough();
//ret.Msg =string.Format(ResUI.SuccessfulConfiguration"), node.getSummary());
ret.Success = true;
ret.Data = JsonUtils.Serialize(_coreConfig);
ret.Data = ApplyCustomOutboundReplace();
return ret;
}
catch (Exception ex)
@@ -293,7 +291,7 @@ public partial class CoreConfigV2rayService(CoreConfigContext context)
ret.Msg = string.Format(ResUI.SuccessfulConfiguration, "");
ret.Success = true;
ret.Data = JsonUtils.Serialize(_coreConfig);
ret.Data = ApplyCustomOutboundReplace();
return ret;
}
catch (Exception ex)

View File

@@ -2,24 +2,105 @@ namespace ServiceLib.Services.CoreConfig;
public partial class CoreConfigV2rayService
{
private string ApplyFullConfigTemplate()
private string ApplyFinalConfigModifiers()
{
ApplyOutboundBindInterface();
ApplyOutboundSendThrough();
var coreConfigContent = ApplyCustomOutboundReplace();
return ApplyFullConfigTemplate(coreConfigContent);
}
private string ApplyCustomOutboundReplace()
{
var coreConfigContent = JsonUtils.Serialize(_coreConfig);
if (context.CustomOutboundMap.Count == 0)
{
return coreConfigContent;
}
var coreConfigNode = JsonNode.Parse(coreConfigContent) as JsonObject;
var coreConfigOutboundsNode = coreConfigNode?["outbounds"] as JsonArray ?? [];
foreach (var outbound in _coreConfig.outbounds ?? [])
{
if (!context.CustomOutboundMap.TryGetValue(outbound, out var customOutboundIndex))
{
continue;
}
var outboundTag = outbound.tag;
var outboundDetour = outbound.streamSettings?.sockopt?.dialerProxy ?? string.Empty;
var outboundBindInterface = outbound.streamSettings?.sockopt?.Interface ?? string.Empty;
var customOutboundContent = context.CustomOutboundContent[customOutboundIndex];
var containTagPlaceholder = customOutboundContent.Contains("{{tag}}");
var containDetourPlaceholder = customOutboundContent.Contains("{{detour}}");
var containBindInterfacePlaceholder = customOutboundContent.Contains("{{interface}}");
customOutboundContent = customOutboundContent.Replace("{{tag}}", outboundTag);
customOutboundContent = customOutboundContent.Replace("{{detour}}", outboundDetour);
customOutboundContent = customOutboundContent.Replace("{{interface}}", outboundBindInterface);
var customOutboundObj = JsonUtils.ParseJson(customOutboundContent) as JsonObject;
if (!containTagPlaceholder)
{
customOutboundObj?["tag"] = outboundTag;
}
if (!containDetourPlaceholder && !outboundDetour.IsNullOrEmpty())
{
customOutboundObj!["streamSettings"] ??= new JsonObject();
customOutboundObj["streamSettings"]["sockopt"] ??= new JsonObject();
customOutboundObj["streamSettings"]["sockopt"]["dialerProxy"] = outboundDetour;
if (customOutboundObj["streamSettings"]?["xhttpSettings"]?["extra"]?["downloadSettings"] is JsonObject downloadSettings)
{
downloadSettings["sockopt"] ??= new JsonObject();
downloadSettings["sockopt"]["dialerProxy"] = outboundDetour;
}
}
else if (outboundDetour.IsNullOrEmpty())
{
(customOutboundObj?["streamSettings"]?["sockopt"] as JsonObject)?.Remove("dialerProxy");
}
if (!containBindInterfacePlaceholder && !outboundBindInterface.IsNullOrEmpty())
{
customOutboundObj!["streamSettings"] ??= new JsonObject();
customOutboundObj["streamSettings"]["sockopt"] ??= new JsonObject();
customOutboundObj["streamSettings"]["sockopt"]["interface"] = outboundBindInterface;
if (customOutboundObj["streamSettings"]?["xhttpSettings"]?["extra"]?["downloadSettings"] is JsonObject downloadSettings)
{
downloadSettings["sockopt"] ??= new JsonObject();
downloadSettings["sockopt"]["interface"] = outboundBindInterface;
}
}
var index = coreConfigOutboundsNode
.Select((node, idx) => new { node, idx })
.FirstOrDefault(x => x.node?["tag"]?.ToString() == outboundTag)?.idx ?? -1;
if (index != -1)
{
coreConfigOutboundsNode[index] = customOutboundObj;
}
}
return JsonUtils.Serialize(coreConfigNode);
}
private string ApplyFullConfigTemplate(string coreConfigContent)
{
var fullConfigTemplate = context.FullConfigTemplate;
if (fullConfigTemplate is not { Enabled: true })
{
return JsonUtils.Serialize(_coreConfig);
return coreConfigContent;
}
var fullConfigTemplateItem = context.IsTunEnabled ? fullConfigTemplate.TunConfig : fullConfigTemplate.Config;
if (fullConfigTemplateItem.IsNullOrEmpty())
{
return JsonUtils.Serialize(_coreConfig);
return coreConfigContent;
}
var fullConfigTemplateNode = JsonNode.Parse(fullConfigTemplateItem);
if (fullConfigTemplateNode == null)
{
return JsonUtils.Serialize(_coreConfig);
return coreConfigContent;
}
// Handle balancer and rules modifications (for multiple load scenarios)
@@ -74,8 +155,8 @@ public partial class CoreConfigV2rayService
else
{
var subjectSelector = _coreConfig.observatory.subjectSelector;
subjectSelector.AddRange(fullConfigTemplateNode["observatory"]?["subjectSelector"]?.AsArray()?.Select(x => x?.GetValue<string>()) ?? []);
fullConfigTemplateNode["observatory"]["subjectSelector"] = JsonNode.Parse(JsonUtils.Serialize(subjectSelector.Distinct().ToList()));
subjectSelector?.AddRange(fullConfigTemplateNode["observatory"]?["subjectSelector"]?.AsArray()?.Select(x => x?.GetValue<string>()) ?? []);
fullConfigTemplateNode["observatory"]?["subjectSelector"] = JsonNode.Parse(JsonUtils.Serialize(subjectSelector?.Distinct().ToList()));
}
}
@@ -88,16 +169,18 @@ public partial class CoreConfigV2rayService
else
{
var subjectSelector = _coreConfig.burstObservatory.subjectSelector;
subjectSelector.AddRange(fullConfigTemplateNode["burstObservatory"]?["subjectSelector"]?.AsArray()?.Select(x => x?.GetValue<string>()) ?? []);
fullConfigTemplateNode["burstObservatory"]["subjectSelector"] = JsonNode.Parse(JsonUtils.Serialize(subjectSelector.Distinct().ToList()));
subjectSelector?.AddRange(fullConfigTemplateNode["burstObservatory"]?["subjectSelector"]?.AsArray()?.Select(x => x?.GetValue<string>()) ?? []);
fullConfigTemplateNode["burstObservatory"]?["subjectSelector"] = JsonNode.Parse(JsonUtils.Serialize(subjectSelector?.Distinct().ToList()));
}
}
var customOutboundsNode = new JsonArray();
foreach (var outbound in _coreConfig.outbounds)
var coreConfigNode = JsonNode.Parse(coreConfigContent);
var coreConfigOutboundsNode = coreConfigNode?["outbounds"] as JsonArray ?? [];
foreach (var outbound in coreConfigOutboundsNode)
{
if (outbound.protocol.ToLower() is "blackhole" or "dns" or "freedom")
if (outbound?["protocol"]?.ToString()?.ToLower() is "blackhole" or "dns" or "freedom")
{
if (fullConfigTemplate.AddProxyOnly == true)
{
@@ -105,14 +188,22 @@ public partial class CoreConfigV2rayService
}
}
else if (!fullConfigTemplate.ProxyDetour.IsNullOrEmpty()
&& (outbound.streamSettings?.sockopt?.dialerProxy.IsNullOrEmpty() ?? true))
&& (outbound["streamSettings"]?["sockopt"]?["dialerProxy"].ToString().IsNullOrEmpty() ?? true))
{
var outboundAddress = outbound.settings?.servers?.FirstOrDefault()?.address
?? outbound.settings?.vnext?.FirstOrDefault()?.address
var outboundAddress = outbound["settings"]?["servers"]?.AsArray()?.FirstOrDefault()?["address"]?.ToString()
?? outbound["settings"]?["vnext"]?.AsArray()?.FirstOrDefault()?["address"]?.ToString()
?? string.Empty;
if (!Utils.IsPrivateNetwork(outboundAddress))
{
FillDialerProxy(outbound, fullConfigTemplate.ProxyDetour);
//FillDialerProxy(outbound, fullConfigTemplate.ProxyDetour);
outbound["streamSettings"] ??= new JsonObject();
outbound["streamSettings"]["sockopt"] ??= new JsonObject();
outbound["streamSettings"]["sockopt"]["dialerProxy"] = fullConfigTemplate.ProxyDetour;
if (outbound["streamSettings"]?["xhttpSettings"]?["extra"]?["downloadSettings"] is JsonObject downloadSettings)
{
downloadSettings["sockopt"] ??= new JsonObject();
downloadSettings["sockopt"]["dialerProxy"] = fullConfigTemplate.ProxyDetour;
}
}
}
customOutboundsNode.Add(JsonUtils.DeepCopy(outbound));

View File

@@ -52,6 +52,12 @@ public partial class CoreConfigV2rayService
{
var txtOutbound = EmbedUtils.GetEmbedText(Global.V2raySampleOutbound);
var outbound = JsonUtils.Deserialize<Outbounds4Ray>(txtOutbound);
if (_node.ConfigType == EConfigType.Outbound)
{
outbound.tag = baseTagName;
context.CustomOutboundMap[outbound] = _node.IndexId;
return outbound;
}
FillOutbound(outbound);
outbound.tag = baseTagName;
return outbound;
@@ -788,12 +794,12 @@ public partial class CoreConfigV2rayService
}
else if (chainStartNodes.Count > 1)
{
var existedChainNodes = JsonUtils.DeepCopy(resultOutbounds);
var existedChainNodes = CloneOutbounds(resultOutbounds);
resultOutbounds.Clear();
var j = 0;
foreach (var chainStartNode in chainStartNodes)
{
var existedChainNodesClone = JsonUtils.DeepCopy(existedChainNodes);
var existedChainNodesClone = CloneOutbounds(existedChainNodes);
foreach (var existedChainNode in existedChainNodesClone)
{
var cloneTag = $"{existedChainNode.tag}-clone-{j + 1}";
@@ -955,4 +961,19 @@ public partial class CoreConfigV2rayService
return fragmentMask;
}
private List<Outbounds4Ray> CloneOutbounds(List<Outbounds4Ray> outbounds)
{
var clonedOutbounds = new List<Outbounds4Ray>();
foreach (var outbound in outbounds)
{
var clonedOutbound = JsonUtils.DeepCopy(outbound);
clonedOutbounds.Add(clonedOutbound);
if (context.CustomOutboundMap.ContainsKey(outbound))
{
context.CustomOutboundMap[clonedOutbound] = context.CustomOutboundMap[outbound];
}
}
return clonedOutbounds;
}
}

View File

@@ -188,7 +188,8 @@ public partial class CoreConfigV2rayService
if (node == null
|| (!Global.XraySupportConfigType.Contains(node.ConfigType)
&& !node.ConfigType.IsGroupType()))
&& !node.ConfigType.IsGroupType()
&& node.ConfigType is not EConfigType.Outbound))
{
return Global.ProxyTag;
}

View File

@@ -12,6 +12,9 @@ public partial class AddServer2ViewModel : MyReactiveObject, ICloseable
[Reactive]
public partial string? CoreType { get; set; }
[Reactive]
public partial bool IsSingboxEndpoint { get; set; }
public ReactiveCommand<RxVoid, RxVoid> BrowseServerCmd { get; }
public ReactiveCommand<RxVoid, RxVoid> EditServerCmd { get; }
public ReactiveCommand<RxVoid, RxVoid> SaveServerCmd { get; }
@@ -40,7 +43,10 @@ public partial class AddServer2ViewModel : MyReactiveObject, ICloseable
});
SelectedSource = profileItem.IndexId.IsNullOrEmpty() ? profileItem : JsonUtils.DeepCopy(profileItem);
CoreType = SelectedSource?.CoreType?.ToString();
var coreStr = SelectedSource?.CoreType?.ToString();
coreStr = coreStr.IsNullOrEmpty() ? Global.CoreTypes.FirstOrDefault() : coreStr;
CoreType = coreStr;
IsSingboxEndpoint = SelectedSource?.GetProtocolExtra()?.IsSingboxEndpoint ?? false;
}
private async Task SaveServerAsync()
@@ -58,6 +64,10 @@ public partial class AddServer2ViewModel : MyReactiveObject, ICloseable
return;
}
SelectedSource.CoreType = CoreType.IsNullOrEmpty() ? null : Enum.Parse<ECoreType>(CoreType);
SelectedSource.SetProtocolExtra(SelectedSource?.GetProtocolExtra() with
{
IsSingboxEndpoint = IsSingboxEndpoint ? true : null,
});
if (await ConfigHandler.EditCustomServer(_config, SelectedSource) == 0)
{
@@ -80,7 +90,8 @@ public partial class AddServer2ViewModel : MyReactiveObject, ICloseable
var item = await AppManager.Instance.GetProfileItem(SelectedSource.IndexId);
item ??= SelectedSource;
item.Address = fileName;
if (await ConfigHandler.AddCustomServer(_config, item, false) == 0)
var result = item.ConfigType == EConfigType.Outbound ? await ConfigHandler.AddCustomOutboundServer(_config, item, false) : await ConfigHandler.AddCustomServer(_config, item, false);
if (result == 0)
{
NoticeManager.Instance.Enqueue(ResUI.SuccessfullyImportedCustomServer);
if (item.IndexId.IsNotEmpty())

View File

@@ -33,6 +33,7 @@ public partial class MainWindowViewModel : MyReactiveObject
public ReactiveCommand<RxVoid, RxVoid> AddAnytlsServerCmd { get; }
public ReactiveCommand<RxVoid, RxVoid> AddNaiveServerCmd { get; }
public ReactiveCommand<RxVoid, RxVoid> AddCustomServerCmd { get; }
public ReactiveCommand<RxVoid, RxVoid> AddCustomOutboundServerCmd { get; }
public ReactiveCommand<RxVoid, RxVoid> AddPolicyGroupServerCmd { get; }
public ReactiveCommand<RxVoid, RxVoid> AddProxyChainServerCmd { get; }
public ReactiveCommand<RxVoid, RxVoid> AddServerViaClipboardCmd { get; }
@@ -145,6 +146,10 @@ public partial class MainWindowViewModel : MyReactiveObject
{
await AddServerAsync(EConfigType.Custom);
});
AddCustomOutboundServerCmd = ReactiveCommand.CreateFromTask(async () =>
{
await AddServerAsync(EConfigType.Outbound);
});
AddPolicyGroupServerCmd = ReactiveCommand.CreateFromTask(async () =>
{
await AddServerAsync(EConfigType.PolicyGroup);
@@ -432,7 +437,7 @@ public partial class MainWindowViewModel : MyReactiveObject
};
bool? ret = false;
if (eConfigType == EConfigType.Custom)
if (eConfigType is EConfigType.Custom or EConfigType.Outbound)
{
var addServer2ViewModel = new AddServer2ViewModel(item);
ret = await AppManager.Instance.WindowDialog.ShowDialogAsync(addServer2ViewModel);

View File

@@ -503,7 +503,7 @@ public partial class ProfilesViewModel : MyReactiveObject
var eConfigType = item.ConfigType;
bool? ret = false;
if (eConfigType == EConfigType.Custom)
if (eConfigType is EConfigType.Custom or EConfigType.Outbound)
{
var addServer2ViewModel = new AddServer2ViewModel(item);
ret = await AppManager.Instance.WindowDialog.ShowDialogAsync(addServer2ViewModel);

View File

@@ -7,6 +7,9 @@ public partial class SubEditViewModel : MyReactiveObject, ICloseable
[Reactive]
public partial SubItem SelectedSource { get; set; }
[Reactive]
public partial string CustomCoreType { get; set; }
public ReactiveCommand<RxVoid, RxVoid> SelectPrevProfileCmd { get; }
public ReactiveCommand<RxVoid, RxVoid> SelectNextProfileCmd { get; }
public ReactiveCommand<RxVoid, RxVoid> SaveCmd { get; }
@@ -39,6 +42,7 @@ public partial class SubEditViewModel : MyReactiveObject, ICloseable
});
SelectedSource = subItem.Id.IsNullOrEmpty() ? subItem : JsonUtils.DeepCopy(subItem);
CustomCoreType = SelectedSource.CustomCoreType?.ToString() ?? string.Empty;
}
private async Task SaveSubAsync()
@@ -67,6 +71,8 @@ public partial class SubEditViewModel : MyReactiveObject, ICloseable
}
}
SelectedSource.CustomCoreType = Enum.TryParse<ECoreType>(CustomCoreType, out var coreType) ? coreType : null;
if (await ConfigHandler.AddSubItem(_config, SelectedSource) == 0)
{
NoticeManager.Instance.Enqueue(ResUI.OperationSuccess);

View File

@@ -103,57 +103,102 @@
HorizontalAlignment="Left"
MaxDropDownHeight="1000" />
<TextBlock
<Separator
Grid.Row="4"
Grid.Column="0"
Margin="{StaticResource Margin4}"
VerticalAlignment="Center"
Text="{x:Static resx:ResUI.TbDisplayLog}" />
<StackPanel
Grid.Row="4"
Grid.Column="1"
Margin="{StaticResource Margin4}"
Orientation="Horizontal">
Grid.ColumnSpan="3"
Margin="{StaticResource Margin4}" />
<Grid
x:Name="gridCustomServer"
Grid.Row="5"
Grid.Column="0"
Grid.ColumnSpan="3"
ColumnDefinitions="Auto,Auto,Auto"
RowDefinitions="Auto,Auto,Auto">
<TextBlock
Grid.Row="0"
Grid.Column="0"
Margin="{StaticResource Margin4}"
VerticalAlignment="Center"
Text="{x:Static resx:ResUI.TbDisplayLog}" />
<StackPanel
Grid.Row="0"
Grid.Column="1"
Margin="{StaticResource Margin4}"
Orientation="Horizontal">
<ToggleSwitch
x:Name="togDisplayLog"
Margin="{StaticResource Margin4}"
HorizontalAlignment="Left" />
<TextBlock
Margin="{StaticResource MarginLr8}"
VerticalAlignment="Center"
Text="{x:Static resx:ResUI.TipDisplayLog}" />
</StackPanel>
<TextBlock
Grid.Row="1"
Grid.Column="0"
Margin="{StaticResource Margin4}"
VerticalAlignment="Center"
Text="{x:Static resx:ResUI.TbPreSocksPort}" />
<TextBox
x:Name="txtPreSocksPort"
Grid.Row="1"
Grid.Column="1"
Width="200"
Margin="{StaticResource Margin4}"
HorizontalAlignment="Left" />
<StackPanel
Grid.Row="2"
Grid.Column="1"
Grid.ColumnSpan="2"
Margin="{StaticResource Margin4}">
<TextBlock
Width="500"
VerticalAlignment="Center"
Text="{x:Static resx:ResUI.TipPreSocksPort}"
TextWrapping="Wrap" />
<TextBlock
Width="500"
Margin="{StaticResource MarginLr8}"
VerticalAlignment="Center"
Text="{x:Static resx:ResUI.CustomServerTips}"
TextWrapping="Wrap" />
</StackPanel>
</Grid>
<Grid
x:Name="gridCustomOutbound"
Grid.Row="5"
Grid.Column="0"
Grid.ColumnSpan="3"
ColumnDefinitions="Auto,Auto,Auto"
IsVisible="False"
RowDefinitions="Auto,Auto,Auto">
<TextBlock
Grid.Row="0"
Grid.Column="0"
Margin="{StaticResource Margin4}"
VerticalAlignment="Center"
Text="Sing-Box Endpoint" />
<ToggleSwitch
x:Name="togDisplayLog"
x:Name="togSingBoxEndpoint"
Grid.Row="0"
Grid.Column="1"
Margin="{StaticResource Margin4}"
HorizontalAlignment="Left" />
<TextBlock
Margin="{StaticResource MarginLr8}"
VerticalAlignment="Center"
Text="{x:Static resx:ResUI.TipDisplayLog}" />
</StackPanel>
<TextBlock
Grid.Row="5"
Grid.Column="0"
Margin="{StaticResource Margin4}"
VerticalAlignment="Center"
Text="{x:Static resx:ResUI.TbPreSocksPort}" />
<TextBox
x:Name="txtPreSocksPort"
Grid.Row="5"
Grid.Column="1"
Width="200"
Margin="{StaticResource Margin4}"
HorizontalAlignment="Left" />
<StackPanel
Grid.Row="6"
Grid.Column="1"
Grid.ColumnSpan="2"
Margin="{StaticResource Margin4}">
<TextBlock
Grid.Row="1"
Grid.Column="1"
Grid.ColumnSpan="2"
Width="500"
VerticalAlignment="Center"
Text="{x:Static resx:ResUI.TipPreSocksPort}"
Text="{x:Static resx:ResUI.TbCustomOutboundTip}"
TextWrapping="Wrap" />
<TextBlock
Width="500"
Margin="{StaticResource MarginLr8}"
VerticalAlignment="Center"
Text="{x:Static resx:ResUI.CustomServerTips}"
TextWrapping="Wrap" />
</StackPanel>
</Grid>
</Grid>
</ScrollViewer>
</DockPanel>

View File

@@ -12,15 +12,19 @@ public partial class AddServer2Window : WindowBase<AddServer2ViewModel>
Loaded += Window_Loaded;
btnCancel.Click += (s, e) => Close();
cmbCoreType.ItemsSource = Utils.GetEnumNames<ECoreType>().Where(t => t != nameof(ECoreType.v2rayN)).ToList().AppendEmpty();
this.WhenActivated(disposables =>
{
this.WhenAnyValue(v => v.ViewModel.SelectedSource)
.KeepNotNull()
.Subscribe(InitializeData)
.DisposeWith(disposables);
this.Bind(ViewModel, vm => vm.SelectedSource.Remarks, v => v.txtRemarks.Text).DisposeWith(disposables);
this.Bind(ViewModel, vm => vm.SelectedSource.Address, v => v.txtAddress.Text).DisposeWith(disposables);
this.Bind(ViewModel, vm => vm.CoreType, v => v.cmbCoreType.SelectedValue).DisposeWith(disposables);
this.Bind(ViewModel, vm => vm.SelectedSource.DisplayLog, v => v.togDisplayLog.IsChecked).DisposeWith(disposables);
this.Bind(ViewModel, vm => vm.SelectedSource.PreSocksPort, v => v.txtPreSocksPort.Text).DisposeWith(disposables);
this.Bind(ViewModel, vm => vm.IsSingboxEndpoint, v => v.togSingBoxEndpoint.IsChecked).DisposeWith(disposables);
this.BindCommand(ViewModel, vm => vm.BrowseServerCmd, v => v.btnBrowse).DisposeWith(disposables);
this.BindCommand(ViewModel, vm => vm.EditServerCmd, v => v.btnEdit).DisposeWith(disposables);
@@ -34,6 +38,24 @@ public partial class AddServer2Window : WindowBase<AddServer2ViewModel>
});
}
private void InitializeData(ProfileItem profileItem)
{
if (profileItem.ConfigType is EConfigType.Custom)
{
Title = ResUI.menuAddCustomServer;
cmbCoreType.ItemsSource = Utils.GetEnumNames<ECoreType>().Where(t => t != nameof(ECoreType.v2rayN)).ToList();
gridCustomServer.IsVisible = true;
gridCustomOutbound.IsVisible = false;
}
else if (profileItem.ConfigType is EConfigType.Outbound)
{
Title = ResUI.menuAddCustomOutboundServer;
cmbCoreType.ItemsSource = Global.CoreTypes;
gridCustomServer.IsVisible = false;
gridCustomOutbound.IsVisible = true;
}
}
private void Window_Loaded(object? sender, RoutedEventArgs e)
{
txtRemarks.Focus();

View File

@@ -33,9 +33,11 @@
Header="{x:Static resx:ResUI.menuAddServerViaScan}"
InputGesture="Ctrl+S" />
<MenuItem x:Name="menuAddServerViaImage" Header="{x:Static resx:ResUI.menuAddServerViaImage}" />
<Separator />
<MenuItem x:Name="menuAddCustomServer" Header="{x:Static resx:ResUI.menuAddCustomServer}" />
<MenuItem x:Name="menuAddPolicyGroupServer" Header="{x:Static resx:ResUI.menuAddPolicyGroupServer}" />
<MenuItem x:Name="menuAddProxyChainServer" Header="{x:Static resx:ResUI.menuAddProxyChainServer}" />
<MenuItem x:Name="menuAddCustomOutboundServer" Header="{x:Static resx:ResUI.menuAddCustomOutboundServer}" />
<Separator />
<MenuItem x:Name="menuAddVmessServer" Header="{x:Static resx:ResUI.menuAddVmessServer}" />
<MenuItem x:Name="menuAddVlessServer" Header="{x:Static resx:ResUI.menuAddVlessServer}" />

View File

@@ -47,6 +47,7 @@ public partial class MainWindow : WindowBase<MainWindowViewModel>
this.BindCommand(ViewModel, vm => vm.AddAnytlsServerCmd, v => v.menuAddAnytlsServer).DisposeWith(disposables);
this.BindCommand(ViewModel, vm => vm.AddNaiveServerCmd, v => v.menuAddNaiveServer).DisposeWith(disposables);
this.BindCommand(ViewModel, vm => vm.AddCustomServerCmd, v => v.menuAddCustomServer).DisposeWith(disposables);
this.BindCommand(ViewModel, vm => vm.AddCustomOutboundServerCmd, v => v.menuAddCustomOutboundServer).DisposeWith(disposables);
this.BindCommand(ViewModel, vm => vm.AddPolicyGroupServerCmd, v => v.menuAddPolicyGroupServer).DisposeWith(disposables);
this.BindCommand(ViewModel, vm => vm.AddProxyChainServerCmd, v => v.menuAddProxyChainServer).DisposeWith(disposables);
this.BindCommand(ViewModel, vm => vm.AddServerViaClipboardCmd, v => v.menuAddServerViaClipboard).DisposeWith(disposables);

View File

@@ -34,7 +34,7 @@
</StackPanel>
<ScrollViewer HorizontalScrollBarVisibility="Auto" VerticalScrollBarVisibility="Auto">
<Grid ColumnDefinitions="Auto,400,Auto" RowDefinitions="Auto,Auto,Auto,Auto,Auto,Auto,Auto,Auto,Auto,Auto,Auto,Auto,Auto,Auto,Auto">
<Grid ColumnDefinitions="Auto,400,Auto" RowDefinitions="Auto,Auto,Auto,Auto,Auto,Auto,Auto,Auto,Auto,Auto,Auto,Auto,Auto,Auto,Auto,Auto">
<TextBlock
Grid.Row="0"
@@ -69,8 +69,8 @@
Grid.Column="1"
Margin="{StaticResource Margin4}"
VerticalAlignment="Center"
TextWrapping="Wrap"
PlaceholderText="{x:Static resx:ResUI.SubUrlTips}" />
PlaceholderText="{x:Static resx:ResUI.SubUrlTips}"
TextWrapping="Wrap" />
<Button
Grid.Row="2"
Grid.Column="2"
@@ -100,8 +100,8 @@
VerticalAlignment="Center"
Classes="TextArea"
MinLines="4"
TextWrapping="Wrap"
PlaceholderText="{x:Static resx:ResUI.SubUrlTips}" />
PlaceholderText="{x:Static resx:ResUI.SubUrlTips}"
TextWrapping="Wrap" />
</StackPanel>
</Flyout>
</Button.Flyout>
@@ -179,8 +179,8 @@
Grid.Column="1"
Margin="{StaticResource Margin4}"
VerticalAlignment="Center"
TextWrapping="Wrap"
PlaceholderText="{x:Static resx:ResUI.SubUrlTips}" />
PlaceholderText="{x:Static resx:ResUI.SubUrlTips}"
TextWrapping="Wrap" />
<TextBlock
Grid.Row="8"
@@ -241,31 +241,44 @@
Grid.Column="0"
Margin="{StaticResource Margin4}"
VerticalAlignment="Center"
Text="{x:Static resx:ResUI.TbPreSocksPort4Sub}" />
<TextBox
x:Name="txtPreSocksPort"
Text="{x:Static resx:ResUI.LvCustomCoreType}" />
<ComboBox
x:Name="cmbCustomCoreType"
Grid.Row="11"
Grid.Column="1"
Width="200"
Margin="{StaticResource Margin4}"
HorizontalAlignment="Left"
ToolTip.Tip="{x:Static resx:ResUI.TipPreSocksPort}"
PlaceholderText="{x:Static resx:ResUI.TipPreSocksPort}" />
HorizontalAlignment="Left" />
<TextBlock
Grid.Row="12"
Grid.Column="0"
Margin="{StaticResource Margin4}"
VerticalAlignment="Center"
Text="{x:Static resx:ResUI.TbPreSocksPort4Sub}" />
<TextBox
x:Name="txtPreSocksPort"
Grid.Row="12"
Grid.Column="1"
Width="200"
Margin="{StaticResource Margin4}"
HorizontalAlignment="Left"
PlaceholderText="{x:Static resx:ResUI.TipPreSocksPort}"
ToolTip.Tip="{x:Static resx:ResUI.TipPreSocksPort}" />
<TextBlock
Grid.Row="13"
Grid.Column="0"
Margin="{StaticResource Margin4}"
VerticalAlignment="Center"
Text="{x:Static resx:ResUI.LvMemo}" />
<TextBox
x:Name="txtMemo"
Grid.Row="12"
Grid.Row="13"
Grid.Column="1"
Margin="{StaticResource Margin4}"
VerticalAlignment="Center"
TextWrapping="Wrap" />
</Grid>
</ScrollViewer>
</DockPanel>

View File

@@ -12,6 +12,7 @@ public partial class SubEditWindow : WindowBase<SubEditViewModel>
btnCancel.Click += (s, e) => Close();
cmbConvertTarget.ItemsSource = Global.SubConvertTargets;
cmbCustomCoreType.ItemsSource = Utils.GetEnumNames<ECoreType>().Where(t => t != nameof(ECoreType.v2rayN)).ToList().AppendEmpty();
this.WhenActivated(disposables =>
{
@@ -28,6 +29,7 @@ public partial class SubEditWindow : WindowBase<SubEditViewModel>
this.Bind(ViewModel, vm => vm.SelectedSource.NextProfile, v => v.txtNextProfile.Text).DisposeWith(disposables);
this.Bind(ViewModel, vm => vm.SelectedSource.PreSocksPort, v => v.txtPreSocksPort.Text).DisposeWith(disposables);
this.Bind(ViewModel, vm => vm.SelectedSource.Memo, v => v.txtMemo.Text).DisposeWith(disposables);
this.Bind(ViewModel, vm => vm.CustomCoreType, v => v.cmbCustomCoreType.SelectedValue).DisposeWith(disposables);
this.BindCommand(ViewModel, vm => vm.SelectPrevProfileCmd, v => v.btnSelectPrevProfile).DisposeWith(disposables);
this.BindCommand(ViewModel, vm => vm.SelectNextProfileCmd, v => v.btnSelectNextProfile).DisposeWith(disposables);

View File

@@ -41,21 +41,116 @@
materialDesign:ScrollViewerAssist.IsAutoHideEnabled="True"
HorizontalScrollBarVisibility="Auto"
VerticalScrollBarVisibility="Auto">
<Grid>
<Grid.RowDefinitions>
<RowDefinition Height="Auto" />
<RowDefinition Height="Auto" />
<RowDefinition Height="Auto" />
<RowDefinition Height="Auto" />
<RowDefinition Height="Auto" />
<RowDefinition Height="Auto" />
<RowDefinition Height="Auto" />
<RowDefinition Height="Auto" />
</Grid.RowDefinitions>
<Grid Grid.Row="0">
<Grid.ColumnDefinitions>
<ColumnDefinition Width="Auto" />
<ColumnDefinition Width="Auto" />
<ColumnDefinition Width="Auto" />
</Grid.ColumnDefinitions>
<TextBlock
Grid.Row="0"
Grid.Column="0"
Margin="{StaticResource Margin4}"
Style="{StaticResource ModuleTitle}"
Text="{x:Static resx:ResUI.menuServers}" />
<TextBlock
Grid.Row="1"
Grid.Column="0"
Margin="{StaticResource Margin4}"
VerticalAlignment="Center"
Style="{StaticResource ToolbarTextBlock}"
Text="{x:Static resx:ResUI.TbRemarks}" />
<TextBox
x:Name="txtRemarks"
Grid.Row="1"
Grid.Column="1"
Width="400"
Margin="{StaticResource Margin4}"
VerticalAlignment="Center"
AcceptsReturn="True"
Style="{StaticResource MyOutlinedTextBox}" />
<TextBlock
Grid.Row="2"
Grid.Column="0"
Margin="{StaticResource Margin4}"
VerticalAlignment="Center"
Style="{StaticResource ToolbarTextBlock}"
Text="{x:Static resx:ResUI.TbAddress}" />
<TextBox
x:Name="txtAddress"
Grid.Row="2"
Grid.Column="1"
Width="400"
Margin="{StaticResource Margin4}"
VerticalAlignment="Center"
AcceptsReturn="True"
IsReadOnly="True"
Style="{StaticResource MyOutlinedTextBox}" />
<StackPanel
Grid.Row="2"
Grid.Column="2"
VerticalAlignment="Center"
Orientation="Horizontal">
<Button
x:Name="btnBrowse"
Margin="{StaticResource MarginLeftRight4}"
Content="{x:Static resx:ResUI.TbBrowse}"
Style="{StaticResource DefButton}" />
<Button
x:Name="btnEdit"
Margin="{StaticResource MarginLeftRight4}"
Content="{x:Static resx:ResUI.TbEdit}"
Style="{StaticResource DefButton}" />
</StackPanel>
<TextBlock
Grid.Row="3"
Grid.Column="0"
Margin="{StaticResource Margin4}"
VerticalAlignment="Center"
Style="{StaticResource ToolbarTextBlock}"
Text="{x:Static resx:ResUI.TbCoreType}" />
<ComboBox
x:Name="cmbCoreType"
Grid.Row="3"
Grid.Column="1"
Width="200"
Margin="{StaticResource Margin4}"
HorizontalAlignment="Left"
MaxDropDownHeight="1000"
Style="{StaticResource MyOutlinedTextComboBox}" />
<Separator
Grid.Row="4"
Grid.Column="0"
Grid.ColumnSpan="3"
Margin="0,2"
Style="{DynamicResource MaterialDesignSeparator}" />
<Grid
x:Name="gridCustomServer"
Grid.Row="5"
Grid.Column="0"
Grid.ColumnSpan="3">
<Grid.RowDefinitions>
<RowDefinition Height="Auto" />
<RowDefinition Height="Auto" />
<RowDefinition Height="Auto" />
<RowDefinition Height="Auto" />
<RowDefinition Height="Auto" />
<RowDefinition Height="Auto" />
<RowDefinition Height="Auto" />
<RowDefinition Height="Auto" />
</Grid.RowDefinitions>
<Grid.ColumnDefinitions>
<ColumnDefinition Width="Auto" />
@@ -67,87 +162,11 @@
Grid.Row="0"
Grid.Column="0"
Margin="{StaticResource Margin4}"
Style="{StaticResource ModuleTitle}"
Text="{x:Static resx:ResUI.menuServers}" />
<TextBlock
Grid.Row="1"
Grid.Column="0"
Margin="{StaticResource Margin4}"
VerticalAlignment="Center"
Style="{StaticResource ToolbarTextBlock}"
Text="{x:Static resx:ResUI.TbRemarks}" />
<TextBox
x:Name="txtRemarks"
Grid.Row="1"
Grid.Column="1"
Width="400"
Margin="{StaticResource Margin4}"
VerticalAlignment="Center"
AcceptsReturn="True"
Style="{StaticResource MyOutlinedTextBox}" />
<TextBlock
Grid.Row="2"
Grid.Column="0"
Margin="{StaticResource Margin4}"
VerticalAlignment="Center"
Style="{StaticResource ToolbarTextBlock}"
Text="{x:Static resx:ResUI.TbAddress}" />
<TextBox
x:Name="txtAddress"
Grid.Row="2"
Grid.Column="1"
Width="400"
Margin="{StaticResource Margin4}"
VerticalAlignment="Center"
AcceptsReturn="True"
IsReadOnly="True"
Style="{StaticResource MyOutlinedTextBox}" />
<StackPanel
Grid.Row="2"
Grid.Column="2"
VerticalAlignment="Center"
Orientation="Horizontal">
<Button
x:Name="btnBrowse"
Margin="{StaticResource MarginLeftRight4}"
Content="{x:Static resx:ResUI.TbBrowse}"
Style="{StaticResource DefButton}" />
<Button
x:Name="btnEdit"
Margin="{StaticResource MarginLeftRight4}"
Content="{x:Static resx:ResUI.TbEdit}"
Style="{StaticResource DefButton}" />
</StackPanel>
<TextBlock
Grid.Row="3"
Grid.Column="0"
Margin="{StaticResource Margin4}"
VerticalAlignment="Center"
Style="{StaticResource ToolbarTextBlock}"
Text="{x:Static resx:ResUI.TbCoreType}" />
<ComboBox
x:Name="cmbCoreType"
Grid.Row="3"
Grid.Column="1"
Width="200"
Margin="{StaticResource Margin4}"
HorizontalAlignment="Left"
MaxDropDownHeight="1000"
Style="{StaticResource MyOutlinedTextComboBox}" />
<TextBlock
Grid.Row="4"
Grid.Column="0"
Margin="{StaticResource Margin4}"
VerticalAlignment="Center"
Style="{StaticResource ToolbarTextBlock}"
Text="{x:Static resx:ResUI.TbDisplayLog}" />
<StackPanel
Grid.Row="4"
Grid.Row="0"
Grid.Column="1"
Margin="{StaticResource Margin4}"
Orientation="Horizontal">
@@ -160,7 +179,7 @@
</StackPanel>
<TextBlock
Grid.Row="5"
Grid.Row="1"
Grid.Column="0"
Margin="{StaticResource Margin4}"
VerticalAlignment="Center"
@@ -168,7 +187,7 @@
Text="{x:Static resx:ResUI.TbPreSocksPort}" />
<TextBox
x:Name="txtPreSocksPort"
Grid.Row="5"
Grid.Row="1"
Grid.Column="1"
Width="200"
Margin="{StaticResource Margin4}"
@@ -176,7 +195,7 @@
AcceptsReturn="True"
Style="{StaticResource MyOutlinedTextBox}" />
<StackPanel
Grid.Row="6"
Grid.Row="2"
Grid.Column="1"
Grid.ColumnSpan="2">
<TextBlock
@@ -194,6 +213,48 @@
TextWrapping="Wrap" />
</StackPanel>
</Grid>
<Grid
x:Name="gridCustomOutbound"
Grid.Row="6"
Grid.Column="0"
Grid.ColumnSpan="3"
Visibility="Collapsed">
<Grid.RowDefinitions>
<RowDefinition Height="Auto" />
<RowDefinition Height="Auto" />
<RowDefinition Height="Auto" />
</Grid.RowDefinitions>
<Grid.ColumnDefinitions>
<ColumnDefinition Width="Auto" />
<ColumnDefinition Width="Auto" />
<ColumnDefinition Width="Auto" />
</Grid.ColumnDefinitions>
<TextBlock
Grid.Row="0"
Grid.Column="0"
Margin="{StaticResource Margin4}"
VerticalAlignment="Center"
Style="{StaticResource ToolbarTextBlock}"
Text="Sing-Box Endpoint" />
<ToggleButton
x:Name="togSingBoxEndpoint"
Grid.Row="0"
Grid.Column="1"
Margin="{StaticResource Margin4}"
HorizontalAlignment="Left" />
<TextBlock
Grid.Row="1"
Grid.Column="1"
Grid.ColumnSpan="2"
Width="500"
VerticalAlignment="Center"
Style="{StaticResource ToolbarTextBlock}"
Text="{x:Static resx:ResUI.TbCustomOutboundTip}"
TextWrapping="Wrap" />
</Grid>
</Grid>
</ScrollViewer>
</DockPanel>

View File

@@ -12,11 +12,17 @@ public partial class AddServer2Window
this.WhenActivated(disposables =>
{
this.WhenAnyValue(v => v.ViewModel.SelectedSource)
.KeepNotNull()
.Subscribe(InitializeData)
.DisposeWith(disposables);
this.Bind(ViewModel, vm => vm.SelectedSource.Remarks, v => v.txtRemarks.Text).DisposeWith(disposables);
this.Bind(ViewModel, vm => vm.SelectedSource.Address, v => v.txtAddress.Text).DisposeWith(disposables);
this.Bind(ViewModel, vm => vm.CoreType, v => v.cmbCoreType.Text).DisposeWith(disposables);
this.Bind(ViewModel, vm => vm.SelectedSource.DisplayLog, v => v.togDisplayLog.IsChecked).DisposeWith(disposables);
this.Bind(ViewModel, vm => vm.SelectedSource.PreSocksPort, v => v.txtPreSocksPort.Text).DisposeWith(disposables);
this.Bind(ViewModel, vm => vm.IsSingboxEndpoint, v => v.togSingBoxEndpoint.IsChecked).DisposeWith(disposables);
this.BindCommand(ViewModel, vm => vm.BrowseServerCmd, v => v.btnBrowse).DisposeWith(disposables);
this.BindCommand(ViewModel, vm => vm.EditServerCmd, v => v.btnEdit).DisposeWith(disposables);
@@ -35,6 +41,24 @@ public partial class AddServer2Window
WindowsUtils.SetDarkBorder(this, AppManager.Instance.Config.UiItem.CurrentTheme);
}
private void InitializeData(ProfileItem profileItem)
{
if (profileItem.ConfigType is EConfigType.Custom)
{
Title = ResUI.menuAddCustomServer;
cmbCoreType.ItemsSource = Utils.GetEnumNames<ECoreType>().Where(t => t != nameof(ECoreType.v2rayN)).ToList();
gridCustomServer.Visibility = Visibility.Visible;
gridCustomOutbound.Visibility = Visibility.Collapsed;
}
else if (profileItem.ConfigType is EConfigType.Outbound)
{
Title = ResUI.menuAddCustomOutboundServer;
cmbCoreType.ItemsSource = Global.CoreTypes;
gridCustomServer.Visibility = Visibility.Collapsed;
gridCustomOutbound.Visibility = Visibility.Visible;
}
}
private void Window_Loaded(object sender, RoutedEventArgs e)
{
txtRemarks.Focus();

View File

@@ -70,6 +70,7 @@
x:Name="menuAddServerViaImage"
Height="{StaticResource MenuItemHeight}"
Header="{x:Static resx:ResUI.menuAddServerViaImage}" />
<Separator Margin="-40,5" />
<MenuItem
x:Name="menuAddCustomServer"
Height="{StaticResource MenuItemHeight}"
@@ -82,6 +83,10 @@
x:Name="menuAddProxyChainServer"
Height="{StaticResource MenuItemHeight}"
Header="{x:Static resx:ResUI.menuAddProxyChainServer}" />
<MenuItem
x:Name="menuAddCustomOutboundServer"
Height="{StaticResource MenuItemHeight}"
Header="{x:Static resx:ResUI.menuAddCustomOutboundServer}" />
<Separator Margin="-40,5" />
<MenuItem
x:Name="menuAddVmessServer"

View File

@@ -47,6 +47,7 @@ public partial class MainWindow
this.BindCommand(ViewModel, vm => vm.AddAnytlsServerCmd, v => v.menuAddAnytlsServer).DisposeWith(disposables);
this.BindCommand(ViewModel, vm => vm.AddNaiveServerCmd, v => v.menuAddNaiveServer).DisposeWith(disposables);
this.BindCommand(ViewModel, vm => vm.AddCustomServerCmd, v => v.menuAddCustomServer).DisposeWith(disposables);
this.BindCommand(ViewModel, vm => vm.AddCustomOutboundServerCmd, v => v.menuAddCustomOutboundServer).DisposeWith(disposables);
this.BindCommand(ViewModel, vm => vm.AddPolicyGroupServerCmd, v => v.menuAddPolicyGroupServer).DisposeWith(disposables);
this.BindCommand(ViewModel, vm => vm.AddProxyChainServerCmd, v => v.menuAddProxyChainServer).DisposeWith(disposables);
this.BindCommand(ViewModel, vm => vm.AddServerViaClipboardCmd, v => v.menuAddServerViaClipboard).DisposeWith(disposables);

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" />
@@ -299,10 +300,25 @@
Margin="{StaticResource Margin4}"
VerticalAlignment="Center"
Style="{StaticResource ToolbarTextBlock}"
Text="{x:Static resx:ResUI.LvCustomCoreType}" />
<ComboBox
x:Name="cmbCustomCoreType"
Grid.Row="11"
Grid.Column="1"
Margin="{StaticResource Margin4}"
MaxDropDownHeight="1000"
Style="{StaticResource MyOutlinedTextComboBox}" />
<TextBlock
Grid.Row="12"
Grid.Column="0"
Margin="{StaticResource Margin4}"
VerticalAlignment="Center"
Style="{StaticResource ToolbarTextBlock}"
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 +328,7 @@
ToolTip="{x:Static resx:ResUI.TipPreSocksPort}" />
<TextBlock
Grid.Row="12"
Grid.Row="13"
Grid.Column="0"
Margin="{StaticResource Margin4}"
VerticalAlignment="Center"
@@ -320,7 +336,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

@@ -9,6 +9,7 @@ public partial class SubEditWindow
Loaded += Window_Loaded;
cmbConvertTarget.ItemsSource = Global.SubConvertTargets;
cmbCustomCoreType.ItemsSource = Utils.GetEnumNames<ECoreType>().Where(t => t != nameof(ECoreType.v2rayN)).ToList().AppendEmpty();
this.WhenActivated(disposables =>
{
@@ -25,6 +26,7 @@ public partial class SubEditWindow
this.Bind(ViewModel, vm => vm.SelectedSource.NextProfile, v => v.txtNextProfile.Text).DisposeWith(disposables);
this.Bind(ViewModel, vm => vm.SelectedSource.PreSocksPort, v => v.txtPreSocksPort.Text).DisposeWith(disposables);
this.Bind(ViewModel, vm => vm.SelectedSource.Memo, v => v.txtMemo.Text).DisposeWith(disposables);
this.Bind(ViewModel, vm => vm.CustomCoreType, v => v.cmbCustomCoreType.Text).DisposeWith(disposables);
this.BindCommand(ViewModel, vm => vm.SelectPrevProfileCmd, v => v.btnSelectPrevProfile).DisposeWith(disposables);
this.BindCommand(ViewModel, vm => vm.SelectNextProfileCmd, v => v.btnSelectNextProfile).DisposeWith(disposables);