Compare commits

..

46 Commits

Author SHA1 Message Date
2dust 77c462ec7b up 7.25.1 2026-09-10 09:43:13 +08:00
2dust 526cd6834d Show language names in theme settings 2026-09-10 09:40:16 +08:00
Jamal Ali 7be23d7ebc Add Azerbaijani (az) UI localization (#10141) 2026-09-10 09:18:00 +08:00
tt2563 d952c180c9 Update ResUI.zh-Hant.resx (#10139)
Update Traditional Chinese translation
2026-09-10 09:15:00 +08:00
2dust 80fa6575a3 Bug fix 2026-09-09 14:33:31 +08:00
jryx0 f05e44d7d9 优化策略组选择 (#10136)
Co-authored-by: 张皓 <hao.zhang@gmail.com>
2026-09-09 14:30:21 +08:00
稔君 b10c16a94a Add custom HTTP headers for subscription updates (#10118)
* Add custom HTTP headers for subscription updates

* Res

---------

Co-authored-by: 2dust <31833384+2dust@users.noreply.github.com>
2026-09-09 14:24:05 +08:00
DHR60 143ada1358 Remove vnext (#10132) 2026-09-09 13:59:52 +08:00
2dust d8c7bd9d02 Bug fix
https://github.com/2dust/v2rayN/issues/10107
2026-09-09 13:55:31 +08:00
dependabot[bot] 85666af36a Bump ReactiveUI.Avalonia from 12.1.1 to 12.1.2 (#10133)
---
updated-dependencies:
- dependency-name: ReactiveUI.Avalonia
  dependency-version: 12.1.2
  dependency-type: direct:production
  update-type: version-update:semver-patch
...

Signed-off-by: dependabot[bot] <support@github.com>
Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>
2026-09-09 09:22:47 +08:00
DHR60 7286c67a78 Display more information for Connections (#10116)
* Display sniff host

* Display process path
2026-09-09 09:22:30 +08:00
2dust 8b8e579bc8 Bug fix
https://github.com/2dust/v2rayN/issues/10113
2026-09-08 20:32:26 +08:00
dependabot[bot] ed4dd7f6d9 Bump TUnit from 1.65.68 to 1.66.27 (#10127)
---
updated-dependencies:
- dependency-name: TUnit
  dependency-version: 1.66.27
  dependency-type: direct:production
  update-type: version-update:semver-minor
...

Signed-off-by: dependabot[bot] <support@github.com>
Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>
2026-09-08 19:59:09 +08:00
CrayzyCray bb62c9eda5 Enable auto decompression for string downloads (#10122) 2026-09-08 19:58:55 +08:00
2dust 3d50d9f82c Use profile IDs for server move operations
Refactor `ConfigHandler.MoveServer` to accept a `List<string>` of profile `IndexId`s instead of full `ProfileItem` objects. In `ProfilesViewModel`, remove the duplicated `_lstProfile` cache and build move lists directly from `ProfileItems`, so move and drag-reorder actions use the live UI order and avoid stale profile snapshots.
2026-09-06 10:31:45 +08:00
2dust d05c3439f8 up 7.25.0 2026-09-05 09:55:32 +08:00
2dust cb5ae8c900 Update Directory.Packages.props 2026-09-05 09:51:51 +08:00
2dust ec6a7b3db2 Shorten log/temp cleanup retention to 7 days
Update scheduled file cleanup in `TaskManager` to delete log and temp files older than 7 days instead of 1 month, reducing stale local data and tightening routine maintenance behavior.
2026-09-04 15:50:06 +08:00
2dust 99328a14d2 Reset tab content when switching layouts
Clear all auxiliary tab contents before applying a new grid orientation to prevent stale view reuse, and remove the delayed tab-index workaround. Also treat `ProfilesViewModel` as a transient view so its view is recreated like other dynamic tabs.
2026-09-04 15:31:04 +08:00
2dust 8ebbd7f4f8 Cache selected views in SimpleViewLocator
Add a `ConditionalWeakTable`-based view cache keyed by view model instance for `MsgViewModel`, `ClashProxiesViewModel`, and `ClashConnectionsViewModel` so repeated bindings reuse the same control. The locator now also handles missing registrations and null factory results more explicitly by returning descriptive `TextBlock` messages.
2026-09-04 15:14:00 +08:00
DHR60 b8e8321603 Fix ViewModel and UI (#10093)
* Fix avalonia ScrollToEnd

* Refactor ViewModel
2026-09-04 14:46:41 +08:00
Aredelya fe2e219b7a Expose the mux multiplexing settings in the Core settings UI (#10055)
* Exposed the mux multiplexing settings in the core settings UI. Now it's possible to change the default concurrency value of 8 without editing config.json.

* Adjust

* Adjust

---------

Co-authored-by: 2dust <31833384+2dust@users.noreply.github.com>
2026-09-04 11:48:42 +08:00
patterniha df32b4d6cc Add an option to check for update without proxy (#10090)
Checking for updates always dialed through the local proxy, which fails
exactly when an update may be needed most - when no working server is
available. Like the subscription-update menu, updating without the proxy
is now possible: the Check Update window gets an "Update via proxy"
toggle (persisted, on by default so existing behavior is unchanged),
honored by the check-only, update and geo-file flows, and by the
scheduled update check.

Co-authored-by: Claude Fable 5 <noreply@anthropic.com>
2026-09-04 11:05:10 +08:00
稔君 9451b69246 Fix subscription requests missing Accept header (#10095) 2026-09-04 10:47:36 +08:00
DHR60 4a59c7f4d6 Lock sing-box max version (#10091)
* Refactor Semantic Version

* Lock max version

* Allow sing-box update to pre-release
2026-09-04 10:17:31 +08:00
dependabot[bot] fed59a7313 Bump Avalonia.Desktop from 12.1.1 to 12.1.2 (#10089)
---
updated-dependencies:
- dependency-name: Avalonia.Desktop
  dependency-version: 12.1.2
  dependency-type: direct:production
  update-type: version-update:semver-patch
...

Signed-off-by: dependabot[bot] <support@github.com>
Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>
2026-09-03 20:22:09 +08:00
DHR60 093b8d4c34 Adjust Clash API (#10088) 2026-09-03 20:21:46 +08:00
Loukky f03db020fa feat: update node IP and delay info on connect (#10050)
* feat: update node IP and delay info on connect

* Adjust

---------

Co-authored-by: 2dust <31833384+2dust@users.noreply.github.com>
2026-09-02 16:46:15 +08:00
DHR60 f62eaab8c5 Support sing-box 1.14 (#9736)
* Support sing-box 1.14

* Unified format

* Support parallel dns

* Add `optimistic DNS caching` support

* Try fix serial multi DNS

* Add HttpClient support
2026-09-02 11:54:06 +08:00
DHR60 1bf3d14771 Fix UI (#10066) 2026-09-02 11:28:55 +08:00
liuclare 64aad8027a Skip the TUN IPv6 route when the host has no global IPv6 address (#10080)
#9930 made the Xray TUN inbound always request ::/0 in autoSystemRoutingTable
so that IPv6 stops bypassing the tunnel. That only helps a host which actually
holds a globally routable IPv6 address. On any other host it does harm.

With IPv6 disabled the TUN device gets no IPv6 address at all, the kernel
rejects the route with EACCES and the whole inbound fails to start:

  Failed to start: app/proxyman/inbound: failed to start proxy > proxy/tun:
  failed to add system route ::/0 > permission denied

With IPv6 enabled but no global address the route is accepted and the host
gains an IPv6 default route it cannot use. The TUN completes the TCP handshake
locally before dialing the outbound, so IPv6 destinations start to look
reachable and get picked, and the connection then dies at the outbound instead
of failing fast (#10051).

Neither host has IPv6 traffic that could bypass the tunnel, so ::/0 buys them
nothing. Detect a global IPv6 address once while building the config context
and drop ::/0 when there is none. Link-local and unique local addresses do not
count: they never reach the IPv6 internet.

Co-authored-by: liuclare <177657698+liuclare@users.noreply.github.com>
Co-authored-by: Claude Opus 5 <noreply@anthropic.com>
2026-09-02 11:17:27 +08:00
DHR60 7870ae89cf Move "GLOBAL" to the end (#10064) 2026-08-31 18:00:44 +08:00
2dust 521230c40d up 7.24.9 2026-08-29 10:46:53 +08:00
Miheichev Aleksandr Sergeevich 426d5d5a21 ci: run the test job on pushes to master (#10054)
The Code Test workflow only has a pull_request trigger, so the suite
never runs against master itself: a direct push is untested, and a
merge can break tests even when the pull request's own check was
green, because checks run on the PR head rather than on the merged
result. Add a push trigger for master with the same paths filter.

Co-authored-by: Claude Fable 5 <noreply@anthropic.com>
2026-08-29 09:42:40 +08:00
2dust 3f14c86a04 Trigger routing migration when rules exist 2026-08-28 15:00:19 +08:00
dependabot[bot] 70b8ecd3c8 Bump TUnit from 1.65.63 to 1.65.68 (#10052)
---
updated-dependencies:
- dependency-name: TUnit
  dependency-version: 1.65.68
  dependency-type: direct:production
  update-type: version-update:semver-patch
...

Signed-off-by: dependabot[bot] <support@github.com>
Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>
2026-08-28 14:38:57 +08:00
DHR60 0d452328a2 Add wireguard remote dns support (#10036) 2026-08-28 14:38:43 +08:00
Miheichev Aleksandr Sergeevich 2a65e5ac80 fix(fmt): stop share-URI query values from being decoded twice or dropped (#10027)
`Utils.ParseQueryString` already unescapes every value, and
`BaseFmt.GetQueryDecoded` unescaped it again. A value that still held a
valid percent sequence after the first pass decayed on the second: an
obfuscation password of `ob%41fs` is exported as `ob%2541fs` and imported
back as `obAfs`. Only well-formed sequences are affected, which is why the
damage is silent - `100%` and `66%ff` survive untouched.

The same function also split each pair on every `=`, and skipped the pair
unless exactly two halves came out. RFC 3986 lists `=` among the
sub-delimiters a query value may carry, so only the first one separates the
key from the value, and `HttpUtility.ParseQueryString` reads a query string
the same way. Splitting on all of them discarded a syntactically valid
pair: `?ech=AAj+DQAEAAAAAA==` was lost entirely, and so was a `plugin`
value in the non-canonical SIP002 spelling, since those are `;` separated
`key=value` lists.

v2rayN percent-encodes both on export, so its own links were never
affected; what changes is that the parser now follows the grammar instead
of discarding a pair it cannot split in two.

Splitting on the first `=` only, and reading the value the parser already
decoded, fixes both. `ParseQueryString` keeps decoding because
`ConfigHandler` reads its result directly.

`GetQueryDecoded` and `GetQueryValue` are now equivalent; they are left
separate to keep this change small, and can be collapsed if you prefer.
2026-08-28 14:38:02 +08:00
Miheichev Aleksandr Sergeevich f332caf507 fix(hysteria2): default the port to 443 when the share URI omits it (#10026)
The Hysteria2 URI scheme makes the port optional: "The hostname and
optional port of the server. If the port is omitted, it defaults to 443."
`Hysteria2Fmt.Resolve` assigned `url.Port` straight through, and
`System.Uri` answers -1 for an unregistered scheme that carries no port,
so `hysteria2://password@hy2.example/` imported as a profile with
`Port = -1`. `ProfileItem.IsValid` rejects any port outside 1..65535, so
such a link produced a profile that could never be used, and nothing said
why.

-1 is the only value that means "the port was omitted"; a ':' with no
digits after it maps to -1 as well. An explicit ":0" parses as 0 and
keeps the fate it has today - rejected by `IsValid` - rather than being
redirected to a server the link never named.

`ResolveRealm` takes its port from `HyRealm.RendezvousPort` instead of
the URI, so it is unaffected.

The added tests cover both spellings of the scheme, with and without a
trailing slash, a bare ':', and the resulting profile's validity. Two of
them are controls: an explicit port is still preserved, and an explicit
":0" still does not turn into 443.
2026-08-28 14:33:05 +08:00
Miheichev Aleksandr Sergeevich 4b412e246f test: cover the share-URI round trip for the remaining protocols (#10017)
`FmtHandlerTests` round-tripped VMess, VLESS, Shadowsocks and SOCKS.
`FmtHandler.GetShareUri` dispatches ten protocols, so Trojan, Hysteria2,
TUIC, Anytls, WireGuard and Naive were exported and re-imported untested,
and `WireguardFmt` was covered in the `Resolve` direction only.

Each new test exports a profile, imports the result and asserts the fields
that protocol carries in its URI: the flow for Trojan, the uuid/password
pair and the congestion control for TUIC, the obfuscation password and the
port range for Hysteria2, the peer keys, reserved bytes, interface address
and MTU for WireGuard, and the credentials plus the insecure concurrency
for Naive.

`ShareUriSuite_ShouldCoverAndRoundTripEveryExportableProtocol` compares the
profile-factory map against `Global.ProtocolShares` and round-trips every
entry, so a newly exportable protocol cannot be added without a case here.

Three things a round trip alone cannot prove are asserted on the wire form
instead. The allow-insecure flag is spelled per protocol since #9888 -
`allowInsecure` and `insecure` for Trojan, `allow_insecure` for TUIC,
`insecure` for Anytls and Hysteria2 - so an exporter and an importer that
agreed on the wrong name would otherwise round-trip cleanly. The WireGuard
test pins the percent-encoding of the base64 keys and the brackets around
the IPv6 literal. Hysteria2 keeps `CertSha` unset on purpose: the importer
turns `AllowInsecure` on by itself when a `pinSHA256` is present, which
would mask an exporter that stopped emitting `insecure=1`.

Fixtures are deterministic and no longer plain ASCII: a fixed uuid for
TUIC, real 32-byte base64 keys and an IPv6 address for WireGuard, and
reserved and non-Latin characters in passwords and remarks.

`ExportThenImport` derives the expected scheme, because `NaiveFmt` emits
`naive+https://` or `naive+quic://` and never the `naive://` prefix that
`Global.ProtocolShares` records for that type - that entry is only read
when importing.
2026-08-28 14:27:50 +08:00
Miheichev Aleksandr Sergeevich 09ac4d181a ci: run the test job on its own inputs and trim its checkout (#10016)
Follow-ups to #10007, none of which change what the test command does.

The `paths:` filter listed individual source folders rather than the
projects the suite builds, so a change anywhere else in that build
produced no check at all. Of the last 40 merged pull requests, 31 touch
the test build and 15 of those ran no tests. #9976 and #9932 edit
`ServiceLib/Common/`, which every test depends on, and neither shows a
single check. `Directory.Build.props` is the sharpest case: it sets
`TargetFramework` and the Release options for every project, so an edit
there can break the test build without producing a workflow run at all.

The filter now follows the project graph instead. `ServiceLib.Tests`
references `ServiceLib`, which references `ServiceLib.UdpTest`, and
`Directory.Build.*`, `Directory.Packages.props` and `global.json`
configure that build. It is shorter than the list it replaces, needs no
edit when a test is added or a class moves, and still skips UI and
documentation work: of those same 40 pull requests, 9 stay filtered out.

`global.json` belongs there for a different reason: it does not affect a
single line of the code under test, but it decides whether the tests run
at all, so a bad edit there silently reproduces the failure #10007 fixed.

The Checkout step needs neither `submodules: 'recursive'` nor
`fetch-depth: '0'`. `GlobalHotKeys` is pulled in by `v2rayN.Desktop`
only, and nothing in this job reads git history.

`global.json` also gained the final newline `.editorconfig` asks for with
`insert_final_newline = true` under `[*]`.
2026-08-28 14:27:11 +08:00
Miheichev Aleksandr Sergeevich c575527725 i18n(ru): translate newly added UI strings (#10015)
* i18n(ru): translate newly added UI strings

Translate the 3 strings missing from ResUI.ru.resx after the DNS
"Block AAAA Queries" toggle and the Xray-only certificate pinning
hint were introduced:

- TbXrayOnly, TbBlockAAAAQueries, TbBlockAAAAQueriesTips

Translated from the zh-Hans source and cross-checked against the
English resource. Russian regains full key parity with ResUI.resx
(583/583), and key ordering mirrors the English resource file.
Xray and the AAAA record type stay untranslated, matching the
established glossary; the phrasing follows the neighbouring
TbBlockSVCBHTTPSQueries label and the existing "При включении" tip pattern.

Co-authored-by: Claude Opus 5 <noreply@anthropic.com>

* i18n(ru): translate TbBlockSVCBHTTPSQueriesTips left in English

The key existed in ResUI.ru.resx but its value was the untranslated
English text, so the DNS settings window mixed Russian and English
in the row right above the newly translated "Block AAAA Queries".

Translated from the zh-Hans source, which says "availability
queries" (可用性查询); the Russian follows that wording rather than
the English "checks". ECH, HTTP/3 and Xray stay untranslated,
matching the established glossary, and the tip keeps the
"При включении …" pattern used by the surrounding tips.

Co-authored-by: Claude Opus 5 <noreply@anthropic.com>

---------

Co-authored-by: Claude Opus 5 <noreply@anthropic.com>
2026-08-28 14:26:02 +08:00
2dust 589ccc6e06 Bug fix
https://github.com/2dust/v2rayN/issues/10043
2026-08-28 14:24:12 +08:00
DHR60 0a2f3e4f22 Fix hy2 fmt (#10044) 2026-08-27 15:31:39 +08:00
dependabot[bot] 007adb6403 Bump TUnit from 1.65.38 to 1.65.63 (#10037)
---
updated-dependencies:
- dependency-name: TUnit
  dependency-version: 1.65.63
  dependency-type: direct:production
  update-type: version-update:semver-patch
...

Signed-off-by: dependabot[bot] <support@github.com>
Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>
2026-08-26 19:42:53 +08:00
DHR60 e50c0a9170 Fix (#10035)
* Fix

* Fix

* Use HashSet instead of List

* Use HashSet instead of List for sing-box
2026-08-26 19:41:52 +08:00
122 changed files with 4986 additions and 1401 deletions
+17 -5
View File
@@ -1,12 +1,27 @@
name: Code Test
on:
push:
branches:
- master
paths:
- 'v2rayN/ServiceLib/**'
- 'v2rayN/ServiceLib.UdpTest/**'
- 'v2rayN/ServiceLib.Tests/**'
- 'v2rayN/Directory.Build.*'
- 'v2rayN/Directory.Packages.props'
- 'global.json'
- '.github/workflows/test.yml'
pull_request:
branches:
- master
paths:
- 'v2rayN/ServiceLib/Services/CoreConfig/**'
- 'v2rayN/ServiceLib/Handler/Fmt/**'
- 'v2rayN/ServiceLib/**'
- 'v2rayN/ServiceLib.UdpTest/**'
- 'v2rayN/ServiceLib.Tests/**'
- 'v2rayN/Directory.Build.*'
- 'v2rayN/Directory.Packages.props'
- 'global.json'
- '.github/workflows/test.yml'
permissions:
@@ -19,9 +34,6 @@ jobs:
steps:
- name: Checkout
uses: actions/checkout@v7
with:
submodules: 'recursive'
fetch-depth: '0'
- name: Setup .NET
uses: actions/setup-dotnet@v6
+1 -1
View File
@@ -2,4 +2,4 @@
"test": {
"runner": "Microsoft.Testing.Platform"
}
}
}
+1 -1
View File
@@ -1,7 +1,7 @@
<Project>
<PropertyGroup>
<Version>7.24.8</Version>
<Version>7.25.1</Version>
</PropertyGroup>
<PropertyGroup>
+6 -6
View File
@@ -7,19 +7,19 @@
<ItemGroup>
<PackageVersion Include="Avalonia.AvaloniaEdit" Version="12.0.0" />
<PackageVersion Include="Avalonia.Controls.DataGrid" Version="12.1.2" />
<PackageVersion Include="Avalonia.Desktop" Version="12.1.1" />
<PackageVersion Include="Avalonia.Desktop" Version="12.1.2" />
<PackageVersion Include="AvaloniaUI.DiagnosticsSupport" Version="2.2.3" />
<PackageVersion Include="DialogHost.Avalonia" Version="0.12.3" />
<PackageVersion Include="IPNetwork2" Version="4.3.0" />
<PackageVersion Include="ReactiveUI.Avalonia" Version="12.1.1" />
<PackageVersion Include="ReactiveUI.Avalonia" Version="12.1.2" />
<PackageVersion Include="CliWrap" Version="3.10.5" />
<PackageVersion Include="Downloader" Version="5.9.5" />
<PackageVersion Include="Downloader" Version="5.9.6" />
<PackageVersion Include="H.NotifyIcon.Wpf" Version="2.4.1" />
<PackageVersion Include="MaterialDesignThemes" Version="5.3.2" />
<PackageVersion Include="QRCoder" Version="1.8.0" />
<PackageVersion Include="ReactiveUI" Version="24.1.0" />
<PackageVersion Include="ReactiveUI" Version="24.2.0" />
<PackageVersion Include="ReactiveUI.SourceGenerators" Version="3.2.0" />
<PackageVersion Include="ReactiveUI.WPF" Version="24.1.0" />
<PackageVersion Include="ReactiveUI.WPF" Version="24.2.0" />
<PackageVersion Include="Semi.Avalonia" Version="12.1.0.1" />
<PackageVersion Include="Semi.Avalonia.AvaloniaEdit" Version="12.0.0" />
<PackageVersion Include="Semi.Avalonia.DataGrid" Version="12.1.0.1" />
@@ -27,7 +27,7 @@
<PackageVersion Include="sqlite-net-e" Version="1.11.285" />
<PackageVersion Include="Repobot.SQLite.Unofficial" Version="3.53.4.1" />
<PackageVersion Include="TaskScheduler" Version="2.12.2" />
<PackageVersion Include="TUnit" Version="1.65.38" />
<PackageVersion Include="TUnit" Version="1.66.27" />
<PackageVersion Include="TUnit.Assertions.Should" Version="1.65.38-beta" />
<PackageVersion Include="WebDav.Client" Version="2.9.0" />
<PackageVersion Include="YamlDotNet" Version="18.1.0" />
@@ -205,7 +205,8 @@ internal static class CoreConfigTestFactory
return node;
}
public static CoreConfigContext CreateContext(Config config, ProfileItem node, ECoreType runCoreType)
public static CoreConfigContext CreateContext(Config config, ProfileItem node, ECoreType runCoreType,
bool hasGlobalIPv6Address = true)
{
return new CoreConfigContext
{
@@ -226,6 +227,7 @@ internal static class CoreConfigTestFactory
FullConfigTemplate = null,
IsTunEnabled = config.TunModeItem.EnableTun,
ProtectDomainList = [],
HasGlobalIPv6Address = hasGlobalIPv6Address,
};
}
@@ -378,50 +378,6 @@ public class CoreConfigSingboxServiceTests
await hasRouteRule.Should().BeTrue();
}
[Test]
public async Task GenerateClientConfigContent_DirectExpectedIPs_ShouldApplyGeoipAndCidrToDirectDnsRule()
{
var config = CoreConfigTestFactory.CreateConfigWithDirectExpectedIPs(
ECoreType.sing_box,
"192.168.0.0/16,geoip:cn");
CoreConfigTestFactory.BindAppManagerConfig(config);
var node = CoreConfigTestFactory.CreateSocksNode(ECoreType.sing_box, "n-main", "main");
var context = CoreConfigTestFactory.CreateContext(config, node, ECoreType.sing_box) with
{
RoutingItem = new RoutingItem
{
Id = "r-dns-direct-expected",
Remarks = "dns-direct-expected",
RuleSet = JsonUtils.Serialize(new List<RulesItem>
{
new()
{
Enabled = true,
RuleType = ERuleType.DNS,
OutboundTag = Global.DirectTag,
Domain = ["geosite:cn"],
}
}),
DomainStrategy = Global.AsIs,
DomainStrategy4Singbox = string.Empty,
}
};
var result = new CoreConfigSingboxService(context).GenerateClientConfigContent();
await result.Success.Should().BeTrue().Because($"ret msg: {result.Msg}");
var cfg = JsonUtils.Deserialize<SingboxConfig>(result.Data!.ToString())!;
var hasExpectedRule = cfg.dns.rules?.Any(r =>
r.server == Global.SingboxDirectDNSTag
&& r.ip_cidr?.Contains("192.168.0.0/16") == true
&& r.rule_set?.Contains("geosite-cn") == true
&& r.rule_set?.Contains("geoip-cn") == true) ?? false;
await hasExpectedRule.Should().BeTrue();
}
[Test]
public async Task GenerateClientConfigContent_BootstrapDNS_ShouldConfigurePureIPResolver()
{
@@ -544,14 +500,18 @@ public class CoreConfigSingboxServiceTests
Remarks = "dns-direct-variant",
RuleSet = JsonUtils.Serialize(new List<RulesItem>
{
new()
{
Enabled = true, RuleType = ERuleType.DNS, OutboundTag = Global.ProxyTag, Domain = ["geosite:google"],
},
new()
{
Enabled = true, RuleType = ERuleType.DNS, OutboundTag = Global.DirectTag, Domain = [domainTag],
}
},
}),
DomainStrategy = Global.AsIs,
DomainStrategy4Singbox = string.Empty,
}
},
};
var result = new CoreConfigSingboxService(context).GenerateClientConfigContent();
@@ -559,11 +519,14 @@ public class CoreConfigSingboxServiceTests
await result.Success.Should().BeTrue().Because($"ret msg: {result.Msg}");
var cfg = JsonUtils.Deserialize<SingboxConfig>(result.Data!.ToString())!;
var hasExpectedRule = cfg.dns.rules?.Any(r =>
r.server == Global.SingboxDirectDNSTag
&& r.ip_cidr?.Contains("192.168.0.0/16") == true
&& r.rule_set?.Contains(expectedRuleSetTag) == true
&& r.rule_set?.Contains("geoip-cn") == true) ?? false;
var hasExpectedRule = (cfg.dns.rules?.Any(r =>
r.ip_cidr?.Contains("192.168.0.0/16") == true
&& r.action == "respond"
&& r.rule_set?.Contains("geoip-cn") == true) ?? false)
&& (cfg.dns.rules?.Any(r =>
r.server == Global.SingboxDirectDNSTag
&& r.rule_set?.Contains(expectedRuleSetTag) == true
&& r.action == "evaluate") ?? false);
await hasExpectedRule.Should().BeTrue();
}
@@ -632,6 +595,60 @@ public class CoreConfigSingboxServiceTests
await cfg.dns.rules.Should().Contain(r => r.clash_mode == nameof(ERuleMode.Direct));
}
[Test]
[Arguments(true)]
[Arguments(false)]
public async Task GenerateClientConfigContent_MultiDnsServers_ShouldIncludeAllServers(bool parallelQuery)
{
var config =
CoreConfigTestFactory.CreateConfig(ECoreType.sing_box);
config.SimpleDNSItem.ParallelQuery = parallelQuery;
config.SimpleDNSItem.DirectDNS = "1.1.1.1,2.2.2.2";
config.SimpleDNSItem.RemoteDNS = "3.3.3.3,4.4.4.4";
CoreConfigTestFactory.BindAppManagerConfig(config);
var node = CoreConfigTestFactory.CreateSocksNode(ECoreType.sing_box, "n-main", "main");
var context = CoreConfigTestFactory.CreateContext(config, node, ECoreType.sing_box) with
{
RoutingItem = new RoutingItem
{
Id = "r-dns-direct-variant",
Remarks = "dns-direct-variant",
RuleSet = JsonUtils.Serialize(new List<RulesItem>
{
new()
{
Enabled = true,
RuleType = ERuleType.DNS,
OutboundTag = Global.ProxyTag,
Domain = ["geosite:google"],
},
new()
{
Enabled = true,
RuleType = ERuleType.DNS,
OutboundTag = Global.DirectTag,
Domain = ["geosite:cn"],
},
}),
DomainStrategy = Global.AsIs,
DomainStrategy4Singbox = string.Empty,
},
};
var result = new CoreConfigSingboxService(context).GenerateClientConfigContent();
await result.Success.Should().BeTrue().Because($"ret msg: {result.Msg}");
var cfg = JsonUtils.Deserialize<SingboxConfig>(result.Data!.ToString())!;
await cfg.dns.servers.Where(s => s.tag.StartsWith(Global.SingboxDirectDNSTagPrefix)).Should().HaveCount(2);
await cfg.dns.servers.Where(s => s.tag.StartsWith(Global.SingboxRemoteDNSTagPrefix)).Should().HaveCount(2);
if (parallelQuery)
{
await cfg.dns.rules.Should().Contain(r => r.race == true);
}
}
[Test]
public async Task GenerateClientConfigContent_Hysteria2Realm_ShouldEmitHttpsServerUrl()
{
@@ -50,8 +50,6 @@ public class CoreConfigV2rayServiceTests
await headers["Set-Cookie"]!.AsArray()
.Select(item => item!.GetValue<string>())
.Should().BeEquivalentTo(["a=1", "b=2"]);
await outbound.settings.servers.Should().BeNull();
await outbound.settings.vnext.Should().BeNull();
}
[Test]
@@ -588,6 +586,53 @@ public class CoreConfigV2rayServiceTests
await tunInbound.settings.gateway.Should().HaveCount(enableIPv6Address ? 2 : 1);
}
[Test]
[Arguments(true)]
[Arguments(false)]
public async Task GenerateClientConfigContent_Tun_ShouldSkipIPv6RouteWithoutGlobalIPv6(bool enableIPv6Address)
{
// A host without a global IPv6 address has no IPv6 traffic that could bypass the tunnel,
// while ::/0 would pull IPv6 attempts into a tunnel they cannot leave.
var config = CoreConfigTestFactory.CreateConfigWithTun(ECoreType.Xray, enableIPv6Address);
CoreConfigTestFactory.BindAppManagerConfig(config);
var node = CoreConfigTestFactory.CreateVmessNode(ECoreType.Xray, "n-main", "main");
var context = CoreConfigTestFactory.CreateContext(config, node, ECoreType.Xray, hasGlobalIPv6Address: false);
var result = new CoreConfigV2rayService(context).GenerateClientConfigContent();
await result.Success.Should().BeTrue();
var cfg = JsonUtils.Deserialize<V2rayConfig>(result.Data!.ToString())!;
var tunInbound = cfg.inbounds.FirstOrDefault(i => i.protocol == "tun");
await tunInbound.Should().NotBeNull();
await tunInbound!.settings.autoSystemRoutingTable.Should().Contain("0.0.0.0/0");
var ipv6Routes = tunInbound.settings.autoSystemRoutingTable!.Where(x => x.Contains(':')).ToList();
await ipv6Routes.Should().BeEmpty();
}
[Test]
public async Task GenerateClientConfigContent_TunRouteExcludeAddress_ShouldSkipIPv6RangesWithoutGlobalIPv6()
{
var config = CoreConfigTestFactory.CreateConfigWithTunRouteExcludeAddress(ECoreType.Xray);
config.TunModeItem.EnableIPv6Address = false;
CoreConfigTestFactory.BindAppManagerConfig(config);
var node = CoreConfigTestFactory.CreateVmessNode(ECoreType.Xray, "n-main", "main");
var context = CoreConfigTestFactory.CreateContext(config, node, ECoreType.Xray, hasGlobalIPv6Address: false);
var result = new CoreConfigV2rayService(context).GenerateClientConfigContent();
await result.Success.Should().BeTrue();
var cfg = JsonUtils.Deserialize<V2rayConfig>(result.Data!.ToString())!;
var tunInbound = cfg.inbounds.FirstOrDefault(i => i.protocol == "tun");
await tunInbound.Should().NotBeNull();
await tunInbound!.settings.autoSystemRoutingTable.Should().NotBeEmpty();
var ipv6Routes = tunInbound.settings.autoSystemRoutingTable!.Where(x => x.Contains(':')).ToList();
await ipv6Routes.Should().BeEmpty();
}
[Test]
public async Task GenerateClientConfigContent_TunRouteExcludeAddress_ShouldIncludeIPv6Ranges()
{
@@ -667,6 +712,5 @@ public class CoreConfigV2rayServiceTests
var proxyOutbound = cfg!.outbounds.FirstOrDefault(o => o.tag == Global.ProxyTag);
await proxyOutbound.Should().NotBeNull();
await proxyOutbound!.protocol.Should().BeEqualTo("shadowsocks");
await proxyOutbound.settings.servers.Should().NotBeNull();
}
}
+395 -2
View File
@@ -2,6 +2,46 @@ namespace ServiceLib.Tests.Fmt;
public class FmtHandlerTests
{
/// <summary>
/// One profile factory per protocol that <see cref="FmtHandler.GetShareUri" /> can export.
/// The suite below asserts that this map and <see cref="Global.ProtocolShares" /> agree, so a
/// newly exportable protocol cannot be added without a round-trip case.
/// </summary>
private static readonly Dictionary<EConfigType, Func<ProfileItem>> ShareProfileFactories = new()
{
[EConfigType.VMess] = CreateVmessProfile,
[EConfigType.Shadowsocks] = CreateShadowsocksProfile,
[EConfigType.SOCKS] = CreateSocksProfile,
[EConfigType.VLESS] = CreateVlessProfile,
[EConfigType.Trojan] = CreateTrojanProfile,
[EConfigType.Hysteria2] = CreateHysteria2Profile,
[EConfigType.TUIC] = CreateTuicProfile,
[EConfigType.WireGuard] = CreateWireguardProfile,
[EConfigType.Anytls] = CreateAnytlsProfile,
[EConfigType.Naive] = () => CreateNaiveProfile(false),
};
[Test]
public async Task ShareUriSuite_ShouldCoverAndRoundTripEveryExportableProtocol()
{
var uncovered = string.Join(", ", Global.ProtocolShares.Keys.Except(ShareProfileFactories.Keys));
var unexpected = string.Join(", ", ShareProfileFactories.Keys.Except(Global.ProtocolShares.Keys));
await uncovered.Should().BeEqualTo(string.Empty);
await unexpected.Should().BeEqualTo(string.Empty);
foreach (var (configType, factory) in ShareProfileFactories)
{
var source = factory();
var resolved = await ExportThenImport(source);
await resolved.ConfigType.Should().BeEqualTo(configType);
await resolved.Address.Should().BeEqualTo(source.Address);
await resolved.Port.Should().BeEqualTo(source.Port);
}
}
[Test]
public async Task GetShareUriAndResolveConfig_Vmess_ShouldRoundTripBasicFields()
{
@@ -62,6 +102,165 @@ public class FmtHandlerTests
await resolved.Password.Should().BeEqualTo(source.Password);
}
[Test]
public async Task GetShareUriAndResolveConfig_Trojan_ShouldRoundTripBasicFields()
{
var source = CreateTrojanProfile();
var resolved = await ExportThenImport(source);
await AssertCommonShareFields(source, resolved);
await AssertRawTransportFields(source, resolved);
await resolved.Password.Should().BeEqualTo(source.Password);
await resolved.Sni.Should().BeEqualTo(source.Sni);
await resolved.GetProtocolExtra().Flow.Should().BeEqualTo(source.GetProtocolExtra().Flow);
await resolved.GetAllowInsecure().Should().BeTrue();
// Trojan is the one exporter that writes both spellings of the flag.
await AssertExportContains(source, "allowInsecure=1", "insecure=1");
}
[Test]
public async Task GetShareUriAndResolveConfig_Tuic_ShouldRoundTripUserInfoAndCongestionControl()
{
var source = CreateTuicProfile();
var resolved = await ExportThenImport(source);
await AssertCommonShareFields(source, resolved);
await resolved.Username.Should().BeEqualTo(source.Username);
await resolved.Password.Should().BeEqualTo(source.Password);
await resolved.Sni.Should().BeEqualTo(source.Sni);
await resolved.Alpn.Should().BeEqualTo(source.Alpn);
await resolved.GetProtocolExtra().CongestionControl.Should()
.BeEqualTo(source.GetProtocolExtra().CongestionControl);
await resolved.GetAllowInsecure().Should().BeTrue();
await AssertExportContains(source, "allow_insecure=1");
}
[Test]
public async Task GetShareUriAndResolveConfig_Anytls_ShouldRoundTripBasicFields()
{
var source = CreateAnytlsProfile();
var resolved = await ExportThenImport(source);
await AssertCommonShareFields(source, resolved);
await AssertRawTransportFields(source, resolved);
await resolved.Password.Should().BeEqualTo(source.Password);
await resolved.Sni.Should().BeEqualTo(source.Sni);
await resolved.Alpn.Should().BeEqualTo(source.Alpn);
await resolved.GetAllowInsecure().Should().BeTrue();
await AssertExportContains(source, "insecure=1");
}
[Test]
public async Task GetShareUriAndResolveConfig_Hysteria2_ShouldRoundTripObfsAndNormalizePortRange()
{
var source = CreateHysteria2Profile();
var resolved = await ExportThenImport(source);
var sourceExtra = source.GetProtocolExtra();
var resolvedExtra = resolved.GetProtocolExtra();
await AssertCommonShareFields(source, resolved);
await resolved.Password.Should().BeEqualTo(source.Password);
await resolved.Sni.Should().BeEqualTo(source.Sni);
await resolved.Alpn.Should().BeEqualTo(source.Alpn);
await resolved.EchConfigList.Should().BeEqualTo(source.EchConfigList);
await resolved.GetAllowInsecure().Should().BeTrue();
await resolvedExtra.SalamanderPass.Should().BeEqualTo(sourceExtra.SalamanderPass);
// Hysteria2Fmt stores a port range internally as "5000:6000" and emits the URI form.
await resolvedExtra.Ports.Should().BeEqualTo("5000-6000");
await AssertExportContains(source, "insecure=1", "obfs=salamander", "mport=5000-6000");
}
[Test]
public async Task GetShareUriAndResolveConfig_Wireguard_ShouldRoundTripKeysAndInterface()
{
var source = CreateWireguardProfile();
var resolved = await ExportThenImport(source);
var extra = resolved.GetProtocolExtra();
var sourceExtra = source.GetProtocolExtra();
await AssertCommonShareFields(source, resolved);
await resolved.Password.Should().BeEqualTo(source.Password);
await extra.WgPublicKey.Should().BeEqualTo(sourceExtra.WgPublicKey);
await extra.WgPresharedKey.Should().BeEqualTo(sourceExtra.WgPresharedKey);
await extra.WgReserved.Should().BeEqualTo(sourceExtra.WgReserved);
await extra.WgInterfaceAddress.Should().BeEqualTo(sourceExtra.WgInterfaceAddress);
await extra.WgMtu.Should().BeEqualTo(sourceExtra.WgMtu);
}
[Test]
public async Task GetShareUri_Wireguard_ShouldEncodeKeysAndBracketIpv6()
{
var source = CreateWireguardProfile();
// Base64 keys carry '/', '+' and '=', and the address is an IPv6 literal: both have to
// survive the wire form, which a round trip through the same encoder would not prove.
await AssertExportContains(
source,
Uri.EscapeDataString(source.Password),
Uri.EscapeDataString(source.GetProtocolExtra().WgPublicKey ?? string.Empty),
"@[2001:db8::40]:51820");
}
[Test]
public async Task GetShareUriAndResolveConfig_Naive_ShouldRoundTripCredentialsOverHttps()
{
var source = CreateNaiveProfile(false);
var resolved = await ExportThenImport(source, Global.NaiveHttpsProtocolShare);
await AssertCommonShareFields(source, resolved);
await AssertRawTransportFields(source, resolved);
await resolved.Username.Should().BeEqualTo(source.Username);
await resolved.Password.Should().BeEqualTo(source.Password);
await resolved.GetProtocolExtra().InsecureConcurrency.Should()
.BeEqualTo(source.GetProtocolExtra().InsecureConcurrency);
// NaiveFmt only ever sets this flag on the quic branch, so the https branch leaves it
// unset rather than false - assert "is not quic" instead of an explicit false.
await (resolved.GetProtocolExtra().NaiveQuic == true).Should().BeFalse();
}
[Test]
public async Task GetShareUriAndResolveConfig_NaiveQuic_ShouldRoundTripQuicScheme()
{
var source = CreateNaiveProfile(true);
var resolved = await ExportThenImport(source, Global.NaiveQuicProtocolShare);
await AssertCommonShareFields(source, resolved);
await AssertRawTransportFields(source, resolved);
await resolved.Username.Should().BeEqualTo(source.Username);
await resolved.Password.Should().BeEqualTo(source.Password);
await resolved.GetProtocolExtra().InsecureConcurrency.Should()
.BeEqualTo(source.GetProtocolExtra().InsecureConcurrency);
await resolved.GetProtocolExtra().NaiveQuic.Should().BeTrue();
}
[Test]
[Arguments("p:a@ss#% +/=")]
[Arguments("пароль 東京")]
public async Task GetShareUriAndResolveConfig_Trojan_ShouldRoundTripEncodedCredentials(string password)
{
var source = CreateTrojanProfile();
source.Password = password;
source.Remarks = "Trojan — тест 東京 #1";
var resolved = await ExportThenImport(source);
await resolved.Password.Should().BeEqualTo(password);
await resolved.Remarks.Should().BeEqualTo(source.Remarks);
}
[Test]
public async Task ResolveConfig_UnsupportedProtocol_ShouldReturnNull()
{
@@ -82,14 +281,70 @@ public class FmtHandlerTests
await uri.Should().BeNull();
}
private static async Task AssertCommonShareFields(ProfileItem source, ProfileItem resolved)
{
await resolved.ConfigType.Should().BeEqualTo(source.ConfigType);
await resolved.Remarks.Should().BeEqualTo(source.Remarks);
await resolved.Address.Should().BeEqualTo(source.Address);
await resolved.Port.Should().BeEqualTo(source.Port);
}
/// <summary>
/// Only for protocols whose exporter goes through the shared transport query
/// (<c>security</c>, <c>type</c>, <c>headerType</c>). TUIC, Hysteria2 and WireGuard do not.
/// </summary>
private static async Task AssertRawTransportFields(ProfileItem source, ProfileItem resolved)
{
await resolved.Network.Should().BeEqualTo(source.Network);
await resolved.StreamSecurity.Should().BeEqualTo(source.StreamSecurity);
await resolved.GetTransportExtra().RawHeaderType.Should()
.BeEqualTo(source.GetTransportExtra().RawHeaderType);
}
/// <summary>
/// Asserts on the wire form itself. A round trip cannot catch an exporter and an importer that
/// agree on the wrong spelling of a parameter, and the insecure flag is spelled differently by
/// every protocol.
/// </summary>
private static async Task AssertExportContains(ProfileItem source, params string[] expectedFragments)
{
var uri = FmtHandler.GetShareUri(source);
await uri.Should().NotBeNull();
foreach (var fragment in expectedFragments)
{
await uri!.Contains(fragment, StringComparison.Ordinal).Should()
.BeTrue().Because($"uri: {uri}, expected fragment: {fragment}");
}
}
private static string ExpectedShareScheme(ProfileItem item)
{
if (item.ConfigType != EConfigType.Naive)
{
return Global.ProtocolShares[item.ConfigType];
}
// NaiveFmt never emits the "naive://" prefix that Global.ProtocolShares records for the
// type; that entry is only read when importing.
return item.GetProtocolExtra().NaiveQuic == true
? Global.NaiveQuicProtocolShare
: Global.NaiveHttpsProtocolShare;
}
private static async Task<ProfileItem> ExportThenImport(ProfileItem source)
{
return await ExportThenImport(source, ExpectedShareScheme(source));
}
private static async Task<ProfileItem> ExportThenImport(ProfileItem source, string expectedPrefix)
{
var uri = FmtHandler.GetShareUri(source);
await uri.Should().NotBeNull();
await uri.Should().NotBeEmpty();
await uri!.StartsWith(Global.ProtocolShares[source.ConfigType], StringComparison.OrdinalIgnoreCase).Should()
.BeTrue();
await uri!.StartsWith(expectedPrefix, StringComparison.OrdinalIgnoreCase).Should().BeTrue();
var resolved = FmtHandler.ResolveConfig(uri, out var msg);
@@ -166,4 +421,142 @@ public class FmtHandlerTests
Password = "pass",
};
}
private static ProfileItem CreateTrojanProfile()
{
var item = new ProfileItem
{
ConfigType = EConfigType.Trojan,
Remarks = "trojan demo",
Address = "trojan.example",
Port = 443,
Password = "trojan-pass",
Network = nameof(ETransport.raw),
StreamSecurity = Global.StreamSecurity,
Sni = "sni.trojan.example",
AllowInsecure = Global.StringTrue,
};
item.SetProtocolExtra(new ProtocolExtraItem { Flow = Global.Flows[1], });
item.SetTransportExtra(new TransportExtraItem { RawHeaderType = Global.None, });
return item;
}
private static ProfileItem CreateTuicProfile()
{
var item = new ProfileItem
{
ConfigType = EConfigType.TUIC,
Remarks = "tuic demo",
Address = "tuic.example",
Port = 8443,
// A colon separates the two halves of the TUIC user info, so it cannot appear in the
// uuid; a fixed value also keeps a failure reproducible.
Username = "01234567-89ab-cdef-0123-456789abcdef",
Password = "tuic-pass",
Sni = "sni.tuic.example",
Alpn = "h3",
AllowInsecure = Global.StringTrue,
};
item.SetProtocolExtra(new ProtocolExtraItem { CongestionControl = "bbr", });
return item;
}
private static ProfileItem CreateAnytlsProfile()
{
var item = new ProfileItem
{
ConfigType = EConfigType.Anytls,
Remarks = "anytls demo",
Address = "anytls.example",
Port = 8443,
Password = "anytls-pass",
Network = nameof(ETransport.raw),
StreamSecurity = Global.StreamSecurity,
Sni = "sni.anytls.example",
Alpn = "h2,http/1.1",
AllowInsecure = Global.StringTrue,
};
item.SetTransportExtra(new TransportExtraItem { RawHeaderType = Global.None, });
return item;
}
private static ProfileItem CreateHysteria2Profile()
{
// CertSha is deliberately left unset: the importer turns AllowInsecure on by itself when a
// pinSHA256 is present, which would mask an exporter that stopped emitting insecure=1.
var item = new ProfileItem
{
ConfigType = EConfigType.Hysteria2,
Remarks = "hysteria2 demo",
Address = "hy2.example",
Port = 8443,
Password = "demo-user:demo-pass",
Sni = "sni.hy2.example",
Alpn = "h3",
EchConfigList = "AAj+DQAEAAAAAA==",
AllowInsecure = Global.StringTrue,
};
item.SetProtocolExtra(new ProtocolExtraItem
{
SalamanderPass = "salamander-pass",
Ports = "5000:6000",
});
return item;
}
private static string CreateWireguardKey(byte value)
{
return Convert.ToBase64String(Enumerable.Repeat(value, 32).ToArray());
}
private static ProfileItem CreateWireguardProfile()
{
var item = new ProfileItem
{
ConfigType = EConfigType.WireGuard,
Remarks = "WireGuard — тест 東京 #1",
Address = "2001:db8::40",
Port = 51820,
Password = CreateWireguardKey(0xFE),
};
item.SetProtocolExtra(new ProtocolExtraItem
{
WgPublicKey = CreateWireguardKey(0xFD),
WgPresharedKey = CreateWireguardKey(0xFC),
WgReserved = "1,2,255",
WgInterfaceAddress = "10.0.0.2/32,fd00::2/128",
WgMtu = 1420,
});
return item;
}
private static ProfileItem CreateNaiveProfile(bool quic)
{
var item = new ProfileItem
{
ConfigType = EConfigType.Naive,
Remarks = quic ? "naive quic demo" : "naive https demo",
Address = "naive.example",
Port = 443,
Username = "naive-user",
Password = "päss:word@/?#&=+ 東京",
Network = nameof(ETransport.raw),
StreamSecurity = Global.None,
};
item.SetProtocolExtra(new ProtocolExtraItem { NaiveQuic = quic, InsecureConcurrency = 4, });
item.SetTransportExtra(new TransportExtraItem { RawHeaderType = Global.None, });
return item;
}
}
@@ -0,0 +1,54 @@
namespace ServiceLib.Tests.Fmt;
public class Hysteria2FmtTests
{
// "The hostname and optional port of the server. If the port is omitted, it defaults to 443."
// -- https://v2.hysteria.network/docs/developers/URI-Scheme/
// A ':' with no digits after it is an omitted port too, per RFC 3986 'port = *DIGIT'.
[Test]
[Arguments("hysteria2://password@hy2.example/")]
[Arguments("hysteria2://password@hy2.example")]
[Arguments("hysteria2://password@hy2.example:/")]
[Arguments("hy2://password@hy2.example/?sni=real.example")]
public async Task ResolveConfig_WithoutPort_ShouldDefaultTo443(string shareUri)
{
var resolved = FmtHandler.ResolveConfig(shareUri, out var msg);
await resolved.Should().NotBeNull().Because($"uri: {shareUri}, msg: {msg}");
await resolved!.ConfigType.Should().BeEqualTo(EConfigType.Hysteria2);
await resolved.Address.Should().BeEqualTo("hy2.example");
await resolved.Port.Should().BeEqualTo(443);
}
[Test]
public async Task ResolveConfig_WithoutPort_ShouldProduceAValidProfile()
{
// Uri.Port is -1 for an unregistered scheme with no port, and ProfileItem.IsValid rejects
// any port outside 1..65535 - so the default is what keeps such a link usable at all.
var resolved = FmtHandler.ResolveConfig("hysteria2://password@hy2.example/", out _);
await resolved.Should().NotBeNull();
await resolved!.IsValid().Should().BeTrue();
}
[Test]
public async Task ResolveConfig_WithExplicitPort_ShouldKeepIt()
{
var resolved = FmtHandler.ResolveConfig("hysteria2://password@hy2.example:8443/", out _);
await resolved.Should().NotBeNull();
await resolved!.Port.Should().BeEqualTo(8443);
}
[Test]
public async Task ResolveConfig_WithExplicitZeroPort_ShouldNotApplyTheDefault()
{
// Uri.Port is 0 here, not -1: the port is present, it is just not a usable one. Treating
// it as "omitted" would silently move the endpoint to :443, so it stays invalid instead.
var resolved = FmtHandler.ResolveConfig("hysteria2://password@hy2.example:0/", out _);
await resolved.Should().NotBeNull();
await resolved!.Port.Should().BeEqualTo(0);
await resolved.IsValid().Should().BeFalse();
}
}
@@ -0,0 +1,73 @@
namespace ServiceLib.Tests.Fmt;
public class ShareUriQueryTests
{
[Test]
[Arguments("ob%41fs")]
[Arguments("66%ff")]
[Arguments("100%")]
public async Task GetShareUriAndResolveConfig_QueryValueWithPercent_ShouldSurviveTheRoundTrip(string obfsPassword)
{
var source = new ProfileItem
{
ConfigType = EConfigType.Hysteria2,
Remarks = "percent demo",
Address = "hy2.example",
Port = 8443,
Password = "pw",
};
source.SetProtocolExtra(new ProtocolExtraItem { SalamanderPass = obfsPassword, });
var uri = FmtHandler.GetShareUri(source);
await uri.Should().NotBeNull();
var resolved = FmtHandler.ResolveConfig(uri!, out var msg);
await resolved.Should().NotBeNull().Because($"uri: {uri}, msg: {msg}");
await resolved!.GetProtocolExtra().SalamanderPass.Should().BeEqualTo(obfsPassword);
}
// RFC 3986 lists '=' among the sub-delimiters a query value may carry, so only the first one
// separates the key from the value. Splitting on every '=' discarded the pair outright.
[Test]
public async Task ResolveConfig_QueryValueWithUnescapedEquals_ShouldNotBeDropped()
{
const string shareUri = "hysteria2://pw@hy2.example:8443/?ech=AAj+DQAEAAAAAA==&sni=real.example";
var resolved = FmtHandler.ResolveConfig(shareUri, out var msg);
await resolved.Should().NotBeNull().Because($"uri: {shareUri}, msg: {msg}");
await resolved!.EchConfigList.Should().BeEqualTo("AAj+DQAEAAAAAA==");
await resolved.Sni.Should().BeEqualTo("real.example");
}
// Canonical SIP002 percent-encodes the plugin argument, and that is what this client's own
// exporter emits. This covers the non-canonical spelling instead: the options are a ';'
// separated list of 'key=value' pairs, so left unescaped the value always carries '='.
[Test]
public async Task ResolveConfig_NonCanonicalSip002PluginWithLiteralEquals_ShouldConfigureObfs()
{
const string shareUri =
"ss://YWVzLTEyOC1nY206cGFzczEyMw==@1.2.3.4:8388/?plugin=obfs-local;obfs=http;obfs-host=example.com#ss";
var resolved = FmtHandler.ResolveConfig(shareUri, out var msg);
await resolved.Should().NotBeNull().Because($"uri: {shareUri}, msg: {msg}");
await resolved!.ConfigType.Should().BeEqualTo(EConfigType.Shadowsocks);
await resolved.GetTransportExtra().Host.Should().BeEqualTo("example.com");
await resolved.GetTransportExtra().RawHeaderType.Should().BeEqualTo(Global.RawHeaderHttp);
}
[Test]
public async Task ResolveConfig_EscapedQueryValue_ShouldStillDecodeExactlyOnce()
{
const string shareUri = "hysteria2://pw@hy2.example:8443/?ech=AAj%2BDQAEAAAAAA%3D%3D&obfs=salamander&obfs-password=a%20b";
var resolved = FmtHandler.ResolveConfig(shareUri, out var msg);
await resolved.Should().NotBeNull().Because($"uri: {shareUri}, msg: {msg}");
await resolved!.EchConfigList.Should().BeEqualTo("AAj+DQAEAAAAAA==");
await resolved.GetProtocolExtra().SalamanderPass.Should().BeEqualTo("a b");
}
}
@@ -11,6 +11,7 @@ public class WireguardFmtTests
PrivateKey = interface-private-key
Address = 10.0.0.2/32, fd00::2/128 ; inline comment
MTU = 1420
DNS = 2001::db8::53, 1.2.3.4, 2001:db8::54
[Peer]
PublicKey = peer-public-key
@@ -35,6 +36,7 @@ public class WireguardFmtTests
await first.GetProtocolExtra().WgReserved.Should().BeEqualTo("1, 2, 3");
await first.GetProtocolExtra().WgInterfaceAddress.Should().BeEqualTo("10.0.0.2/32, fd00::2/128");
await first.GetProtocolExtra().WgMtu.Should().BeEqualTo(1420);
await first.GetProtocolExtra().WgDns.Should().BeEqualTo("2001::db8::53, 1.2.3.4, 2001:db8::54");
var second = resolved[1];
await second.Address.Should().BeEqualTo("example.com");
@@ -0,0 +1,77 @@
namespace ServiceLib.Tests.Helper;
public class HttpRequestHeadersHelperTests
{
[Test]
public async Task TryParse_ShouldAcceptEmptySettingsForExistingSubscriptions()
{
foreach (var json in new string?[] { null, "", " \r\n ", "{}" })
{
await HttpRequestHeadersHelper.TryParse(json, out var headers).Should().BeTrue();
await headers.Count.Should().BeEqualTo(0);
}
}
[Test]
public async Task TryParse_ShouldPreserveValuesAndUseCaseInsensitiveNames()
{
const string json = """
{
"X-hwid": "my_test_device",
"Authorization": "Bearer test:token",
"accept": "application/json",
"Content-Type": "application/json",
"X-Empty": ""
}
""";
await HttpRequestHeadersHelper.TryParse(json, out var headers).Should().BeTrue();
await headers["x-HWID"].Should().BeEqualTo("my_test_device");
await headers["AUTHORIZATION"].Should().BeEqualTo("Bearer test:token");
await headers["Accept"].Should().BeEqualTo("application/json");
await headers["Content-Type"].Should().BeEqualTo("application/json");
await headers["X-Empty"].Should().BeEqualTo("");
}
[Test]
[Arguments("not-json")]
[Arguments("null")]
[Arguments("[]")]
[Arguments("{\"X-Test\": 1}")]
[Arguments("{\"X-Test\": null}")]
[Arguments("{\"X-Test\": [\"one\", \"two\"]}")]
[Arguments("{\"X-Test\": \"one\", \"X-Test\": \"two\"}")]
[Arguments("{\"Accept\": \"one\", \"accept\": \"two\"}")]
[Arguments("{\"Bad Header\": \"value\"}")]
[Arguments("{\"Bad:Header\": \"value\"}")]
[Arguments("{\"\": \"value\"}")]
[Arguments("{\"X-Test\": \"one\\r\\nInjected: two\"}")]
[Arguments("{\"X-Test\": \"one\\nInjected: two\"}")]
[Arguments("{\"X-Test\": \"one\\u0000two\"}")]
public async Task TryParse_ShouldRejectInvalidHeadersWithoutReturningPartialSettings(string json)
{
await HttpRequestHeadersHelper.TryParse(json, out var headers).Should().BeFalse();
await headers.Count.Should().BeEqualTo(0);
}
[Test]
public async Task RequestHeaders_ShouldSurviveDatabaseMigrationAndEditing()
{
using var database = new SQLiteConnection(":memory:", false);
database.Execute("CREATE TABLE SubItem (Id TEXT PRIMARY KEY, Remarks TEXT, Url TEXT)");
database.Execute("INSERT INTO SubItem (Id, Remarks, Url) VALUES (?, ?, ?)", "existing", "Existing", "https://example.com/sub");
database.CreateTable<SubItem>();
var item = database.Find<SubItem>("existing");
await HttpRequestHeadersHelper.TryParse(item.RequestHeaders, out var oldHeaders).Should().BeTrue();
await oldHeaders.Count.Should().BeEqualTo(0);
item.RequestHeaders = "{\"X-hwid\":\"my_device\"}";
database.Update(item);
await database.Find<SubItem>(item.Id).RequestHeaders.Should().BeEqualTo(item.RequestHeaders);
item.RequestHeaders = "";
database.Update(item);
await database.Find<SubItem>(item.Id).RequestHeaders.Should().BeEqualTo("");
}
}
@@ -0,0 +1,159 @@
namespace ServiceLib.Tests.Services;
public class DownloadServiceHeadersTests
{
[Test]
[Arguments(false, false)]
[Arguments(false, true)]
[Arguments(true, false)]
[Arguments(true, true)]
public async Task TryDownloadString_ShouldSendCustomHeadersThroughBothDownloaders(bool useProxy, bool failFirstRequest)
{
await CertPemManager.Instance.Init(new Config { GuiItem = new GUIItem() });
await using var server = new SubscriptionHttpServer(failFirstRequest);
const string json = """
{
"accept": "application/json",
"user-agent": "CustomSubscriptionClient/1.0",
"authorization": "Bearer test-token",
"X-hwid": "test-device",
"Cookie": "session=test",
"Content-Type": "application/json"
}
""";
await HttpRequestHeadersHelper.TryParse(json, out var headers).Should().BeTrue();
var service = new DownloadService { AcceptHeader = "*/*", RequestHeaders = headers };
var uri = new UriBuilder(useProxy ? "http://subscription.invalid/sub" : server.Url)
{
UserName = "user",
Password = "password"
}.Uri;
IWebProxy? proxy = useProxy ? new WebProxy(server.Url) : null;
var content = await service.TryDownloadString(uri.AbsoluteUri, proxy, "OriginalClient/1.0").WaitAsync(TimeSpan.FromSeconds(20));
await content.Should().BeEqualTo(SubscriptionHttpServer.Body);
await (server.Requests.Count >= (failFirstRequest ? 2 : 1)).Should().BeTrue();
foreach (var request in server.Requests)
{
await request["Accept"].Should().BeEqualTo("application/json");
await request["User-Agent"].Should().BeEqualTo("CustomSubscriptionClient/1.0");
await request["Authorization"].Should().BeEqualTo("Bearer test-token");
await request["X-hwid"].Should().BeEqualTo("test-device");
await request["Cookie"].Should().BeEqualTo("session=test");
await request["Content-Type"].Should().BeEqualTo("application/json");
}
}
[Test]
[Arguments(false)]
[Arguments(true)]
public async Task TryDownloadString_ShouldKeepDefaultAcceptUserAgentAndBasicAuth(bool failFirstRequest)
{
await CertPemManager.Instance.Init(new Config { GuiItem = new GUIItem() });
await using var server = new SubscriptionHttpServer(failFirstRequest);
var service = new DownloadService { AcceptHeader = "*/*" };
var uri = new UriBuilder(server.Url) { UserName = "user", Password = "password" }.Uri;
var content = await service.TryDownloadString(uri.AbsoluteUri, (IWebProxy?)null, "ExistingClient/1.0").WaitAsync(TimeSpan.FromSeconds(20));
await content.Should().BeEqualTo(SubscriptionHttpServer.Body);
foreach (var request in server.Requests)
{
await request["Accept"].Should().BeEqualTo("*/*");
await request["User-Agent"].Should().BeEqualTo("ExistingClient/1.0");
await request["Authorization"].Should().BeEqualTo("Basic dXNlcjpwYXNzd29yZA==");
await request.ContainsKey("X-hwid").Should().BeFalse();
}
}
[Test]
public async Task TryDownloadString_ShouldNotShareHeadersWithOtherDownloads()
{
await CertPemManager.Instance.Init(new Config { GuiItem = new GUIItem() });
await using var server = new SubscriptionHttpServer();
var subscription = new DownloadService
{
AcceptHeader = "*/*",
RequestHeaders = new Dictionary<string, string> { ["X-hwid"] = "first-device" }
};
var ordinaryDownload = new DownloadService();
await (await subscription.TryDownloadString(server.Url, (IWebProxy?)null, "TestClient/1.0")).Should().BeEqualTo(SubscriptionHttpServer.Body);
await (await ordinaryDownload.TryDownloadString(server.Url, (IWebProxy?)null, "TestClient/1.0")).Should().BeEqualTo(SubscriptionHttpServer.Body);
var requests = server.Requests.ToArray();
await requests.Length.Should().BeEqualTo(2);
await requests[0]["X-hwid"].Should().BeEqualTo("first-device");
await requests[1].ContainsKey("X-hwid").Should().BeFalse();
await requests[1].ContainsKey("Accept").Should().BeFalse();
}
private sealed class SubscriptionHttpServer : IAsyncDisposable
{
public const string Body = "subscription-test-content";
private readonly TcpListener _listener = new(IPAddress.Loopback, 0);
private readonly CancellationTokenSource _cancellation = new();
private readonly Task _serverTask;
private readonly bool _failFirstRequest;
public string Url { get; }
public ConcurrentQueue<Dictionary<string, string>> Requests { get; } = new();
public SubscriptionHttpServer(bool failFirstRequest = false)
{
_failFirstRequest = failFirstRequest;
_listener.Start();
Url = $"http://127.0.0.1:{((IPEndPoint)_listener.LocalEndpoint).Port}/subscription";
_serverTask = ServeAsync();
}
private async Task ServeAsync()
{
var cancellationToken = _cancellation.Token;
while (!cancellationToken.IsCancellationRequested)
{
using var client = await _listener.AcceptTcpClientAsync(cancellationToken);
await using var stream = client.GetStream();
using var reader = new StreamReader(stream, Encoding.ASCII, leaveOpen: true);
var requestLine = await reader.ReadLineAsync(cancellationToken);
if (requestLine == null)
{
continue;
}
var headers = new Dictionary<string, string>(StringComparer.OrdinalIgnoreCase);
while (await reader.ReadLineAsync(cancellationToken) is { Length: > 0 } line)
{
var separator = line.IndexOf(':');
var name = line.Substring(0, separator);
var value = line.Substring(separator + 1).Trim();
headers[name] = headers.TryGetValue(name, out var previous) ? $"{previous}, {value}" : value;
}
Requests.Enqueue(headers);
var status = _failFirstRequest && Requests.Count == 1 ? "503 Service Unavailable" : "200 OK";
var body = requestLine.StartsWith("HEAD ") ? "" : Body;
var response = $"HTTP/1.1 {status}\r\nContent-Length: {Body.Length}\r\nConnection: close\r\n\r\n{body}";
await stream.WriteAsync(Encoding.ASCII.GetBytes(response), cancellationToken);
}
}
public async ValueTask DisposeAsync()
{
await _cancellation.CancelAsync();
_listener.Stop();
try
{
await _serverTask;
}
catch (OperationCanceledException)
{
}
finally
{
_cancellation.Dispose();
}
}
}
}
@@ -2,7 +2,14 @@ namespace ServiceLib.Base;
public class BulkObservableCollection<T> : ObservableCollection<T>
{
private bool _suppressNotification = false;
private bool _suppressNotification;
public BulkObservableCollection()
{
}
public BulkObservableCollection(IEnumerable<T> collection) : base(collection) { }
public BulkObservableCollection(List<T> list) : base(list) { }
protected override void OnCollectionChanged(NotifyCollectionChangedEventArgs e)
{
@@ -63,4 +70,27 @@ public class BulkObservableCollection<T> : ObservableCollection<T>
return true;
}
public void ReplaceRange(IEnumerable<T>? collection)
{
_suppressNotification = true;
try
{
Items.Clear();
if (collection != null)
{
foreach (var item in collection)
{
Items.Add(item);
}
}
}
finally
{
_suppressNotification = false;
OnPropertyChanged(new PropertyChangedEventArgs(nameof(Count)));
OnPropertyChanged(new PropertyChangedEventArgs("Item[]"));
OnCollectionChanged(new NotifyCollectionChangedEventArgs(NotifyCollectionChangedAction.Reset));
}
}
}
+37 -1
View File
@@ -200,7 +200,9 @@ public class Utils
var parts = query[1..].Split('&', StringSplitOptions.RemoveEmptyEntries);
foreach (var part in parts)
{
var keyValue = part.Split('=');
// Split on the FIRST '=' only: RFC 3986 lists '=' among the sub-delimiters a query
// value may carry, so everything after the first one belongs to the value.
var keyValue = part.Split('=', 2);
if (keyValue.Length != 2)
{
continue;
@@ -813,6 +815,40 @@ public class Utils
.Any(ni => ni.Name.Equals(inInterfaceName, StringComparison.OrdinalIgnoreCase));
}
/// <summary>
/// Whether the host holds a globally routable IPv6 address, that is one inside 2000::/3.
/// Link-local and unique local addresses are excluded: they never reach the IPv6 internet,
/// so a host holding only those has no IPv6 traffic that could bypass the tunnel, and no
/// IPv6 path that traffic sent into the tunnel could come back out of.
/// </summary>
public static bool HasGlobalIPv6Address()
{
try
{
return NetworkInterface.GetAllNetworkInterfaces()
.Where(ni => ni.OperationalStatus == OperationalStatus.Up
&& ni.NetworkInterfaceType != NetworkInterfaceType.Loopback)
.SelectMany(ni => ni.GetIPProperties().UnicastAddresses)
.Any(ua => IsGlobalUnicastIPv6(ua.Address));
}
catch
{
return true;
}
}
private static bool IsGlobalUnicastIPv6(IPAddress address)
{
if (address.AddressFamily != AddressFamily.InterNetworkV6)
{
return false;
}
// 2000::/3 is the only range currently assigned for global unicast, which leaves out
// ::1, fe80::/10, fc00::/7 and ff00::/8 in a single test.
return (address.GetAddressBytes()[0] & 0xE0) == 0x20;
}
#endregion Speed Test
#region Miscellaneous
-1
View File
@@ -5,5 +5,4 @@ public enum ERuleMode
Rule = 0,
Global = 1,
Direct = 2,
Unchanged = 3
}
+24 -13
View File
@@ -97,11 +97,16 @@ public class Global
public const string StringFalse = "false";
public const int SqliteMaxBatchSize = 10000;
public const string SingboxDirectDNSTag = "direct_dns";
public const string SingboxRemoteDNSTag = "remote_dns";
public const string SingboxLocalDNSTag = "local_local";
public const string SingboxHostsDNSTag = "hosts_dns";
public const string SingboxFakeDNSTag = "fake_dns";
public const string SingboxDirectDNSTagPrefix = "direct-dns-";
public const string SingboxRemoteDNSTagPrefix = "remote-dns-";
public const string SingboxDirectDNSTag = "direct-dns-1";
public const string SingboxRemoteDNSTag = "remote-dns-1";
public const string SingboxDirectDNSTagTemplate = "direct-dns-{0}";
public const string SingboxRemoteDNSTagTemplate = "remote-dns-{0}";
public const string SingboxLocalDNSTag = "local-local";
public const string SingboxHostsDNSTag = "hosts-dns";
public const string SingboxFakeDNSTag = "fake-dns";
public const string SingboxSrsDownloadHttpClientTag = "srs-download-http-client";
public const int Hysteria2DefaultHopInt = 30;
public const string PolicyGroupExcludeKeywords = @"剩余|过期|到期|重置|[Rr]emaining|[Ee]xpir|[Rr]eset";
@@ -484,16 +489,22 @@ public class Global
"localhost"
];
public static readonly List<LanguageOption> LanguageOptions =
[
new("zh-Hans", "简体中文"),
new("zh-Hant", "繁體中文"),
new("en", "English"),
new("fa", "فارسی"),
new("fr", "Français"),
new("ru", "Русский"),
new("hu", "Magyar"),
new("id", "Bahasa Indonesia"),
new("az", "Azərbaycan dili")
];
public static readonly List<string> Languages =
[
"zh-Hans",
"zh-Hant",
"en",
"fa",
"fr",
"ru",
"hu",
"id"
.. LanguageOptions.Select(t => t.Value)
];
public static readonly List<string> Alpns =
+1 -2
View File
@@ -8,6 +8,7 @@ global using System.Net.NetworkInformation;
global using System.Net.Sockets;
global using ReactiveUI.Primitives;
global using ReactiveUI.Primitives.Concurrency;
global using ReactiveUI.Primitives.Extensions;
global using ReactiveUI.Primitives.Disposables;
global using ReactiveUI.Primitives.Signals;
global using System.Reflection;
@@ -41,5 +42,3 @@ global using ServiceLib.Services;
global using ServiceLib.Services.CoreConfig;
global using ServiceLib.Services.Statistics;
global using SQLite;
@@ -49,6 +49,7 @@ public class CoreConfigContextBuilder
RoutingItem = await ConfigHandler.GetDefaultRouting(config),
IsWindows = Utils.IsWindows(),
IsMacOS = Utils.IsMacOS(),
HasGlobalIPv6Address = Utils.HasGlobalIPv6Address(),
ProtectCoreTypeList = config.TunModeItem.EnableTun ? [ECoreType.Xray, ECoreType.sing_box] : []
};
var validatorResult = NodeValidatorResult.Empty();
+23 -25
View File
@@ -471,12 +471,12 @@ public static class ConfigHandler
/// Supports moving to top, up, down, bottom or specific position
/// </summary>
/// <param name="config">Current configuration</param>
/// <param name="lstProfile">List of server profiles</param>
/// <param name="lstProfile">List of server profile index ids</param>
/// <param name="index">Index of the server to move</param>
/// <param name="eMove">Direction to move the server</param>
/// <param name="pos">Target position when using EMove.Position</param>
/// <returns>0 if successful, -1 if failed</returns>
public static async Task<int> MoveServer(Config config, List<ProfileItem> lstProfile, int index, EMove eMove, int pos = -1)
public static async Task<int> MoveServer(Config config, List<string> lstProfile, int index, EMove eMove, int pos = -1)
{
var count = lstProfile.Count;
if (index < 0 || index > lstProfile.Count - 1)
@@ -486,7 +486,7 @@ public static class ConfigHandler
for (var i = 0; i < lstProfile.Count; i++)
{
ProfileExManager.Instance.SetSort(lstProfile[i].IndexId, (i + 1) * 10);
ProfileExManager.Instance.SetSort(lstProfile[i], (i + 1) * 10);
}
var sort = 0;
@@ -498,7 +498,7 @@ public static class ConfigHandler
{
return 0;
}
sort = ProfileExManager.Instance.GetSort(lstProfile.First().IndexId) - 1;
sort = ProfileExManager.Instance.GetSort(lstProfile.First()) - 1;
break;
}
@@ -508,7 +508,7 @@ public static class ConfigHandler
{
return 0;
}
sort = ProfileExManager.Instance.GetSort(lstProfile[index - 1].IndexId) - 1;
sort = ProfileExManager.Instance.GetSort(lstProfile[index - 1]) - 1;
break;
}
@@ -519,7 +519,7 @@ public static class ConfigHandler
{
return 0;
}
sort = ProfileExManager.Instance.GetSort(lstProfile[index + 1].IndexId) + 1;
sort = ProfileExManager.Instance.GetSort(lstProfile[index + 1]) + 1;
break;
}
@@ -529,7 +529,7 @@ public static class ConfigHandler
{
return 0;
}
sort = ProfileExManager.Instance.GetSort(lstProfile[^1].IndexId) + 1;
sort = ProfileExManager.Instance.GetSort(lstProfile[^1]) + 1;
break;
}
@@ -538,7 +538,7 @@ public static class ConfigHandler
break;
}
ProfileExManager.Instance.SetSort(lstProfile[index].IndexId, sort);
ProfileExManager.Instance.SetSort(lstProfile[index], sort);
return await Task.FromResult(0);
}
@@ -922,6 +922,7 @@ public static class ConfigHandler
WgInterfaceAddress = profileItem.GetProtocolExtra().WgInterfaceAddress?.TrimEx(),
WgReserved = wgReserved,
WgMtu = profileItem.GetProtocolExtra().WgMtu is null or <= 0 ? Global.TunMtus.First() : profileItem.GetProtocolExtra().WgMtu,
WgDns = profileItem.GetProtocolExtra().WgDns?.TrimEx(),
});
if (profileItem.Password.IsNullOrEmpty())
@@ -1753,15 +1754,13 @@ public static class ConfigHandler
{
lstProfiles = SingboxFmt.ResolveToCustomOutbound(strData, subRemarks);
}
if (lstProfiles.Count == 0)
if (lstProfiles.Count > 0)
{
return -1;
}
var count = await AddBatchCustomServers(config, lstProfiles, subid, isSub);
if (count > 0)
{
return count;
var count = await AddBatchCustomServers(config, lstProfiles, subid, isSub);
if (count > 0)
{
return count;
}
}
if (HtmlPageFmt.IsHtmlPage(strData))
@@ -1801,15 +1800,13 @@ public static class ConfigHandler
_ => null,
};
if ((lstProfiles?.Count ?? 0) == 0)
if (lstProfiles?.Count > 0)
{
return -1;
}
var count = await AddBatchCustomServers(config, lstProfiles, subid, isSub);
if (count > 0)
{
return count;
var count = await AddBatchCustomServers(config, lstProfiles, subid, isSub);
if (count > 0)
{
return count;
}
}
return await SaveCustomRawFileServer(config, strData, subid, isSub, subItem, customCoreType);
@@ -2205,6 +2202,7 @@ public static class ConfigHandler
item.Enabled = subItem.Enabled;
item.AutoUpdateInterval = subItem.AutoUpdateInterval;
item.UserAgent = subItem.UserAgent;
item.RequestHeaders = subItem.RequestHeaders;
item.Sort = subItem.Sort;
item.Filter = subItem.Filter;
item.UpdateTime = subItem.UpdateTime;
@@ -2614,7 +2612,7 @@ public static class ConfigHandler
items = await AppManager.Instance.RoutingItems();
}
if (!blImportAdvancedRules && items.Count(u => u.Remarks.StartsWith(ver)) > 0)
if (!blImportAdvancedRules && items.Count() > 0) // items.Count(u => u.Remarks.StartsWith(ver)) > 0)
{
//migrate
//TODO Temporary code to be removed later
@@ -5,14 +5,14 @@ public static class ConnectionHandler
private static readonly string _tag = "ConnectionHandler";
/// <summary>
/// Runs ping and IP checks and returns a formatted result string.
/// Runs ping and IP checks.
/// </summary>
public static async Task<string> RunAvailabilityCheck()
public static async Task<AvailabilityCheckResult> RunAvailabilityCheck()
{
var time = await GetRealPingTimeInfo();
var ip = time > 0 ? await GetIPInfo() : Global.None;
return string.Format(ResUI.TestMeOutput, time, ip);
return new AvailabilityCheckResult(time, ip);
}
/// <summary>
+6 -1
View File
@@ -342,8 +342,13 @@ public class BaseFmt
return query[key] ?? defaultValue;
}
/// <summary>
/// Values are already unescaped by <see cref="Utils.ParseQueryString" />, so this must not
/// unescape them a second time: a value that still holds a valid percent sequence after the
/// first pass - an obfuscation password of "ob%41fs", say - would decay into "obAfs".
/// </summary>
protected static string GetQueryDecoded(NameValueCollection query, string key, string defaultValue = "")
{
return Utils.UrlDecode(GetQueryValue(query, key, defaultValue));
return GetQueryValue(query, key, defaultValue);
}
}
+10 -1
View File
@@ -17,7 +17,11 @@ public class Hysteria2Fmt : BaseFmt
}
item.Address = url.IdnHost;
item.Port = url.Port;
// The URI scheme makes the port optional and defaults it to 443. Uri.Port answers -1 for
// an unregistered scheme carrying no port, which ProfileItem.IsValid then rejects.
// Only -1 means "omitted": an explicit ":0" has to stay 0 and be rejected the way it
// always was, instead of being quietly redirected to a server the link never named.
item.Port = url.Port == -1 ? 443 : url.Port;
item.Remarks = url.GetComponents(UriComponents.Fragment, UriFormat.Unescaped);
item.Password = Utils.UrlDecode(url.UserInfo);
@@ -223,6 +227,11 @@ public class Hysteria2Fmt : BaseFmt
var sha = item.CertSha;
dicQuery.Add("pinSHA256", Utils.UrlEncode(sha));
}
else if (!item.Cert.IsNullOrEmpty()
&& CertPemManager.GetLeafCertSha256Thumbprint(item.Cert) is { Length: > 0 } thumbprint)
{
dicQuery.Add("pinSHA256", Utils.UrlEncode(thumbprint));
}
if (!item.EchConfigList.IsNullOrEmpty())
{
dicQuery.Add("ech", Utils.UrlEncode(item.EchConfigList));
@@ -31,6 +31,7 @@ public class WireguardFmt : BaseFmt
WgReserved = GetQueryDecoded(query, "reserved"),
WgInterfaceAddress = GetQueryDecoded(query, "address"),
WgMtu = int.TryParse(GetQueryDecoded(query, "mtu"), out var mtuVal) ? mtuVal : null,
WgDns = GetQueryDecoded(query, "dns"),
});
return item;
@@ -71,6 +72,10 @@ public class WireguardFmt : BaseFmt
{
dicQuery.Add("mtu", protoExtra.WgMtu.ToString());
}
if (!protoExtra.WgDns.IsNullOrEmpty())
{
dicQuery.Add("dns", Utils.UrlEncode(protoExtra.WgDns));
}
return ToUri(EConfigType.WireGuard, item.Address, item.Port, item.Password, dicQuery, remark);
}
@@ -133,6 +138,7 @@ public class WireguardFmt : BaseFmt
var wgMtu = interfaceDic.TryGetValue("MTU", out var mtuStr) && int.TryParse(mtuStr, out var mtuVal) ? mtuVal : 0;
var wgInterfaceAddress = interfaceDic.TryGetValue("Address", out var interfaceAddress) ? interfaceAddress : string.Empty;
var wgDns = interfaceDic.TryGetValue("DNS", out var dns) ? dns : string.Empty;
var index = 0;
var resultList = new List<ProfileItem>();
@@ -156,6 +162,7 @@ public class WireguardFmt : BaseFmt
WgInterfaceAddress = wgInterfaceAddress,
WgReserved = (peerDic.TryGetValue("Reserved", out var reserved) ? reserved : string.Empty).NullIfEmpty(),
WgMtu = wgMtu > 0 ? wgMtu : null,
WgDns = wgDns,
};
var item = new ProfileItem
@@ -31,7 +31,7 @@ public static class SubscriptionHandler
}
// Create download handler
var downloadHandle = CreateDownloadHandler(hashCode, updateFunc);
var downloadHandle = CreateDownloadHandler(item, hashCode, updateFunc);
await updateFunc?.Invoke(false, $"{hashCode}{ResUI.MsgStartGettingSubscriptions}");
// Get all subscription content (main subscription + additional subscriptions)
@@ -80,9 +80,18 @@ public static class SubscriptionHandler
return true;
}
private static DownloadService CreateDownloadHandler(string hashCode, Func<bool, string, Task> updateFunc)
private static DownloadService CreateDownloadHandler(SubItem item, string hashCode, Func<bool, string, Task> updateFunc)
{
var downloadHandle = new DownloadService();
if (!HttpRequestHeadersHelper.TryParse(item.RequestHeaders, out var requestHeaders))
{
throw new FormatException(ResUI.SubRequestHeadersInvalid);
}
var downloadHandle = new DownloadService
{
AcceptHeader = "*/*",
RequestHeaders = requestHeaders
};
downloadHandle.Error += (sender2, args) =>
{
updateFunc?.Invoke(false, $"{hashCode}{args.GetException().Message}");
+4 -2
View File
@@ -8,7 +8,8 @@ public class DownloaderHelper
private static readonly Lazy<DownloaderHelper> _instance = new(() => new());
public static DownloaderHelper Instance => _instance.Value;
public async Task<string?> DownloadStringAsync(IWebProxy? webProxy, string url, string? userAgent, int timeout)
public async Task<string?> DownloadStringAsync(IWebProxy? webProxy, string url, string? userAgent, int timeout,
IReadOnlyDictionary<string, string>? requestHeaders = null, string? acceptHeader = null)
{
if (url.IsNullOrEmpty())
{
@@ -28,6 +29,7 @@ public class DownloaderHelper
var requestConfiguration = new RequestConfiguration()
{
Headers = headers,
Accept = acceptHeader,
UserAgent = userAgent,
ConnectTimeout = connectTimeout * 1000,
Proxy = webProxy
@@ -37,7 +39,7 @@ public class DownloaderHelper
BlockTimeout = timeout * 1000,
MaxTryAgainOnFailure = 2,
RequestConfiguration = requestConfiguration,
CustomHttpMessageHandlerFactory = () => GetSocketsHttpHandler(requestConfiguration),
CustomHttpMessageHandlerFactory = () => HttpRequestHeadersHelper.CreateHandler(GetSocketsHttpHandler(requestConfiguration), requestHeaders),
};
await using var downloader = new Downloader.DownloadService(downloadOpt);
@@ -0,0 +1,88 @@
namespace ServiceLib.Helper;
public static class HttpRequestHeadersHelper
{
public static bool TryParse(string? json, out Dictionary<string, string> headers)
{
headers = new(StringComparer.OrdinalIgnoreCase);
if (string.IsNullOrWhiteSpace(json))
{
return true;
}
try
{
using var document = JsonDocument.Parse(json);
if (document.RootElement.ValueKind != JsonValueKind.Object)
{
return false;
}
var parsed = new Dictionary<string, string>(StringComparer.OrdinalIgnoreCase);
using var request = new HttpRequestMessage { Content = new ByteArrayContent([]) };
foreach (var property in document.RootElement.EnumerateObject())
{
if (property.Value.ValueKind != JsonValueKind.String
|| !parsed.TryAdd(property.Name, property.Value.GetString()!)
|| !TryAddHeader(request, property.Name, parsed[property.Name]))
{
return false;
}
}
headers = parsed;
return true;
}
catch (Exception ex) when (ex is JsonException or ArgumentException or FormatException)
{
return false;
}
}
public static HttpMessageHandler CreateHandler(HttpMessageHandler innerHandler, IReadOnlyDictionary<string, string>? headers)
{
return headers is { Count: > 0 } ? new RequestHeadersHandler(innerHandler, headers) : innerHandler;
}
private static bool TryAddHeader(HttpRequestMessage request, string name, string value)
{
if (value.Any(c => char.IsControl(c) && c != '\t'))
{
return false;
}
return request.Headers.TryAddWithoutValidation(name, value)
|| request.Content!.Headers.TryAddWithoutValidation(name, value);
}
private sealed class RequestHeadersHandler(HttpMessageHandler innerHandler, IReadOnlyDictionary<string, string> headers)
: DelegatingHandler(innerHandler)
{
protected override Task<HttpResponseMessage> SendAsync(HttpRequestMessage request, CancellationToken cancellationToken)
{
using var customHeaders = new HttpRequestMessage { Content = new ByteArrayContent([]) };
foreach (var header in headers)
{
if (!TryAddHeader(customHeaders, header.Key, header.Value))
{
throw new FormatException(ResUI.SubRequestHeadersInvalid);
}
}
// Apply after each downloader's defaults, replacing headers without changing their values.
foreach (var header in customHeaders.Headers.NonValidated)
{
request.Headers.Remove(header.Key);
request.Headers.TryAddWithoutValidation(header.Key, header.Value);
}
foreach (var header in customHeaders.Content.Headers.NonValidated)
{
request.Content ??= new ByteArrayContent([]);
request.Content.Headers.Remove(header.Key);
request.Content.Headers.TryAddWithoutValidation(header.Key, header.Value);
}
return base.SendAsync(request, cancellationToken);
}
}
}
@@ -281,6 +281,37 @@ public class CertPemManager
}
}
public static string GetLeafCertSha256Thumbprint(string pemCertChain, bool includeColon = false)
{
var certs = ParsePemChain(pemCertChain);
if (certs.Count == 0)
{
return string.Empty;
}
foreach (var certStr in certs)
{
try
{
var cert = X509Certificate2.CreateFromPem(certStr);
var extension = cert.Extensions
.OfType<X509BasicConstraintsExtension>()
.FirstOrDefault();
var isCa = extension?.CertificateAuthority == true;
if (isCa)
{
continue;
}
var thumbprint = cert.GetCertHashString(HashAlgorithmName.SHA256);
return includeColon ? string.Join(":", thumbprint.Chunk(2).Select(c => new string(c))) : thumbprint;
}
catch
{
// Ignore
}
}
return string.Empty;
}
private static readonly Lazy<X509Certificate2Collection> _chromeRootCerts = new(() =>
{
var pemText = EmbedUtils.GetEmbedText(Global.ChromeRootCertFileName);
+117 -103
View File
@@ -1,113 +1,104 @@
using static ServiceLib.Models.Dto.ClashProxies;
namespace ServiceLib.Manager;
public sealed class ClashApiManager
{
private const string _tag = "ClashApiManager";
private static readonly Lazy<ClashApiManager> instance = new(() => new());
public static ClashApiManager Instance => instance.Value;
private static readonly string _tag = "ClashApiHandler";
private Dictionary<string, ProxiesItem>? _proxies;
public Dictionary<string, object> ProfileContent { get; set; }
private static string ApiUrl => $"{Global.HttpProtocol}{Global.Loopback}:{AppManager.Instance.StatePort2}";
public async Task<Tuple<ClashProxies, ClashProviders>?> GetClashProxiesAsync()
public async Task<ClashItem?> GetProxies()
{
for (var i = 0; i < 3; i++)
{
var url = $"{GetApiUrl()}/proxies";
var result = await HttpClientHelper.Instance.TryGetAsync(url);
var clashProxies = JsonUtils.Deserialize<ClashProxies>(result);
var url = $"{ApiUrl}/proxies";
var resultTask = HttpClientHelper.Instance.TryGetAsync(url);
var url2 = $"{GetApiUrl()}/providers/proxies";
var result2 = await HttpClientHelper.Instance.TryGetAsync(url2);
var url2 = $"{ApiUrl}/providers/proxies";
var result2Task = HttpClientHelper.Instance.TryGetAsync(url2);
await Task.WhenAll(resultTask, result2Task);
var result = await resultTask;
var result2 = await result2Task;
var clashProxies = JsonUtils.Deserialize<ClashProxies>(result);
var clashProviders = JsonUtils.Deserialize<ClashProviders>(result2);
if (clashProxies != null || clashProviders != null)
if (clashProxies is null && clashProviders is null)
{
_proxies = clashProxies?.proxies;
return new Tuple<ClashProxies, ClashProviders>(clashProxies, clashProviders);
await Task.Delay(2000);
continue;
}
await Task.Delay(2000);
var item = new ClashItem();
if (clashProxies?.proxies is { Count: > 0 } proxies)
{
item = item with { Proxies = proxies };
}
if (clashProviders.providers is { Count: > 0 } providers)
{
foreach (var provider in providers)
{
foreach (var proxy in provider.Value.proxies ?? [])
{
if (string.IsNullOrEmpty(proxy.name)
|| !item.Proxies.TryAdd(proxy.name, proxy))
{
continue;
}
item.ProviderIndexMap.Add(proxy.name, provider.Key);
}
}
}
return item;
}
return null;
}
public void ClashProxiesDelayTest(bool blAll, List<ClashProxyModel> lstProxy, Func<ClashProxyModel?, string, Task> updateFunc)
public async Task<int> TestDelay(string name, ClashItem? clashItem)
{
Task.Run(async () =>
if (clashItem?.ProviderIndexMap.TryGetValue(name, out var providerName) == true)
{
if (blAll)
{
if (_proxies == null)
{
await GetClashProxiesAsync();
}
lstProxy = [];
lstProxy.AddRange(from kv in _proxies ?? []
where !Global.notAllowTestType.Contains(kv.Value.type?.ToLower())
select new ClashProxyModel()
{
Name = kv.Value.name,
Type = kv.Value.type?.ToLower(),
});
}
if (lstProxy is not { Count: > 0 })
{
return;
}
var urlBase = $"{GetApiUrl()}/proxies";
urlBase += @"/{0}/delay?timeout=10000&url=" + AppManager.Instance.Config.SpeedTestItem.SpeedPingTestUrl;
var tasks = new List<Task>();
foreach (var it in lstProxy)
{
if (Global.notAllowTestType.Contains(it.Type.ToLower()))
{
continue;
}
var name = it.Name;
var url = string.Format(urlBase, name);
tasks.Add(Task.Run(async () =>
{
var result = await HttpClientHelper.Instance.TryGetAsync(url);
await updateFunc?.Invoke(it, result);
}));
}
await Task.WhenAll(tasks);
await Task.Delay(1000);
await updateFunc?.Invoke(null, "");
});
return await TestProviderProxyDelay(name, providerName);
}
return await TestProxyDelay(name);
}
public List<ProxiesItem>? GetClashProxyGroups()
private async Task<int> TestProxyDelay(string name)
{
var url = $"{ApiUrl}/proxies/{Utils.UrlEncode(name)}/delay?timeout=10000&url=" +
Utils.UrlEncode(AppManager.Instance.Config.SpeedTestItem.SpeedPingTestUrl);
return await SendTestRequest(url);
}
private async Task<int> TestProviderProxyDelay(string name, string providerName)
{
var url =
$"{ApiUrl}/providers/proxies/{Utils.UrlEncode(providerName)}/{Utils.UrlEncode(name)}/healthcheck?timeout=10000&url=" +
Utils.UrlEncode(AppManager.Instance.Config.SpeedTestItem.SpeedPingTestUrl);
return await SendTestRequest(url);
}
private async Task<int> SendTestRequest(string url)
{
var result = await HttpClientHelper.Instance.TryGetAsync(url);
var jsonObject = JsonUtils.ParseJson(result) as JsonObject;
return jsonObject?["delay"] is { } n && n.GetValueKind() == JsonValueKind.Number &&
n.GetValue<JsonElement>().TryGetInt32(out var v)
? v
: -1;
}
public async Task SetActiveProxy(string groupName, string nodeName)
{
try
{
var fileContent = ProfileContent;
if (fileContent is null || fileContent?.ContainsKey("proxy-groups") == false)
{
return null;
}
return JsonUtils.Deserialize<List<ProxiesItem>>(JsonUtils.Serialize(fileContent["proxy-groups"]));
}
catch (Exception ex)
{
Logging.SaveLog(_tag, ex);
return null;
}
}
public async Task ClashSetActiveProxy(string name, string nameNode)
{
try
{
var url = $"{GetApiUrl()}/proxies/{name}";
var url = $"{ApiUrl}/proxies/{Utils.UrlEncode(groupName)}";
var headers = new Dictionary<string, string>();
headers.Add("name", nameNode);
headers.Add("name", nodeName);
await HttpClientHelper.Instance.PutAsync(url, headers);
}
catch (Exception ex)
@@ -116,39 +107,67 @@ public sealed class ClashApiManager
}
}
public async Task ClashConfigUpdate(Dictionary<string, string> headers)
public async Task UpdateClashMode(string mode)
{
if (_proxies == null)
var headers = new Dictionary<string, string>
{
return;
}
{ "mode", mode },
};
await UpdateConfig(headers);
}
var urlBase = $"{GetApiUrl()}/configs";
public async Task UpdateConfig(Dictionary<string, string> headers)
{
var urlBase = $"{ApiUrl}/configs";
await HttpClientHelper.Instance.PatchAsync(urlBase, headers);
}
public async Task ClashConfigReload(string filePath)
public async Task<List<string>> GetClashModes()
{
await ClashConnectionClose("");
try
var jsonNode = await GetConfig();
if ((jsonNode?["mode-list"] ?? jsonNode?["modes"]) is not JsonArray { Count: > 0 } modesArray)
{
var url = $"{GetApiUrl()}/configs?force=true";
var headers = new Dictionary<string, string>();
headers.Add("path", filePath);
await HttpClientHelper.Instance.PutAsync(url, headers);
return [];
}
catch (Exception ex)
var modes = new List<string>();
foreach (var mode in modesArray)
{
Logging.SaveLog(_tag, ex);
if (mode is JsonValue jsonValue && jsonValue.GetValueKind() == JsonValueKind.String)
{
modes.Add(jsonValue.GetValue<string>());
}
}
return modes;
}
public async Task<ClashConnections?> GetClashConnectionsAsync()
public async Task<string?> GetClashMode()
{
var jsonNode = await GetConfig();
if (jsonNode["mode"] is not JsonValue jsonValue || jsonValue.GetValueKind() != JsonValueKind.String)
{
return null;
}
return jsonValue.GetValue<string>();
}
public async Task<JsonObject> GetConfig()
{
var url = $"{ApiUrl}/configs";
var result = await HttpClientHelper.Instance.TryGetAsync(url);
var jsonNode = JsonUtils.ParseJson(result);
if (jsonNode is not JsonObject jsonObject)
{
return new JsonObject();
}
return jsonObject;
}
public async Task<ClashConnections?> GetConnections()
{
try
{
var url = $"{GetApiUrl()}/connections";
var url = $"{ApiUrl}/connections";
var result = await HttpClientHelper.Instance.TryGetAsync(url);
var clashConnections = JsonUtils.Deserialize<ClashConnections>(result);
@@ -162,11 +181,11 @@ public sealed class ClashApiManager
return null;
}
public async Task ClashConnectionClose(string id)
public async Task CloseConnection(string id)
{
try
{
var url = $"{GetApiUrl()}/connections/{id}";
var url = $"{ApiUrl}/connections/{id}";
await HttpClientHelper.Instance.DeleteAsync(url);
}
catch (Exception ex)
@@ -174,9 +193,4 @@ public sealed class ClashApiManager
Logging.SaveLog(_tag, ex);
}
}
private string GetApiUrl()
{
return $"{Global.HttpProtocol}{Global.Loopback}:{AppManager.Instance.StatePort2}";
}
}
@@ -229,6 +229,7 @@ public sealed class CoreInfoManager
DownloadUrlLinuxLoong64 = urlSingbox + "/download/{0}/sing-box-{1}-linux-loong64.tar.gz",
DownloadUrlOSX64 = urlSingbox + "/download/{0}/sing-box-{1}-darwin-amd64.tar.gz",
DownloadUrlOSXArm64 = urlSingbox + "/download/{0}/sing-box-{1}-darwin-arm64.tar.gz",
LockedMaxVersion = new SemanticVersion(1, 14, int.MaxValue),
Match = "sing-box",
VersionArg = "version",
},
+8 -4
View File
@@ -12,7 +12,11 @@ public class TaskManager
_config = config;
_updateFunc = updateFunc;
Task.Run(ScheduledTasks);
_ = Task.Factory.StartNew(
ScheduledTasks,
CancellationToken.None,
TaskCreationOptions.LongRunning,
TaskScheduler.Default);
}
private async Task ScheduledTasks()
@@ -57,8 +61,8 @@ public class TaskManager
//Logging.SaveLog("Execute delete expired files");
FileUtils.DeleteExpiredFiles(Utils.GetBinConfigPath(), DateTime.Now.AddHours(-1), "Test");
FileUtils.DeleteExpiredFiles(Utils.GetLogPath(), DateTime.Now.AddMonths(-1));
FileUtils.DeleteExpiredFiles(Utils.GetTempPath(), DateTime.Now.AddMonths(-1));
FileUtils.DeleteExpiredFiles(Utils.GetLogPath(), DateTime.Now.AddDays(-7));
FileUtils.DeleteExpiredFiles(Utils.GetTempPath(), DateTime.Now.AddDays(-7));
try
{
@@ -136,7 +140,7 @@ public class TaskManager
var updateService = new UpdateService(_config, async (success, msg) => await Task.CompletedTask);
var msgs = await updateService.CheckHasUpdateOnlyAll(_config.CheckUpdateItem.CheckPreReleaseUpdate);
var msgs = await updateService.CheckHasUpdateOnlyAll(_config.CheckUpdateItem.CheckPreReleaseUpdate, _config.CheckUpdateItem.UpdateViaProxy);
foreach (var msg in msgs)
{
await _updateFunc?.Invoke(false, msg);
@@ -209,7 +209,6 @@ public class HysteriaItem
[Serializable]
public class ClashUIItem
{
public ERuleMode RuleMode { get; set; }
public bool EnableIPv6 { get; set; }
public bool EnableMixinContent { get; set; }
public int ProxiesSorting { get; set; }
@@ -244,6 +243,7 @@ public class WebDavItem
public class CheckUpdateItem
{
public bool CheckPreReleaseUpdate { get; set; }
public bool UpdateViaProxy { get; set; } = true;
public List<string>? SelectedCoreTypes { get; set; }
}
@@ -25,6 +25,10 @@ public record CoreConfigContext
public bool IsWindows { get; init; }
public bool IsMacOS { get; init; }
// Defaults to true so that a context built without this flag keeps routing IPv6 into the
// tunnel; only a positive detection of the host having no global IPv6 address turns it off.
public bool HasGlobalIPv6Address { get; init; } = true;
// Generation Context
public Dictionary<object, string> CustomOutboundMap { get; init; } = new();
}
@@ -16,6 +16,7 @@ public class CoreInfo
public string? DownloadUrlLinuxLoong64 { get; set; }
public string? DownloadUrlOSX64 { get; set; }
public string? DownloadUrlOSXArm64 { get; set; }
public SemanticVersion? LockedMaxVersion { get; set; }
public string? Match { get; set; }
public string? VersionArg { get; set; }
public bool AbsolutePath { get; set; }
@@ -8,6 +8,7 @@ public class SingboxConfig
public List<Outbound4Sbox> outbounds { get; set; }
public List<Endpoints4Sbox>? endpoints { get; set; }
public Route4Sbox route { get; set; }
public List<HttpClient4Sbox>? http_clients { get; set; }
public Experimental4Sbox? experimental { get; set; }
}
@@ -24,6 +25,7 @@ public class Dns4Sbox
public List<Server4Sbox> servers { get; set; }
public List<Rule4Sbox> rules { get; set; }
public string? final { get; set; }
public bool? optimistic { get; set; }
public string? strategy { get; set; }
public bool? disable_cache { get; set; }
public bool? disable_expire { get; set; }
@@ -94,6 +96,11 @@ public class Rule4Sbox
public bool? rule_set_ip_cidr_match_source { get; set; }
public bool? rule_set_ip_cidr_accept_empty { get; set; }
public bool? tls_record_fragment { get; set; }
public string? preferred_by { get; set; }
public string? match_response { get; set; } // or bool
public string? tag { get; set; }
public bool? race { get; set; }
public bool? speculative { get; set; }
}
[Serializable]
@@ -108,7 +115,6 @@ public class Inbound4Sbox
public int? mtu { get; set; }
public bool? auto_route { get; set; }
public bool? strict_route { get; set; }
public bool? endpoint_independent_nat { get; set; }
public string? stack { get; set; }
public List<User4Sbox> users { get; set; }
public List<string>? route_exclude_address { get; set; }
@@ -318,10 +324,12 @@ public class Ruleset4Sbox
public string? format { get; set; }
public string? path { get; set; }
public string? url { get; set; }
public string? download_detour { get; set; }
public string? http_client { get; set; }
public string? update_interval { get; set; }
}
public class HttpClient4Sbox : BaseServer4Sbox;
public abstract class DialFields4Sbox
{
public string? detour { get; set; }
@@ -75,8 +75,6 @@ public class Inboundsettings4Ray
public string? address { get; set; }
public List<UsersItem4Ray>? clients { get; set; }
public string? decryption { get; set; }
public bool? allowTransparent { get; set; }
@@ -96,21 +94,6 @@ public class Inboundsettings4Ray
public List<string>? dns { get; set; }
}
public class UsersItem4Ray
{
public string? id { get; set; }
public int? alterId { get; set; }
public string? email { get; set; }
public string? security { get; set; }
public string? encryption { get; set; }
public string? flow { get; set; }
}
public class Sniffing4Ray
{
public bool enabled { get; set; }
@@ -137,10 +120,6 @@ public class Outbounds4Ray
public class Outboundsettings4Ray
{
public List<VnextItem4Ray>? vnext { get; set; }
public List<ServersItem4Ray>? servers { get; set; }
public Response4Ray? response { get; set; }
public int? userLevel { get; set; }
@@ -172,29 +151,18 @@ public class Outboundsettings4Ray
public int? workers { get; set; }
public int? version { get; set; }
}
public class WireguardPeer4Ray
{
public string endpoint { get; set; }
public string publicKey { get; set; }
public string? preSharedKey { get; set; }
}
public List<string>? remoteDNS { get; set; }
public class VnextItem4Ray
{
public string address { get; set; }
public string? id { get; set; }
public int port { get; set; }
public int? alterId { get; set; }
public List<UsersItem4Ray> users { get; set; }
}
public string? security { get; set; }
public class ServersItem4Ray
{
public string email { get; set; }
public string? encryption { get; set; }
public string address { get; set; }
public string? flow { get; set; }
public string? method { get; set; }
@@ -202,24 +170,14 @@ public class ServersItem4Ray
public string? password { get; set; }
public int port { get; set; }
public int? level { get; set; }
public string flow { get; set; }
public bool? uot { get; set; }
public List<SocksUsersItem4Ray> users { get; set; }
}
public class SocksUsersItem4Ray
public class WireguardPeer4Ray
{
public string user { get; set; }
public string pass { get; set; }
public int? level { get; set; }
public string endpoint { get; set; }
public string publicKey { get; set; }
public string? preSharedKey { get; set; }
}
public class Mux4Ray
@@ -0,0 +1,6 @@
namespace ServiceLib.Models.Dto;
public sealed record AvailabilityCheckResult(int Time, string Ip)
{
public string? GetValidIp() => Ip == Global.None ? null : Ip;
}
@@ -13,4 +13,5 @@ public class ClashConnectionModel
public double Time { get; set; }
public string? Elapsed { get; set; }
public string? Chain { get; set; }
public string? ProcessPath { get; set; }
}
@@ -1,36 +1,37 @@
namespace ServiceLib.Models.Dto;
public class ClashConnections
public record ClashConnections
{
public ulong downloadTotal { get; set; }
public ulong uploadTotal { get; set; }
public List<ConnectionItem>? connections { get; set; }
public ulong downloadTotal { get; init; }
public ulong uploadTotal { get; init; }
public List<ConnectionItem>? connections { get; init; }
}
public class ConnectionItem
public record ConnectionItem
{
public string? id { get; set; }
public MetadataItem? metadata { get; set; }
public ulong upload { get; set; }
public ulong download { get; set; }
public DateTime start { get; set; }
public List<string>? chains { get; set; }
public string? rule { get; set; }
public string? rulePayload { get; set; }
public string? id { get; init; }
public MetadataItem? metadata { get; init; }
public ulong upload { get; init; }
public ulong download { get; init; }
public DateTime start { get; init; }
public List<string>? chains { get; init; }
public string? rule { get; init; }
public string? rulePayload { get; init; }
}
public class MetadataItem
public record MetadataItem
{
public string? network { get; set; }
public string? type { get; set; }
public string? sourceIP { get; set; }
public string? destinationIP { get; set; }
public string? sourcePort { get; set; }
public string? destinationPort { get; set; }
public string? host { get; set; }
public string? nsMode { get; set; }
public object? uid { get; set; }
public string? process { get; set; }
public string? processPath { get; set; }
public string? remoteDestination { get; set; }
public string? network { get; init; }
public string? type { get; init; }
public string? sourceIP { get; init; }
public string? destinationIP { get; init; }
public string? sourcePort { get; init; }
public string? destinationPort { get; init; }
public string? host { get; init; }
public string? nsMode { get; init; }
public object? uid { get; init; }
public string? process { get; init; }
public string? processPath { get; init; }
public string? remoteDestination { get; init; }
public string? sniffHost { get; init; }
}
+44
View File
@@ -0,0 +1,44 @@
namespace ServiceLib.Models.Dto;
public record ClashItem
{
public Dictionary<string, ClashProxy> Proxies { get; init; } = [];
public Dictionary<string, string> ProviderIndexMap { get; init; } = [];
public bool IsEmpty() => Proxies.Count == 0;
}
public record ClashProxy
{
public List<string>? all { get; init; }
public List<HistoryItem>? history { get; init; }
public string? name { get; init; }
public string? type { get; init; }
public bool udp { get; init; }
public string? now { get; init; }
public int delay { get; init; }
public record HistoryItem
{
public string? time { get; init; }
public int delay { get; init; }
}
}
public record ClashProvider
{
public string? name { get; init; }
public List<ClashProxy>? proxies { get; init; }
public string? type { get; init; }
public string? vehicleType { get; init; }
}
public record ClashProxies
{
public Dictionary<string, ClashProxy> proxies { get; init; } = [];
}
public record ClashProviders
{
public Dictionary<string, ClashProvider> providers { get; init; } = [];
}
@@ -1,16 +0,0 @@
using static ServiceLib.Models.Dto.ClashProxies;
namespace ServiceLib.Models.Dto;
public class ClashProviders
{
public Dictionary<string, ProvidersItem>? providers { get; set; }
public class ProvidersItem
{
public string? name { get; set; }
public List<ProxiesItem>? proxies { get; set; }
public string? type { get; set; }
public string? vehicleType { get; set; }
}
}
@@ -1,23 +0,0 @@
namespace ServiceLib.Models.Dto;
public class ClashProxies
{
public Dictionary<string, ProxiesItem>? proxies { get; set; }
public class ProxiesItem
{
public List<string>? all { get; set; }
public List<HistoryItem>? history { get; set; }
public string? name { get; set; }
public string? type { get; set; }
public bool udp { get; set; }
public string? now { get; set; }
public int delay { get; set; }
}
public class HistoryItem
{
public string? time { get; set; }
public int delay { get; set; }
}
}
@@ -3,9 +3,9 @@ namespace ServiceLib.Models.Dto;
[Serializable]
public partial class ClashProxyModel : ReactiveObject
{
public string? Name { get; set; }
public required string Name { get; set; }
public string? Type { get; set; }
public required string Type { get; set; }
public string? Now { get; set; }
@@ -0,0 +1,3 @@
namespace ServiceLib.Models.Dto;
public sealed record LanguageOption(string Value, string Display);
+171 -103
View File
@@ -1,18 +1,20 @@
namespace ServiceLib.Models.Dto;
public class SemanticVersion
public class SemanticVersion : IEquatable<SemanticVersion>, IComparable
{
private readonly string? build;
private readonly int major;
private readonly int minor;
private readonly int patch;
private readonly string version;
private readonly string? prerelease;
private readonly string raw;
public SemanticVersion(int major, int minor, int patch)
{
this.major = major;
this.minor = minor;
this.patch = patch;
version = $"{major}.{minor}.{patch}";
raw = $"{major}.{minor}.{patch}";
}
public SemanticVersion(string? version)
@@ -24,26 +26,41 @@ public class SemanticVersion
major = 0;
minor = 0;
patch = 0;
raw = $"{major}.{minor}.{patch}";
return;
}
this.version = version.RemovePrefix('v');
raw = version;
var parts = this.version.Split('.');
if (parts.Length == 2)
var span = version.StartsWith("v", StringComparison.OrdinalIgnoreCase)
? version.AsSpan(1)
: version.AsSpan();
var plusIdx = span.IndexOf('+');
var metadataSpan = plusIdx >= 0 ? span[(plusIdx + 1)..] : [];
var leftOfPlus = plusIdx >= 0 ? span[..plusIdx] : span;
var dashIdx = leftOfPlus.IndexOf('-');
var versionSpan = dashIdx >= 0 ? leftOfPlus[..dashIdx] : leftOfPlus;
var preReleaseSpan = dashIdx >= 0 ? leftOfPlus[(dashIdx + 1)..] : [];
build = metadataSpan.Length > 0 ? metadataSpan.ToString() : null;
prerelease = preReleaseSpan.Length > 0 ? preReleaseSpan.ToString() : null;
var parts = versionSpan.ToString().Split('.');
switch (parts.Length)
{
major = int.Parse(parts.First());
minor = int.Parse(parts.Last());
patch = 0;
}
else if (parts.Length is 3 or 4)
{
major = int.Parse(parts[0]);
minor = int.Parse(parts[1]);
patch = int.Parse(parts[2]);
}
else
{
throw new ArgumentException("Invalid version string");
case 2:
major = int.Parse(parts.First());
minor = int.Parse(parts.Last());
patch = 0;
break;
case 3 or 4:
major = int.Parse(parts[0]);
minor = int.Parse(parts[1]);
patch = int.Parse(parts[2]);
break;
default:
throw new ArgumentException("Invalid version string");
}
}
catch
@@ -51,135 +68,186 @@ public class SemanticVersion
major = 0;
minor = 0;
patch = 0;
raw = $"{major}.{minor}.{patch}";
}
}
public bool Equals(SemanticVersion? other)
{
if (other is null)
{
return false;
}
if (ReferenceEquals(this, other))
{
return true;
}
return major == other.major && minor == other.minor && patch == other.patch && prerelease == other.prerelease;
}
public static bool TryParse(string? version, out SemanticVersion? result)
{
try
{
result = new SemanticVersion(version);
return true;
}
catch
{
result = null;
return false;
}
}
public override bool Equals(object? obj)
{
if (obj is SemanticVersion other)
{
return major == other.major && minor == other.minor && patch == other.patch;
}
else
{
return false;
}
return obj is SemanticVersion other && Equals(other);
}
public override int GetHashCode()
{
return major.GetHashCode() ^ minor.GetHashCode() ^ patch.GetHashCode();
return HashCode.Combine(major, minor, patch, prerelease);
}
/// <summary>
/// Use ToVersionString(string? prefix) instead if possible.
/// </summary>
/// <returns>major.minor.patch</returns>
public override string ToString()
{
return version;
return raw;
}
public string ToVersionString(string? prefix = null)
public string ToStandardVersionString(string? prefix = null)
{
if (prefix == null)
var sb = new StringBuilder();
if (!string.IsNullOrEmpty(prefix))
{
return version;
sb.Append(prefix);
}
else
sb.Append($"{major}.{minor}.{patch}");
if (!string.IsNullOrEmpty(prerelease))
{
return $"{prefix}{version}";
sb.Append($"-{prerelease}");
}
if (!string.IsNullOrEmpty(build))
{
sb.Append($"+{build}");
}
return sb.ToString();
}
public static bool operator ==(SemanticVersion v1, SemanticVersion v2)
{ return v1.Equals(v2); }
public static bool operator <(SemanticVersion left, SemanticVersion right)
{
return left.CompareTo(right) < 0;
}
public static bool operator !=(SemanticVersion v1, SemanticVersion v2)
{ return !v1.Equals(v2); }
public static bool operator >(SemanticVersion left, SemanticVersion right)
{
return left.CompareTo(right) > 0;
}
public static bool operator >=(SemanticVersion v1, SemanticVersion v2)
{ return v1.GreaterEquals(v2); }
public static bool operator <=(SemanticVersion left, SemanticVersion right)
{
return left.CompareTo(right) <= 0;
}
public static bool operator <=(SemanticVersion v1, SemanticVersion v2)
{ return v1.LessEquals(v2); }
public static bool operator >=(SemanticVersion left, SemanticVersion right)
{
return left.CompareTo(right) >= 0;
}
#region Private
private bool GreaterEquals(SemanticVersion other)
public int CompareTo(SemanticVersion other)
{
if (major < other.major)
if (major != other.major)
{
return false;
return major.CompareTo(other.major);
}
else if (major > other.major)
if (minor != other.minor)
{
return true;
return minor.CompareTo(other.minor);
}
else
if (patch != other.patch)
{
if (minor < other.minor)
return patch.CompareTo(other.patch);
}
return ComparePreRelease(prerelease, other.prerelease);
}
public int CompareTo(object? obj)
{
if (obj is null)
{
return 1;
}
if (obj is SemanticVersion other)
{
return CompareTo(other);
}
throw new ArgumentException("Object is not a SemanticVersion");
}
private static int ComparePreRelease(string? left, string? right)
{
if (string.IsNullOrEmpty(left) && string.IsNullOrEmpty(right))
{
return 0;
}
if (string.IsNullOrEmpty(left))
{
return 1;
}
if (string.IsNullOrEmpty(right))
{
return -1;
}
var leftSpan = left.AsSpan();
var rightSpan = right.AsSpan();
using var leftEnum = leftSpan.Split('.').GetEnumerator();
using var rightEnum = rightSpan.Split('.').GetEnumerator();
while (true)
{
var hasLeft = leftEnum.MoveNext();
var hasRight = rightEnum.MoveNext();
if (!hasLeft && !hasRight)
{
return false;
return 0;
}
else if (minor > other.minor)
if (!hasLeft)
{
return true;
return -1;
}
else
if (!hasRight)
{
if (patch < other.patch)
{
return false;
}
else if (patch > other.patch)
{
return true;
}
else
{
return true;
}
return 1;
}
var leftSegment = leftSpan[leftEnum.Current];
var rightSegment = rightSpan[rightEnum.Current];
var segCmp = CompareSegment(leftSegment, rightSegment);
if (segCmp != 0)
{
return segCmp;
}
}
}
private bool LessEquals(SemanticVersion other)
private static int CompareSegment(ReadOnlySpan<char> left, ReadOnlySpan<char> right)
{
if (major < other.major)
var leftIsNum = ulong.TryParse(left, out var leftNum);
var rightIsNum = ulong.TryParse(right, out var rightNum);
return (leftIsNum, rightIsNum) switch
{
return true;
}
else if (major > other.major)
{
return false;
}
else
{
if (minor < other.minor)
{
return true;
}
else if (minor > other.minor)
{
return false;
}
else
{
if (patch < other.patch)
{
return true;
}
else if (patch > other.patch)
{
return false;
}
else
{
return true;
}
}
}
(true, true) => leftNum.CompareTo(rightNum),
(false, true) => 1,
(true, false) => -1,
(false, false) => left.SequenceCompareTo(right),
};
}
#endregion Private
@@ -27,6 +27,7 @@ public record ProtocolExtraItem
public string? WgInterfaceAddress { get; init; }
public string? WgReserved { get; init; }
public int? WgMtu { get; init; }
public string? WgDns { get; init; }
// hysteria2
public string? SalamanderPass { get; init; }
@@ -16,6 +16,8 @@ public class SubItem
public string UserAgent { get; set; } = string.Empty;
public string? RequestHeaders { get; set; }
public int Sort { get; set; }
public string? Filter { get; set; }
+99 -9
View File
@@ -555,6 +555,15 @@ namespace ServiceLib.Resx {
}
}
/// <summary>
/// 查找类似 HTTP headers (JSON) 的本地化字符串。
/// </summary>
public static string LvRequestHeaders {
get {
return ResourceManager.GetString("LvRequestHeaders", resourceCulture);
}
}
/// <summary>
/// 查找类似 Type 的本地化字符串。
/// </summary>
@@ -987,6 +996,15 @@ namespace ServiceLib.Resx {
}
}
/// <summary>
/// 查找类似 Latency Test 的本地化字符串。
/// </summary>
public static string menuDelaytest {
get {
return ResourceManager.GetString("menuDelaytest", resourceCulture);
}
}
/// <summary>
/// 查找类似 DNS Settings 的本地化字符串。
/// </summary>
@@ -1401,15 +1419,6 @@ namespace ServiceLib.Resx {
}
}
/// <summary>
/// 查找类似 Latency Test 的本地化字符串。
/// </summary>
public static string menuProxiesDelaytest {
get {
return ResourceManager.GetString("menuProxiesDelaytest", resourceCulture);
}
}
/// <summary>
/// 查找类似 Part Node Latency Test 的本地化字符串。
/// </summary>
@@ -2634,6 +2643,24 @@ namespace ServiceLib.Resx {
}
}
/// <summary>
/// 查找类似 Invalid HTTP headers. Use a JSON object with unique header names and string values. Header names and values must not contain line breaks. 的本地化字符串。
/// </summary>
public static string SubRequestHeadersInvalid {
get {
return ResourceManager.GetString("SubRequestHeadersInvalid", resourceCulture);
}
}
/// <summary>
/// 查找类似 Optional. Example: {&quot;X-hwid&quot;: &quot;my_device&quot;}. Values override default headers for all URLs in this group, including the subscription conversion service. 的本地化字符串。
/// </summary>
public static string SubRequestHeadersTips {
get {
return ResourceManager.GetString("SubRequestHeadersTips", resourceCulture);
}
}
/// <summary>
/// 查找类似 For group please leave blank here 的本地化字符串。
/// </summary>
@@ -3015,6 +3042,15 @@ namespace ServiceLib.Resx {
}
}
/// <summary>
/// 查找类似 DNS 的本地化字符串。
/// </summary>
public static string TbDNS {
get {
return ResourceManager.GetString("TbDNS", resourceCulture);
}
}
/// <summary>
/// 查找类似 DNS Hosts: (&quot;domain1 ip1 ip2&quot; per line) 的本地化字符串。
/// </summary>
@@ -4266,6 +4302,15 @@ namespace ServiceLib.Resx {
}
}
/// <summary>
/// 查找类似 Update via proxy 的本地化字符串。
/// </summary>
public static string TbSettingsEnableUpdateViaProxy {
get {
return ResourceManager.GetString("TbSettingsEnableUpdateViaProxy", resourceCulture);
}
}
/// <summary>
/// 查找类似 Exception 的本地化字符串。
/// </summary>
@@ -4527,6 +4572,42 @@ namespace ServiceLib.Resx {
}
}
/// <summary>
/// 查找类似 Xray Mux setting 的本地化字符串。
/// </summary>
public static string TbSettingsMux4Ray {
get {
return ResourceManager.GetString("TbSettingsMux4Ray", resourceCulture);
}
}
/// <summary>
/// 查找类似 Xray Mux concurrency 的本地化字符串。
/// </summary>
public static string TbSettingsMux4RayConcurrency {
get {
return ResourceManager.GetString("TbSettingsMux4RayConcurrency", resourceCulture);
}
}
/// <summary>
/// 查找类似 Xray Mux XUDP concurrency 的本地化字符串。
/// </summary>
public static string TbSettingsMux4RayXudpConcurrency {
get {
return ResourceManager.GetString("TbSettingsMux4RayXudpConcurrency", resourceCulture);
}
}
/// <summary>
/// 查找类似 Xray Mux XUDP proxy UDP443 的本地化字符串。
/// </summary>
public static string TbSettingsMux4RayXudpProxyUDP443 {
get {
return ResourceManager.GetString("TbSettingsMux4RayXudpProxyUDP443", resourceCulture);
}
}
/// <summary>
/// 查找类似 sing-box Mux Protocol 的本地化字符串。
/// </summary>
@@ -4941,6 +5022,15 @@ namespace ServiceLib.Resx {
}
}
/// <summary>
/// 查找类似 Process 的本地化字符串。
/// </summary>
public static string TbSortingProcess {
get {
return ResourceManager.GetString("TbSortingProcess", resourceCulture);
}
}
/// <summary>
/// 查找类似 Time 的本地化字符串。
/// </summary>
File diff suppressed because it is too large Load Diff
+4 -1
View File
@@ -693,6 +693,9 @@
<data name="TbSettingsEnableCheckPreReleaseUpdate" xml:space="preserve">
<value>به روز رسانی های پیش از انتشار را بررسی شود</value>
</data>
<data name="TbSettingsEnableUpdateViaProxy" xml:space="preserve">
<value>به‌روزرسانی از طریق پروکسی</value>
</data>
<data name="TbSettingsException" xml:space="preserve">
<value>استثنا</value>
</data>
@@ -1215,7 +1218,7 @@
<data name="menuModeRule" xml:space="preserve">
<value>قانون</value>
</data>
<data name="menuProxiesDelaytest" xml:space="preserve">
<data name="menuDelaytest" xml:space="preserve">
<value>تست تأخیر</value>
</data>
<data name="menuProxiesDelaytestPart" xml:space="preserve">
+1 -1
View File
@@ -1212,7 +1212,7 @@
<data name="menuModeRule" xml:space="preserve">
<value>Règle</value>
</data>
<data name="menuProxiesDelaytest" xml:space="preserve">
<data name="menuDelaytest" xml:space="preserve">
<value>Test de latence</value>
</data>
<data name="menuProxiesDelaytestPart" xml:space="preserve">
+1 -1
View File
@@ -1215,7 +1215,7 @@
<data name="menuModeRule" xml:space="preserve">
<value>Szabály</value>
</data>
<data name="menuProxiesDelaytest" xml:space="preserve">
<data name="menuDelaytest" xml:space="preserve">
<value>Késleltetés teszt</value>
</data>
<data name="menuProxiesDelaytestPart" xml:space="preserve">
+1 -1
View File
@@ -1215,7 +1215,7 @@
<data name="menuModeRule" xml:space="preserve">
<value>Aturan</value>
</data>
<data name="menuProxiesDelaytest" xml:space="preserve">
<data name="menuDelaytest" xml:space="preserve">
<value>Uji latensi</value>
</data>
<data name="menuProxiesDelaytestPart" xml:space="preserve">
+32 -2
View File
@@ -693,6 +693,9 @@
<data name="TbSettingsEnableCheckPreReleaseUpdate" xml:space="preserve">
<value>Check for pre-release</value>
</data>
<data name="TbSettingsEnableUpdateViaProxy" xml:space="preserve">
<value>Update via proxy</value>
</data>
<data name="TbSettingsException" xml:space="preserve">
<value>Exception</value>
</data>
@@ -1020,6 +1023,15 @@
<data name="TbSettingsMux4SboxProtocol" xml:space="preserve">
<value>sing-box Mux Protocol</value>
</data>
<data name="TbSettingsMux4RayConcurrency" xml:space="preserve">
<value>Xray Mux concurrency</value>
</data>
<data name="TbSettingsMux4RayXudpConcurrency" xml:space="preserve">
<value>Xray Mux XUDP concurrency</value>
</data>
<data name="TbSettingsMux4RayXudpProxyUDP443" xml:space="preserve">
<value>Xray Mux XUDP proxy UDP443</value>
</data>
<data name="TbRoutingRuleProcess" xml:space="preserve">
<value>Process (Linux/Windows)</value>
</data>
@@ -1221,7 +1233,7 @@
<data name="menuModeRule" xml:space="preserve">
<value>Rule</value>
</data>
<data name="menuProxiesDelaytest" xml:space="preserve">
<data name="menuDelaytest" xml:space="preserve">
<value>Latency Test</value>
</data>
<data name="menuProxiesDelaytestPart" xml:space="preserve">
@@ -1869,4 +1881,22 @@ The "Get Certificate" action may fail if a self-signed certificate is used or if
<data name="TbBlockAAAAQueriesTips" xml:space="preserve">
<value>Block IPv6 queries when enabled</value>
</data>
</root>
<data name="TbDNS" xml:space="preserve">
<value>DNS</value>
</data>
<data name="TbSettingsMux4Ray" xml:space="preserve">
<value>Xray Mux setting</value>
</data>
<data name="LvRequestHeaders" xml:space="preserve">
<value>HTTP headers (JSON)</value>
</data>
<data name="SubRequestHeadersTips" xml:space="preserve">
<value>Optional. Example: {"X-hwid": "my_device"}. Values override default headers for all URLs in this group, including the subscription conversion service.</value>
</data>
<data name="SubRequestHeadersInvalid" xml:space="preserve">
<value>Invalid HTTP headers. Use a JSON object with unique header names and string values. Header names and values must not contain line breaks.</value>
</data>
<data name="TbSortingProcess" xml:space="preserve">
<value>Process</value>
</data>
</root>
+11 -2
View File
@@ -1221,7 +1221,7 @@
<data name="menuModeRule" xml:space="preserve">
<value>Правила</value>
</data>
<data name="menuProxiesDelaytest" xml:space="preserve">
<data name="menuDelaytest" xml:space="preserve">
<value>Тест задержки</value>
</data>
<data name="menuProxiesDelaytestPart" xml:space="preserve">
@@ -1462,7 +1462,7 @@
<value>Включён пользовательский DNS — настройки на этой странице не применяются</value>
</data>
<data name="TbBlockSVCBHTTPSQueriesTips" xml:space="preserve">
<value>Block ECH and HTTP/3 availability checks when enabled. Always enabled in Xray</value>
<value>При включении блокирует запросы доступности ECH и HTTP/3. В Xray включено всегда</value>
</data>
<data name="FillCorrectConfigTemplateText" xml:space="preserve">
<value>Пожалуйста, заполните корректный шаблон конфигурации</value>
@@ -1860,4 +1860,13 @@
<data name="LvCustomCoreType" xml:space="preserve">
<value>Ядро пользовательской конфигурации</value>
</data>
<data name="TbXrayOnly" xml:space="preserve">
<value>Только Xray</value>
</data>
<data name="TbBlockAAAAQueries" xml:space="preserve">
<value>Блокировать DNS-запросы AAAA</value>
</data>
<data name="TbBlockAAAAQueriesTips" xml:space="preserve">
<value>При включении блокирует DNS-запросы IPv6</value>
</data>
</root>
+23 -2
View File
@@ -693,6 +693,9 @@
<data name="TbSettingsEnableCheckPreReleaseUpdate" xml:space="preserve">
<value>检查 Pre-Release</value>
</data>
<data name="TbSettingsEnableUpdateViaProxy" xml:space="preserve">
<value>通过代理更新</value>
</data>
<data name="TbSettingsException" xml:space="preserve">
<value>例外</value>
</data>
@@ -1218,7 +1221,7 @@
<data name="menuModeRule" xml:space="preserve">
<value>规则</value>
</data>
<data name="menuProxiesDelaytest" xml:space="preserve">
<data name="menuDelaytest" xml:space="preserve">
<value>延迟测试</value>
</data>
<data name="menuProxiesDelaytestPart" xml:space="preserve">
@@ -1870,4 +1873,22 @@
<data name="TbBlockAAAAQueriesTips" xml:space="preserve">
<value>开启后将阻止 IPv6 查询</value>
</data>
</root>
<data name="TbDNS" xml:space="preserve">
<value>DNS</value>
</data>
<data name="TbSettingsMux4Ray" xml:space="preserve">
<value>Xray Mux 设置</value>
</data>
<data name="LvRequestHeaders" xml:space="preserve">
<value>HTTP 请求头 (JSON)</value>
</data>
<data name="SubRequestHeadersTips" xml:space="preserve">
<value>可选,示例:{"X-hwid": "my_device"}。自定义值覆盖默认请求头,应用于本组所有订阅地址(包括订阅转换服务)。</value>
</data>
<data name="SubRequestHeadersInvalid" xml:space="preserve">
<value>HTTP 请求头无效。请使用 JSON 对象,键名不能重复,值必须为字符串;请求头名称和值不能包含换行符。</value>
</data>
<data name="TbSortingProcess" xml:space="preserve">
<value>进程</value>
</data>
</root>
+41 -2
View File
@@ -693,6 +693,9 @@
<data name="TbSettingsEnableCheckPreReleaseUpdate" xml:space="preserve">
<value>檢查 Pre-Release</value>
</data>
<data name="TbSettingsEnableUpdateViaProxy" xml:space="preserve">
<value>透過代理更新</value>
</data>
<data name="TbSettingsException" xml:space="preserve">
<value>例外</value>
</data>
@@ -1017,6 +1020,15 @@
<data name="TbSettingsMux4SboxProtocol" xml:space="preserve">
<value>sing-box Mux 多路復用協定</value>
</data>
<data name="TbSettingsMux4RayConcurrency" xml:space="preserve">
<value>Xray Mux 並行數</value>
</data>
<data name="TbSettingsMux4RayXudpConcurrency" xml:space="preserve">
<value>Xray Mux XUDP 並行數</value>
</data>
<data name="TbSettingsMux4RayXudpProxyUDP443" xml:space="preserve">
<value>Xray Mux XUDP proxy UDP443</value>
</data>
<data name="TbRoutingRuleProcess" xml:space="preserve">
<value>行程 (Linux/Windows)</value>
</data>
@@ -1218,7 +1230,7 @@
<data name="menuModeRule" xml:space="preserve">
<value>規則</value>
</data>
<data name="menuProxiesDelaytest" xml:space="preserve">
<data name="menuDelaytest" xml:space="preserve">
<value>延遲測試</value>
</data>
<data name="menuProxiesDelaytestPart" xml:space="preserve">
@@ -1861,4 +1873,31 @@
<data name="LvCustomCoreType" xml:space="preserve">
<value>自訂設定核心</value>
</data>
</root>
<data name="TbXrayOnly" xml:space="preserve">
<value>僅 Xray</value>
</data>
<data name="TbBlockAAAAQueries" xml:space="preserve">
<value>封鎖 AAAA 查詢</value>
</data>
<data name="TbBlockAAAAQueriesTips" xml:space="preserve">
<value>啟用後將封鎖 IPv6 查詢</value>
</data>
<data name="TbDNS" xml:space="preserve">
<value>DNS</value>
</data>
<data name="TbSettingsMux4Ray" xml:space="preserve">
<value>Xray Mux 設定</value>
</data>
<data name="LvRequestHeaders" xml:space="preserve">
<value>HTTP 請求標頭 (JSON)</value>
</data>
<data name="SubRequestHeadersTips" xml:space="preserve">
<value>可選,範例:{"X-hwid": "my_device"}。自訂值會覆寫預設標頭,套用於本組所有訂閱位址(包括訂閱轉換服務)。</value>
</data>
<data name="SubRequestHeadersInvalid" xml:space="preserve">
<value>HTTP 請求標頭無效。請使用 JSON 物件,鍵名不能重複,值必須為字串;標頭名稱和值不能包含換行字元。</value>
</data>
<data name="TbSortingProcess" xml:space="preserve">
<value>行程</value>
</data>
</root>
+8 -22
View File
@@ -2,28 +2,14 @@
"tag": "proxy",
"protocol": "vmess",
"settings": {
"vnext": [
{
"address": "v2ray.cool",
"port": 10086,
"users": [
{
"id": "a3482e88-686a-4a58-8126-99c9df64b7bf",
"security": "auto"
}
]
}
],
"servers": [
{
"address": "v2ray.cool",
"method": "chacha20",
"ota": false,
"password": "123456",
"port": 10086,
"level": 1
}
]
"address": "v2ray.cool",
"port": 10086,
"id": "a3482e88-686a-4a58-8126-99c9df64b7bf",
"security": "auto",
"method": "chacha20",
"ota": false,
"password": "123456",
"level": 1
},
"streamSettings": {
"network": "tcp"
+3 -5
View File
@@ -17,17 +17,15 @@
"rule_set": [
"geosite-google"
],
"server": "remote",
"strategy": "prefer_ipv4"
"server": "remote"
},
{
"rule_set": [
"geosite-cn"
],
"server": "local",
"strategy": "prefer_ipv4"
"server": "local"
}
],
"final": "remote",
"strategy": "prefer_ipv4"
}
}
+2 -4
View File
@@ -17,15 +17,13 @@
"rule_set": [
"geosite-google"
],
"server": "remote",
"strategy": "prefer_ipv4"
"server": "remote"
},
{
"rule_set": [
"geosite-cn"
],
"server": "local",
"strategy": "prefer_ipv4"
"server": "local"
}
],
"final": "remote",
+3
View File
@@ -62,6 +62,9 @@
<DesignTime>True</DesignTime>
<AutoGen>True</AutoGen>
</Compile>
<EmbeddedResource Update="Resx\ResUI.az.resx">
<Generator>PublicResXFileCodeGenerator</Generator>
</EmbeddedResource>
<EmbeddedResource Update="Resx\ResUI.fa.resx">
<SubType>Designer</SubType>
<Generator>PublicResXFileCodeGenerator</Generator>
@@ -94,17 +94,7 @@ public class CoreConfigClashService(Config config, bool isTunEnabled)
fileContent["ipv6"] = config.ClashUIItem.EnableIPv6;
//mode
if (!fileContent.ContainsKey("mode"))
{
fileContent["mode"] = nameof(ERuleMode.Rule).ToLower();
}
else
{
if (config.ClashUIItem.RuleMode != ERuleMode.Unchanged)
{
fileContent["mode"] = config.ClashUIItem.RuleMode.ToString().ToLower();
}
}
fileContent.TryAdd("mode", nameof(ERuleMode.Rule));
//enable tun mode
if (isTunEnabled)
@@ -159,8 +149,6 @@ public class CoreConfigClashService(Config config, bool isTunEnabled)
return ret;
}
ClashApiManager.Instance.ProfileContent = fileContent;
ret.Msg = string.Format(ResUI.SuccessfulConfiguration, $"{node.GetSummary()}");
ret.Success = true;
return ret;
@@ -17,36 +17,11 @@ public partial class CoreConfigSingboxService
GenDnsRules();
_coreConfig.dns ??= new Dns4Sbox();
_coreConfig.dns.independent_cache = true;
_coreConfig.dns.optimistic = context.SimpleDnsItem.ServeStale == true ? true : null;
// final dns
var routing = context.RoutingItem;
var useDirectDns = false;
if (routing != null)
{
var rules = JsonUtils.Deserialize<List<RulesItem>>(routing.RuleSet) ?? [];
if (rules?.LastOrDefault() is { OutboundTag: Global.DirectTag } lastRule)
{
var noDomain = lastRule.Domain == null || lastRule.Domain.Count == 0;
var noProcess = lastRule.Process == null || lastRule.Process.Count == 0;
var isAnyIp = lastRule.Ip == null || lastRule.Ip.Count == 0 || lastRule.Ip.Contains("0.0.0.0/0");
var isAnyPort = string.IsNullOrEmpty(lastRule.Port) || lastRule.Port == "0-65535";
var isAnyNetwork = string.IsNullOrEmpty(lastRule.Network) || lastRule.Network == "tcp,udp";
useDirectDns = noDomain && noProcess && isAnyIp && isAnyPort && isAnyNetwork;
}
}
var useDirectDns = UseDirectDns();
_coreConfig.dns.final = useDirectDns ? Global.SingboxDirectDNSTag : Global.SingboxRemoteDNSTag;
var simpleDnsItem = context.SimpleDnsItem;
if ((!useDirectDns) && simpleDnsItem.FakeIP == true && simpleDnsItem.GlobalFakeIp == false)
{
_coreConfig.dns.rules.Add(new()
{
server = Global.SingboxFakeDNSTag,
query_type = new List<int> { 1, 28 }, // A and AAAA
rewrite_ttl = 1,
});
}
}
catch (Exception ex)
{
@@ -59,14 +34,24 @@ public partial class CoreConfigSingboxService
var simpleDnsItem = context.SimpleDnsItem;
var finalDns = GenBootstrapDns();
var directDns = ParseDnsAddress(simpleDnsItem.DirectDNS ?? Global.DomainDirectDNSAddress.First());
directDns.tag = Global.SingboxDirectDNSTag;
directDns.domain_resolver = Global.SingboxLocalDNSTag;
var directDnsList = ParseDnsAddresses(simpleDnsItem.DirectDNS ?? Global.DomainDirectDNSAddress.First());
for (var i = 0; i < directDnsList.Count; i++)
{
var directDns = directDnsList[i];
var tag = string.Format(Global.SingboxDirectDNSTagTemplate, i + 1);
directDns.tag = tag;
directDns.domain_resolver = Global.SingboxLocalDNSTag;
}
var remoteDns = ParseDnsAddress(simpleDnsItem.RemoteDNS ?? Global.DomainRemoteDNSAddress.First());
remoteDns.tag = Global.SingboxRemoteDNSTag;
remoteDns.detour = Global.ProxyTag;
remoteDns.domain_resolver = Global.SingboxLocalDNSTag;
var remoteDnsList = ParseDnsAddresses(simpleDnsItem.RemoteDNS ?? Global.DomainRemoteDNSAddress.First());
for (var i = 0; i < remoteDnsList.Count; i++)
{
var remoteDns = remoteDnsList[i];
var tag = string.Format(Global.SingboxRemoteDNSTagTemplate, i + 1);
remoteDns.tag = tag;
remoteDns.detour = Global.ProxyTag;
remoteDns.domain_resolver = Global.SingboxLocalDNSTag;
}
var hostsDns = new Server4Sbox
{
@@ -118,26 +103,28 @@ public partial class CoreConfigSingboxService
{
finalDns.domain_resolver = Global.SingboxHostsDNSTag;
}
if (remoteDns.server == host.Key)
{
remoteDns.domain_resolver = Global.SingboxHostsDNSTag;
}
if (directDns.server == host.Key)
foreach (var directDns in directDnsList.Where(directDns => directDns.server == host.Key))
{
directDns.domain_resolver = Global.SingboxHostsDNSTag;
}
foreach (var remoteDns in remoteDnsList.Where(remoteDns => remoteDns.server == host.Key))
{
remoteDns.domain_resolver = Global.SingboxHostsDNSTag;
}
}
_coreConfig.dns ??= new Dns4Sbox();
_coreConfig.dns.servers ??= [];
_coreConfig.dns.servers.Add(remoteDns);
_coreConfig.dns.servers.Add(directDns);
_coreConfig.dns.servers.AddRange(remoteDnsList);
_coreConfig.dns.servers.AddRange(directDnsList);
_coreConfig.dns.servers.Add(hostsDns);
// fake ip
if (simpleDnsItem.FakeIP == true)
{
var fakeipRange = simpleDnsItem.FakeIPRange.IsNullOrEmpty() ? Global.FakeIPRanges.First() : simpleDnsItem.FakeIPRange;
var fakeipRange = simpleDnsItem.FakeIPRange.IsNullOrEmpty()
? Global.FakeIPRanges.First()
: simpleDnsItem.FakeIPRange;
var fakeip = new Server4Sbox
{
tag = Global.SingboxFakeDNSTag,
@@ -164,33 +151,33 @@ public partial class CoreConfigSingboxService
_coreConfig.dns ??= new Dns4Sbox();
_coreConfig.dns.rules ??= [];
_coreConfig.dns.rules.Add(new() { ip_accept_any = true, server = Global.SingboxHostsDNSTag });
_coreConfig.dns.rules.Add(new()
{
preferred_by = Global.SingboxHostsDNSTag,
server = Global.SingboxHostsDNSTag,
});
if (context.ProtectDomainList.Count > 0)
{
_coreConfig.dns.rules.Add(new()
{
server = Global.SingboxDirectDNSTag,
strategy = Utils.DomainStrategy4Sbox(simpleDnsItem.Strategy4ProxyDial),
domain = context.ProtectDomainList.ToList(),
});
}
_coreConfig.dns.rules.AddRange(new[]
{
_coreConfig.dns.rules.AddRange([
new Rule4Sbox
{
server = Global.SingboxRemoteDNSTag,
strategy = Utils.DomainStrategy4Sbox(simpleDnsItem.Strategy4Proxy),
clash_mode = nameof(ERuleMode.Global)
clash_mode = nameof(ERuleMode.Global),
},
new Rule4Sbox
{
server = Global.SingboxDirectDNSTag,
strategy = Utils.DomainStrategy4Sbox(simpleDnsItem.Strategy4Freedom),
clash_mode = nameof(ERuleMode.Direct)
}
});
clash_mode = nameof(ERuleMode.Direct),
},
]);
foreach (var kvp in Utils.ParseHostsToDictionary(simpleDnsItem.Hosts))
{
@@ -199,7 +186,7 @@ public partial class CoreConfigSingboxService
{
continue;
}
var rule = new Rule4Sbox()
var rule = new Rule4Sbox
{
query_type = [1, 5, 28], // A, CNAME and AAAA
action = "predefined",
@@ -262,13 +249,14 @@ public partial class CoreConfigSingboxService
{
query_type = [64, 65],
action = "predefined",
rcode = "NOERROR"
rcode = "NOERROR",
});
}
if (simpleDnsItem.FakeIP == true && simpleDnsItem.GlobalFakeIp != false)
{
var fakeipFilterRule = JsonUtils.Deserialize<Rule4Sbox>(EmbedUtils.GetEmbedText(Global.SingboxFakeIPFilterFileName));
var fakeipFilterRule =
JsonUtils.Deserialize<Rule4Sbox>(EmbedUtils.GetEmbedText(Global.SingboxFakeIPFilterFileName));
fakeipFilterRule.invert = true;
var rule4Fake = new Rule4Sbox
{
@@ -305,18 +293,22 @@ public partial class CoreConfigSingboxService
return;
}
var directDnsList = _coreConfig.dns.servers
.Where(s => s.tag?.StartsWith(Global.SingboxDirectDNSTagPrefix) == true).ToList();
var remoteDnsList = _coreConfig.dns.servers
.Where(s => s.tag?.StartsWith(Global.SingboxRemoteDNSTagPrefix) == true).ToList();
var rules = JsonUtils.Deserialize<List<RulesItem>>(routing.RuleSet) ?? [];
var expectedIPCidr = new List<string>();
var expectedIPsRegions = new List<string>();
var expectedIPCidr = new HashSet<string>();
var expectedIPsRegions = new HashSet<string>();
var regionName = string.Empty;
if (!string.IsNullOrEmpty(simpleDnsItem?.DirectExpectedIPs))
{
var ipItems = simpleDnsItem.DirectExpectedIPs
.Split(new[] { ',', ';' }, StringSplitOptions.RemoveEmptyEntries)
var ipItems = (Utils.String2List(simpleDnsItem.DirectExpectedIPs) ?? [])
.Select(s => s.Trim())
.Where(s => !string.IsNullOrEmpty(s))
.ToList();
.ToHashSet();
foreach (var ip in ipItems)
{
@@ -359,35 +351,60 @@ public partial class CoreConfigSingboxService
if (item.OutboundTag == Global.DirectTag)
{
rule.server = Global.SingboxDirectDNSTag;
rule.strategy = Utils.DomainStrategy4Sbox(simpleDnsItem.Strategy4Freedom);
Rule4Sbox? rule4ExpectedIPs = null;
if (expectedIPsRegions.Count > 0 && rule.geosite?.Count > 0 && !regionName.IsNullOrEmpty())
{
var regionGeosite = rule.geosite.Where(g => g.EndsWith($"-{regionName}", StringComparison.OrdinalIgnoreCase)
|| g.EndsWith($"@{regionName}", StringComparison.OrdinalIgnoreCase)
|| g == regionName).ToList();
var regionGeosite = rule.geosite.Where(g =>
g.EndsWith($"-{regionName}", StringComparison.OrdinalIgnoreCase)
|| g.EndsWith($"@{regionName}", StringComparison.OrdinalIgnoreCase)
|| g == regionName).ToList();
if (regionGeosite.Count > 0)
{
rule.geosite.RemoveAll(regionGeosite.Contains);
var rule4ExpectedIPs = JsonUtils.DeepCopy(rule);
rule4ExpectedIPs = JsonUtils.DeepCopy(rule);
rule4ExpectedIPs.geosite = regionGeosite;
}
}
if (rule.geosite?.Count > 0 || rule.domain?.Count > 0)
{
AddRules(rule, item, directDnsList);
}
if (rule4ExpectedIPs is not null)
{
var expectedRuleList = BuildMultiRules(rule4ExpectedIPs, item, directDnsList);
foreach (var expectedRule in
expectedRuleList.Where(expectedRule => expectedRule.action == "respond"))
{
if (expectedIPsRegions.Count > 0)
{
rule4ExpectedIPs.geoip = expectedIPsRegions;
expectedRule.geoip = expectedIPsRegions.ToList();
}
if (expectedIPCidr.Count > 0)
{
rule4ExpectedIPs.ip_cidr = expectedIPCidr;
expectedRule.ip_cidr = expectedIPCidr.ToList();
}
_coreConfig.dns.rules.Add(rule4ExpectedIPs);
}
_coreConfig.dns.rules.AddRange(expectedRuleList);
var fallbackRemoteRuleList = BuildMultiRules(rule4ExpectedIPs, item, remoteDnsList);
if (expectedRuleList.LastOrDefault()?.race == true)
{
foreach (var fallbackRemoteRule in fallbackRemoteRuleList.Where(fallbackRemoteRule =>
fallbackRemoteRule.action == "evaluate"))
{
fallbackRemoteRule.speculative = true;
}
}
_coreConfig.dns.rules.AddRange(fallbackRemoteRuleList);
}
}
else if (item.OutboundTag == Global.BlockTag)
{
rule.action = "predefined";
rule.rcode = "NXDOMAIN";
_coreConfig.dns.rules.Add(rule);
}
else
{
@@ -395,15 +412,104 @@ public partial class CoreConfigSingboxService
{
var rule4Fake = JsonUtils.DeepCopy(rule);
rule4Fake.server = Global.SingboxFakeDNSTag;
rule4Fake.query_type = new List<int> { 1, 28 }; // A and AAAA
rule4Fake.query_type = new List<int>
{
1,
28,
}; // A and AAAA
rule4Fake.rewrite_ttl = 1;
_coreConfig.dns.rules.Add(rule4Fake);
}
rule.server = Global.SingboxRemoteDNSTag;
rule.strategy = Utils.DomainStrategy4Sbox(simpleDnsItem.Strategy4Proxy);
AddRules(rule, item, remoteDnsList);
}
}
var useDirectDns = UseDirectDns();
if ((!useDirectDns) && simpleDnsItem.FakeIP == true && simpleDnsItem.GlobalFakeIp == false)
{
_coreConfig.dns.rules.Add(new()
{
server = Global.SingboxFakeDNSTag,
query_type =
[
1,
28,
], // A and AAAA
rewrite_ttl = 1,
});
}
var tempRuleItem = new RulesItem
{
Id = $"final-{Utils.GetGuid(false)}",
Enabled = true,
};
AddRules(new(), tempRuleItem, useDirectDns ? directDnsList : remoteDnsList);
return;
_coreConfig.dns.rules.Add(rule);
static List<Rule4Sbox> BuildParallelRules(Rule4Sbox rule, RulesItem item, List<Server4Sbox> dnsList)
{
var evaluateRuleList = new List<Rule4Sbox>();
var racingMatchingRuleList = new List<Rule4Sbox>();
foreach (var dnsServer in dnsList)
{
var dnsServerTag = dnsServer.tag;
var evaluateRule = JsonUtils.DeepCopy(rule);
evaluateRule.action = "evaluate";
// Maybe rule index is better than id? Not sure, but id is unique, so use id for now.
var evaluateTag = $"{dnsServerTag}-{item.Id}";
evaluateRule.tag = evaluateTag;
evaluateRule.server = dnsServerTag;
evaluateRuleList.Add(evaluateRule);
var racingMatchingRule = new Rule4Sbox
{
match_response = evaluateTag,
race = true,
action = "respond",
};
racingMatchingRuleList.Add(racingMatchingRule);
}
return [.. evaluateRuleList, .. racingMatchingRuleList];
}
static List<Rule4Sbox> BuildSerialRules(Rule4Sbox rule, RulesItem item, List<Server4Sbox> dnsList)
{
var ruleList = new List<Rule4Sbox>();
foreach (var dnsServer in dnsList)
{
var dnsServerTag = dnsServer.tag;
var evaluateRule = JsonUtils.DeepCopy(rule);
evaluateRule.action = "evaluate";
// Maybe rule index is better than id? Not sure, but id is unique, so use id for now.
var evaluateTag = $"{dnsServerTag}-{item.Id}";
evaluateRule.tag = evaluateTag;
evaluateRule.server = dnsServerTag;
ruleList.Add(evaluateRule);
var racingMatchingRule = new Rule4Sbox
{
match_response = evaluateTag,
action = "respond",
};
ruleList.Add(racingMatchingRule);
}
return ruleList;
}
List<Rule4Sbox> BuildMultiRules(Rule4Sbox rule, RulesItem item, List<Server4Sbox> dnsList)
{
return simpleDnsItem.ParallelQuery == true ? BuildParallelRules(rule, item, dnsList) : BuildSerialRules(rule, item, dnsList);
}
void AddRules(Rule4Sbox rule, RulesItem item, List<Server4Sbox> dnsList)
{
if (dnsList.Count == 1)
{
rule.server = dnsList.First().tag;
_coreConfig.dns.rules.Add(rule);
}
else
{
var ruleList = BuildMultiRules(rule, item, dnsList);
_coreConfig.dns.rules.AddRange(ruleList);
}
}
}
@@ -432,11 +538,15 @@ public partial class CoreConfigSingboxService
var strDNS = string.Empty;
if (context.IsTunEnabled)
{
strDNS = string.IsNullOrEmpty(item?.TunDNS) ? EmbedUtils.GetEmbedText(Global.TunSingboxDNSFileName) : item?.TunDNS;
strDNS = string.IsNullOrEmpty(item?.TunDNS)
? EmbedUtils.GetEmbedText(Global.TunSingboxDNSFileName)
: item?.TunDNS;
}
else
{
strDNS = string.IsNullOrEmpty(item?.NormalDNS) ? EmbedUtils.GetEmbedText(Global.DNSSingboxNormalFileName) : item?.NormalDNS;
strDNS = string.IsNullOrEmpty(item?.NormalDNS)
? EmbedUtils.GetEmbedText(Global.DNSSingboxNormalFileName)
: item?.NormalDNS;
}
var dns4Sbox = JsonUtils.Deserialize<Dns4Sbox>(strDNS);
@@ -464,15 +574,18 @@ public partial class CoreConfigSingboxService
dns4Sbox.rules.Insert(0, new()
{
server = tag,
clash_mode = nameof(ERuleMode.Direct)
clash_mode = nameof(ERuleMode.Direct),
});
dns4Sbox.rules.Insert(0, new()
{
server = dns4Sbox.servers.Where(t => t.detour == Global.ProxyTag).Select(t => t.tag).FirstOrDefault() ?? "remote",
clash_mode = nameof(ERuleMode.Global)
server = dns4Sbox.servers.Where(t => t.detour == Global.ProxyTag).Select(t => t.tag).FirstOrDefault() ??
"remote",
clash_mode = nameof(ERuleMode.Global),
});
var finalDnsAddress = string.IsNullOrEmpty(dnsItem?.DomainDNSAddress) ? Global.DomainPureIPDNSAddress.FirstOrDefault() : dnsItem?.DomainDNSAddress;
var finalDnsAddress = string.IsNullOrEmpty(dnsItem?.DomainDNSAddress)
? Global.DomainPureIPDNSAddress.FirstOrDefault()
: dnsItem?.DomainDNSAddress;
var localDnsServer = ParseDnsAddress(finalDnsAddress);
localDnsServer.tag = tag;
@@ -500,9 +613,21 @@ public partial class CoreConfigSingboxService
};
}
private static List<Server4Sbox> ParseDnsAddresses(string addresses)
{
var servers = new List<Server4Sbox>();
var addressList = Utils.String2List(addresses)?.ToList();
if (addressList is not { Count: > 0 })
{
return servers;
}
servers.AddRange(addressList.Select(ParseDnsAddress).Where(s => s != null));
return servers;
}
private static Server4Sbox? ParseDnsAddress(string address)
{
var addressFirst = address?.Split(address.Contains(',') ? ',' : ';').FirstOrDefault()?.Trim();
var addressFirst = Utils.String2List(address)?.FirstOrDefault()?.Trim();
if (string.IsNullOrEmpty(addressFirst))
{
return null;
@@ -547,10 +672,33 @@ public partial class CoreConfigSingboxService
{
server.server_port = port;
}
if ((server.type == "https" || server.type == "h3") && !string.IsNullOrEmpty(path) && path != "/")
if (server.type is "https" or "h3" && !string.IsNullOrEmpty(path) && path != "/")
{
server.path = path;
}
return server;
}
private bool UseDirectDns()
{
var routing = context.RoutingItem;
var useDirectDns = false;
if (routing == null)
{
return useDirectDns;
}
var rules = JsonUtils.Deserialize<List<RulesItem>>(routing.RuleSet) ?? [];
if (rules?.LastOrDefault() is not { OutboundTag: Global.DirectTag } lastRule)
{
return useDirectDns;
}
var noDomain = lastRule.Domain == null || lastRule.Domain.Count == 0;
var noProcess = lastRule.Process == null || lastRule.Process.Count == 0;
var isAnyIp = lastRule.Ip == null || lastRule.Ip.Count == 0 || lastRule.Ip.Contains("0.0.0.0/0");
var isAnyPort = string.IsNullOrEmpty(lastRule.Port) || lastRule.Port == "0-65535";
var isAnyNetwork = string.IsNullOrEmpty(lastRule.Network) || lastRule.Network == "tcp,udp";
useDirectDns = noDomain && noProcess && isAnyIp && isAnyPort && isAnyNetwork;
return useDirectDns;
}
}
@@ -244,7 +244,7 @@ public partial class CoreConfigSingboxService
var resolveRule = new Rule4Sbox
{
action = "resolve",
strategy = domainStrategy
strategy = domainStrategy,
};
if (_config.RoutingBasicItem.DomainStrategy == Global.IPOnDemand)
{
@@ -79,6 +79,7 @@ public partial class CoreConfigSingboxService
//Add ruleset srs
_coreConfig.route.rule_set = [];
var containRemoteRuleset = false;
foreach (var item in new HashSet<string>(ruleSets))
{
if (item.IsNullOrEmpty())
@@ -94,11 +95,13 @@ public partial class CoreConfigSingboxService
type = "local",
format = "binary",
tag = item,
path = pathSrs
path = pathSrs,
};
}
else
{
containRemoteRuleset = true;
var srsUrl = string.IsNullOrEmpty(_config.ConstItem.SrsSourceUrl)
? Global.SingboxRulesetUrl
: _config.ConstItem.SrsSourceUrl;
@@ -109,11 +112,21 @@ public partial class CoreConfigSingboxService
format = "binary",
tag = item,
url = string.Format(srsUrl, item.StartsWith(geosite) ? geosite : geoip, item),
download_detour = Global.ProxyTag
http_client = Global.SingboxSrsDownloadHttpClientTag,
};
}
}
_coreConfig.route.rule_set.Add(customRuleset);
}
if (containRemoteRuleset)
{
_coreConfig.http_clients ??= [];
_coreConfig.http_clients.Add(new()
{
tag = Global.SingboxSrsDownloadHttpClientTag,
detour = Global.ProxyTag,
});
}
}
}
@@ -282,9 +282,8 @@ public partial class CoreConfigV2rayService
return false;
}
var outboundAddress = outbound.settings?.servers?.FirstOrDefault()?.address
?? outbound.settings?.vnext?.FirstOrDefault()?.address
?? outbound.settings?.address?.ToString()
var outboundAddress = outbound.settings?.address?.ToString()
?? outbound.settings?.peers?.FirstOrDefault()?.endpoint
?? string.Empty;
if (outboundAddress.Equals("localhost", StringComparison.OrdinalIgnoreCase))
@@ -159,16 +159,16 @@ public partial class CoreConfigV2rayService
var directDNSAddress = ParseDnsAddresses(simpleDNSItem?.DirectDNS, Global.DomainDirectDNSAddress.First());
var remoteDNSAddress = ParseDnsAddresses(simpleDNSItem?.RemoteDNS, Global.DomainRemoteDNSAddress.First());
var directDomainList = new List<string>();
var directGeositeList = new List<string>();
var proxyDomainList = new List<string>();
var proxyGeositeList = new List<string>();
var expectedDomainList = new List<string>();
var expectedIPs = new List<string>();
var directDomainList = new HashSet<string>();
var directGeositeList = new HashSet<string>();
var proxyDomainList = new HashSet<string>();
var proxyGeositeList = new HashSet<string>();
var expectedDomainList = new HashSet<string>();
var expectedIPs = new HashSet<string>();
var regionName = string.Empty;
var bootstrapDNSAddress = ParseDnsAddresses(simpleDNSItem?.BootstrapDNS, Global.DomainPureIPDNSAddress.First());
var dnsServerDomains = new List<string>();
var dnsServerDomains = new HashSet<string>();
foreach (var dns in directDNSAddress)
{
@@ -194,15 +194,13 @@ public partial class CoreConfigV2rayService
dnsServerDomains.Add($"full:{domain}");
}
}
dnsServerDomains = dnsServerDomains.Distinct().ToList();
if (!string.IsNullOrEmpty(simpleDNSItem?.DirectExpectedIPs))
{
expectedIPs = simpleDNSItem.DirectExpectedIPs
.Split(new[] { ',', ';' }, StringSplitOptions.RemoveEmptyEntries)
expectedIPs = (Utils.String2List(simpleDNSItem.DirectExpectedIPs) ?? [])
.Select(s => s.Trim())
.Where(s => !string.IsNullOrEmpty(s))
.ToList();
.ToHashSet();
foreach (var region in from ip in expectedIPs
where ip.StartsWith(Global.GeoIPPrefix, StringComparison.OrdinalIgnoreCase)
@@ -269,15 +267,19 @@ public partial class CoreConfigV2rayService
}
}
if (context.ProtectDomainList.Count > 0)
{
directDomainList.AddRange(context.ProtectDomainList);
}
dnsItem.servers ??= [];
var directDnsTagIndex = 1;
if (dnsServerDomains.Count > 0)
{
AddDnsServers(bootstrapDNSAddress, dnsServerDomains);
}
if (context.ProtectDomainList.Count > 0)
{
AddDnsServers(directDNSAddress, context.ProtectDomainList, true);
}
if (simpleDNSItem.FakeIP == true)
{
var fakeIPMatchDomain = new HashSet<string>(proxyDomainList);
@@ -291,7 +293,7 @@ public partial class CoreConfigV2rayService
if (fakeIPMatchDomain.Count > 0)
{
GenFakeDns();
AddDnsServers(["fakedns"], fakeIPMatchDomain.ToList());
AddDnsServers(["fakedns"], fakeIPMatchDomain);
}
}
@@ -300,10 +302,6 @@ public partial class CoreConfigV2rayService
AddDnsServers(remoteDNSAddress, proxyGeositeList);
AddDnsServers(directDNSAddress, directGeositeList, true);
AddDnsServers(directDNSAddress, expectedDomainList, true, expectedIPs);
if (dnsServerDomains.Count > 0)
{
AddDnsServers(bootstrapDNSAddress, dnsServerDomains);
}
var useDirectDns = false;
@@ -336,7 +334,7 @@ public partial class CoreConfigV2rayService
static List<string> ParseDnsAddresses(string? dnsInput, string defaultAddress)
{
var addresses = dnsInput?.Split(dnsInput.Contains(',') ? ',' : ';')
var addresses = (Utils.String2List(dnsInput) ?? [])
.Select(addr => addr.Trim())
.Where(addr => !string.IsNullOrEmpty(addr))
.Select(addr => addr.StartsWith("dhcp", StringComparison.OrdinalIgnoreCase) ? "localhost" : addr)
@@ -345,7 +343,7 @@ public partial class CoreConfigV2rayService
return addresses.Count > 0 ? addresses : new List<string> { defaultAddress };
}
static DnsServer4Ray CreateDnsServer(string dnsAddress, List<string> domains, List<string>? expectedIPs = null)
static DnsServer4Ray CreateDnsServer(string dnsAddress, HashSet<string> domains, HashSet<string>? expectedIPs = null)
{
var (domain, scheme, port, path) = Utils.ParseUrl(dnsAddress);
var domainFinal = dnsAddress;
@@ -365,13 +363,13 @@ public partial class CoreConfigV2rayService
address = domainFinal,
port = portFinal,
skipFallback = true,
domains = domains.Count > 0 ? domains : null,
expectedIPs = expectedIPs?.Count > 0 ? expectedIPs : null
domains = domains.Count > 0 ? domains.ToList() : null,
expectedIPs = expectedIPs?.Count > 0 ? expectedIPs.ToList() : null
};
return dnsServer;
}
void AddDnsServers(List<string> dnsAddresses, List<string> domains, bool isDirectDns = false, List<string>? expectedIPs = null)
void AddDnsServers(List<string> dnsAddresses, HashSet<string> domains, bool isDirectDns = false, HashSet<string>? expectedIPs = null)
{
if (domains.Count <= 0)
{
@@ -70,7 +70,11 @@ public partial class CoreConfigV2rayService
// Route both families into the tunnel regardless of EnableIPv6Address. That option only
// controls whether the interface gets an IPv6 address; leaving ::/0 out of the routing
// table makes IPv6 follow the system default route and bypass the tunnel entirely.
tunInbound.settings.autoSystemRoutingTable = ["0.0.0.0/0", "::/0"];
// A host without a global IPv6 address is the exception: it has nothing to leak,
// and IPv6 sent into the tunnel would have no way back out.
tunInbound.settings.autoSystemRoutingTable = context.HasGlobalIPv6Address
? ["0.0.0.0/0", "::/0"]
: ["0.0.0.0/0"];
if (_config.TunModeItem.EnableIPv6Address == true)
{
var address6 = _config.TunModeItem.IPv6Address.NullIfEmpty() ?? Global.TunIPv6Address.First();
@@ -95,7 +99,9 @@ public partial class CoreConfigV2rayService
.Where(x => x != null).ToList();
var includeList = new List<IPNetwork2> { wholeInternet };
var includeListV6 = new List<IPNetwork2> { wholeInternetV6 };
var includeListV6 = context.HasGlobalIPv6Address
? new List<IPNetwork2> { wholeInternetV6 }
: new List<IPNetwork2>();
foreach (var exclude in excludeList)
{
@@ -69,166 +69,88 @@ public partial class CoreConfigV2rayService
{
var protocolExtra = _node.GetProtocolExtra();
var muxEnabled = _node.MuxEnabled ?? false;
var outboundSettings = outbound.settings;
switch (_node.ConfigType)
{
case EConfigType.VMess:
{
VnextItem4Ray vnextItem;
if (outbound.settings.vnext.Count <= 0)
{
vnextItem = new VnextItem4Ray();
outbound.settings.vnext.Add(vnextItem);
}
else
{
vnextItem = outbound.settings.vnext.First();
}
vnextItem.address = _node.Address;
vnextItem.port = _node.Port;
UsersItem4Ray usersItem;
if (vnextItem.users.Count <= 0)
{
usersItem = new UsersItem4Ray();
vnextItem.users.Add(usersItem);
}
else
{
usersItem = vnextItem.users.First();
}
usersItem.id = _node.Password;
usersItem.alterId = int.TryParse(protocolExtra?.AlterId, out var result) ? result : 0;
usersItem.email = Global.UserEMail;
outboundSettings.address = _node.Address;
outboundSettings.port = _node.Port;
outboundSettings.id = _node.Password;
outboundSettings.alterId = int.TryParse(protocolExtra?.AlterId, out var result) ? result : 0;
outboundSettings.email = Global.UserEMail;
if (Global.VmessSecurities.Contains(protocolExtra.VmessSecurity))
{
usersItem.security = protocolExtra.VmessSecurity;
outboundSettings.security = protocolExtra.VmessSecurity;
}
else
{
usersItem.security = Global.DefaultSecurity;
outboundSettings.security = Global.DefaultSecurity;
}
FillOutboundMux(outbound, muxEnabled, muxEnabled);
outbound.settings.servers = null;
break;
}
case EConfigType.Shadowsocks:
{
ServersItem4Ray serversItem;
if (outbound.settings.servers.Count <= 0)
{
serversItem = new ServersItem4Ray();
outbound.settings.servers.Add(serversItem);
}
else
{
serversItem = outbound.settings.servers.First();
}
serversItem.address = _node.Address;
serversItem.port = _node.Port;
serversItem.password = _node.Password;
serversItem.method = AppManager.Instance.GetShadowsocksSecurities(_node).Contains(protocolExtra.SsMethod)
outboundSettings.address = _node.Address;
outboundSettings.port = _node.Port;
outboundSettings.password = _node.Password;
outboundSettings.method = AppManager.Instance.GetShadowsocksSecurities(_node).Contains(protocolExtra.SsMethod)
? protocolExtra.SsMethod : "none";
serversItem.uot = protocolExtra.Uot == true ? true : null;
outboundSettings.uot = protocolExtra.Uot == true ? true : null;
serversItem.ota = false;
serversItem.level = 1;
outboundSettings.ota = false;
outboundSettings.level = 1;
FillOutboundMux(outbound);
outbound.settings.vnext = null;
break;
}
case EConfigType.SOCKS:
{
ServersItem4Ray serversItem;
if (outbound.settings.servers.Count <= 0)
{
serversItem = new ServersItem4Ray();
outbound.settings.servers.Add(serversItem);
}
else
{
serversItem = outbound.settings.servers.First();
}
serversItem.address = _node.Address;
serversItem.port = _node.Port;
serversItem.method = null;
serversItem.password = null;
outboundSettings.address = _node.Address;
outboundSettings.port = _node.Port;
if (_node.Username.IsNotEmpty()
&& _node.Password.IsNotEmpty())
{
SocksUsersItem4Ray socksUsersItem = new()
{
user = _node.Username ?? "",
pass = _node.Password,
level = 1
};
serversItem.users = new List<SocksUsersItem4Ray>() { socksUsersItem };
outboundSettings.user = _node.Username;
outboundSettings.pass = _node.Password;
outboundSettings.level = 1;
outboundSettings.email = Global.UserEMail;
}
FillOutboundMux(outbound);
outbound.settings.vnext = null;
break;
}
case EConfigType.HTTP:
{
outbound.settings.address = _node.Address;
outbound.settings.port = _node.Port;
outboundSettings.address = _node.Address;
outboundSettings.port = _node.Port;
if (protocolExtra.HttpHeaders.IsNotEmpty())
{
outbound.settings.headers = JsonUtils.ParseJson(protocolExtra.HttpHeaders);
outboundSettings.headers = JsonUtils.ParseJson(protocolExtra.HttpHeaders);
}
if (_node.Username.IsNotEmpty()
&& _node.Password.IsNotEmpty())
{
outbound.settings.user = _node.Username;
outbound.settings.pass = _node.Password;
outbound.settings.level = 1;
outbound.settings.email = Global.UserEMail;
outboundSettings.user = _node.Username;
outboundSettings.pass = _node.Password;
outboundSettings.level = 1;
outboundSettings.email = Global.UserEMail;
}
FillOutboundMux(outbound);
outbound.settings.vnext = null;
outbound.settings.servers = null;
break;
}
case EConfigType.VLESS:
{
VnextItem4Ray vnextItem;
if (outbound.settings.vnext?.Count <= 0)
{
vnextItem = new VnextItem4Ray();
outbound.settings.vnext.Add(vnextItem);
}
else
{
vnextItem = outbound.settings.vnext.First();
}
vnextItem.address = _node.Address;
vnextItem.port = _node.Port;
UsersItem4Ray usersItem;
if (vnextItem.users.Count <= 0)
{
usersItem = new UsersItem4Ray();
vnextItem.users.Add(usersItem);
}
else
{
usersItem = vnextItem.users.First();
}
usersItem.id = _node.Password;
usersItem.email = Global.UserEMail;
usersItem.encryption = protocolExtra.VlessEncryption;
outboundSettings.address = _node.Address;
outboundSettings.port = _node.Port;
outboundSettings.id = _node.Password;
outboundSettings.email = Global.UserEMail;
outboundSettings.encryption = protocolExtra.VlessEncryption;
if (protocolExtra.Flow.IsNullOrEmpty())
{
@@ -236,46 +158,28 @@ public partial class CoreConfigV2rayService
}
else
{
usersItem.flow = protocolExtra.Flow;
outboundSettings.flow = protocolExtra.Flow;
FillOutboundMux(outbound, false, muxEnabled);
}
outbound.settings.servers = null;
break;
}
case EConfigType.Trojan:
{
ServersItem4Ray serversItem;
if (outbound.settings.servers.Count <= 0)
{
serversItem = new ServersItem4Ray();
outbound.settings.servers.Add(serversItem);
}
else
{
serversItem = outbound.settings.servers.First();
}
serversItem.address = _node.Address;
serversItem.port = _node.Port;
serversItem.password = _node.Password;
outboundSettings.address = _node.Address;
outboundSettings.port = _node.Port;
outboundSettings.password = _node.Password;
serversItem.ota = false;
serversItem.level = 1;
outboundSettings.ota = false;
outboundSettings.level = 1;
FillOutboundMux(outbound);
outbound.settings.vnext = null;
break;
}
case EConfigType.Hysteria2:
{
outbound.settings = new()
{
version = 2,
address = _node.Address,
port = _node.Port,
vnext = null,
servers = null,
};
outboundSettings.address = _node.Address;
outboundSettings.port = _node.Port;
outboundSettings.version = 2;
break;
}
case EConfigType.WireGuard:
@@ -297,11 +201,10 @@ public partial class CoreConfigV2rayService
secretKey = _node.Password,
reserved = Utils.String2List(protocolExtra.WgReserved)?.Select(s => s.Trim()).Select(int.Parse).ToList(),
mtu = protocolExtra.WgMtu > 0 ? protocolExtra.WgMtu : Global.TunMtus.First(),
peers = [peer]
remoteDNS = Utils.String2List(protocolExtra.WgDns)?.Select(s => s.Trim()).ToList(),
peers = [peer],
};
outbound.settings = setting;
outbound.settings.vnext = null;
outbound.settings.servers = null;
break;
}
}
+11 -2
View File
@@ -11,6 +11,10 @@ public class DownloadService
public event ErrorEventHandler? Error;
public string? AcceptHeader { get; init; }
public IReadOnlyDictionary<string, string>? RequestHeaders { get; init; }
private static readonly string _tag = "DownloadService";
/// <summary>
@@ -234,6 +238,7 @@ public class DownloadService
{
Proxy = webProxy,
UseProxy = webProxy != null,
AutomaticDecompression = DecompressionMethods.All,
ConnectTimeout = TimeSpan.FromSeconds(connectTimeout)
};
var certificateChainPolicy = CertPemManager.Instance.BuildCertificateChainPolicy();
@@ -243,7 +248,7 @@ public class DownloadService
handler.SslOptions.RemoteCertificateValidationCallback = null;
}
using var client = new HttpClient(handler)
using var client = new HttpClient(HttpRequestHeadersHelper.CreateHandler(handler, RequestHeaders))
{
Timeout = Timeout.InfiniteTimeSpan
};
@@ -253,6 +258,10 @@ public class DownloadService
userAgent = Utils.GetVersion(false);
}
client.DefaultRequestHeaders.UserAgent.TryParseAdd(userAgent);
if (AcceptHeader.IsNotEmpty())
{
client.DefaultRequestHeaders.Accept.ParseAdd(AcceptHeader);
}
Uri uri = new(url);
//Authorization Header
@@ -290,7 +299,7 @@ public class DownloadService
{
userAgent = Utils.GetVersion(false);
}
var result = await DownloaderHelper.Instance.DownloadStringAsync(webProxy, url, userAgent, timeout);
var result = await DownloaderHelper.Instance.DownloadStringAsync(webProxy, url, userAgent, timeout, RequestHeaders, AcceptHeader);
return result;
}
catch (Exception ex)
@@ -17,7 +17,11 @@ public class StatisticsSingboxService
_updateFunc = updateFunc;
_exitFlag = false;
_ = Task.Run(Run);
_ = Task.Factory.StartNew(
Run,
CancellationToken.None,
TaskCreationOptions.LongRunning,
TaskScheduler.Default);
}
private async Task Init()
@@ -15,7 +15,11 @@ public class StatisticsXrayService
_updateFunc = updateFunc;
_exitFlag = false;
_ = Task.Run(Run);
_ = Task.Factory.StartNew(
Run,
CancellationToken.None,
TaskCreationOptions.LongRunning,
TaskScheduler.Default);
}
public void Close()
+50 -50
View File
@@ -1,13 +1,13 @@
namespace ServiceLib.Services;
public class UpdateService(Config config, Func<bool, string, Task> updateFunc)
public partial class UpdateService(Config config, Func<bool, string, Task> updateFunc)
{
private readonly Config? _config = config;
private readonly Func<bool, string, Task>? _updateFunc = updateFunc;
private readonly int _timeout = 30;
private static readonly string _tag = "UpdateService";
public async Task CheckUpdateGuiN(bool preRelease)
public async Task CheckUpdateGuiN(bool preRelease, bool blProxy = true)
{
var url = string.Empty;
var fileName = string.Empty;
@@ -31,7 +31,7 @@ public class UpdateService(Config config, Func<bool, string, Task> updateFunc)
};
await UpdateFunc(false, string.Format(ResUI.MsgStartUpdating, ECoreType.v2rayN));
var result = await CheckUpdateAsync(downloadHandle, ECoreType.v2rayN, preRelease);
var result = await CheckUpdateAsync(downloadHandle, ECoreType.v2rayN, preRelease, blProxy);
if (result.Success)
{
await UpdateFunc(false, string.Format(ResUI.MsgParsingSuccessfully, ECoreType.v2rayN));
@@ -39,7 +39,7 @@ public class UpdateService(Config config, Func<bool, string, Task> updateFunc)
url = result.Url!;
fileName = Utils.GetTempPath(Utils.GetGuid());
await downloadHandle.DownloadFileAsync(new() { FileUrl = url, FilePath = fileName }, true, TimeSpan.FromSeconds(_timeout));
await downloadHandle.DownloadFileAsync(new() { FileUrl = url, FilePath = fileName }, blProxy, TimeSpan.FromSeconds(_timeout));
}
else
{
@@ -47,7 +47,7 @@ public class UpdateService(Config config, Func<bool, string, Task> updateFunc)
}
}
public async Task CheckUpdateCore(ECoreType type, bool preRelease)
public async Task CheckUpdateCore(ECoreType type, bool preRelease, bool blProxy = true)
{
var url = string.Empty;
var fileName = string.Empty;
@@ -80,7 +80,7 @@ public class UpdateService(Config config, Func<bool, string, Task> updateFunc)
};
await UpdateFunc(false, string.Format(ResUI.MsgStartUpdating, type));
var result = await CheckUpdateAsync(downloadHandle, type, preRelease);
var result = await CheckUpdateAsync(downloadHandle, type, preRelease, blProxy);
if (result.Success)
{
await UpdateFunc(false, string.Format(ResUI.MsgParsingSuccessfully, type));
@@ -89,7 +89,7 @@ public class UpdateService(Config config, Func<bool, string, Task> updateFunc)
url = result.Url!;
var ext = url.Contains(".tar.gz") ? ".tar.gz" : Path.GetExtension(url);
fileName = Utils.GetTempPath(Utils.GetGuid() + ext);
await downloadHandle.DownloadFileAsync(new() { FileUrl = url, FilePath = fileName }, true, TimeSpan.FromSeconds(_timeout));
await downloadHandle.DownloadFileAsync(new() { FileUrl = url, FilePath = fileName }, blProxy, TimeSpan.FromSeconds(_timeout));
}
else
{
@@ -100,7 +100,7 @@ public class UpdateService(Config config, Func<bool, string, Task> updateFunc)
}
}
public async Task<UpdateResult> CheckHasUpdateOnly(ECoreType type, bool preRelease)
public async Task<UpdateResult> CheckHasUpdateOnly(ECoreType type, bool preRelease, bool blProxy = true)
{
if (!CoreInfoManager.Instance.IsCheckUpdateSupported(type))
{
@@ -109,10 +109,10 @@ public class UpdateService(Config config, Func<bool, string, Task> updateFunc)
var downloadHandle = new DownloadService();
var checkPreRelease = CoreInfoManager.Instance.GetCheckPreRelease(type, preRelease);
return await CheckUpdateAsync(downloadHandle, type, checkPreRelease);
return await CheckUpdateAsync(downloadHandle, type, checkPreRelease, blProxy);
}
public async Task<List<string>> CheckHasUpdateOnlyAll(bool preRelease)
public async Task<List<string>> CheckHasUpdateOnlyAll(bool preRelease, bool blProxy = true)
{
var msgs = new List<string>();
foreach (var type in CoreInfoManager.Instance.GetCheckUpdateCoreTypes())
@@ -122,7 +122,7 @@ public class UpdateService(Config config, Func<bool, string, Task> updateFunc)
continue;
}
var result = await CheckHasUpdateOnly(type, preRelease);
var result = await CheckHasUpdateOnly(type, preRelease, blProxy);
if (result.Success && result.Version != null)
{
var msg = string.Format(ResUI.MsgCheckUpdateHasNewVersion, type, result.Version);
@@ -137,7 +137,7 @@ public class UpdateService(Config config, Func<bool, string, Task> updateFunc)
return msgs;
}
public async Task UpdateGeoFileAll()
public async Task UpdateGeoFileAll(bool blProxy = true)
{
var requests = new List<FileDownloadRequest>();
requests.AddRange(GetGeoFilesRequest());
@@ -145,17 +145,17 @@ public class UpdateService(Config config, Func<bool, string, Task> updateFunc)
requests.AddRange(await GetSrsFileAllRequest());
// NOTE: srs files are more small, so we reverse the order to ensure a good download experience for the user.
requests.Reverse();
await DownloadGeoFiles(requests);
await DownloadGeoFiles(requests, blProxy);
await UpdateFunc(true, string.Format(ResUI.MsgDownloadGeoFileSuccessfully, "geo"));
}
#region CheckUpdate private
private async Task<UpdateResult> CheckUpdateAsync(DownloadService downloadHandle, ECoreType type, bool preRelease)
private async Task<UpdateResult> CheckUpdateAsync(DownloadService downloadHandle, ECoreType type, bool preRelease, bool blProxy)
{
try
{
var result = await GetRemoteVersion(downloadHandle, type, preRelease);
var result = await GetRemoteVersion(downloadHandle, type, preRelease, blProxy);
if (!result.Success || result.Version is null)
{
return result;
@@ -170,14 +170,14 @@ public class UpdateService(Config config, Func<bool, string, Task> updateFunc)
}
}
private async Task<UpdateResult> GetRemoteVersion(DownloadService downloadHandle, ECoreType type, bool preRelease)
private async Task<UpdateResult> GetRemoteVersion(DownloadService downloadHandle, ECoreType type, bool preRelease, bool blProxy)
{
var coreInfo = CoreInfoManager.Instance.GetCoreInfo(type);
var tagName = string.Empty;
if (preRelease)
if (preRelease || coreInfo?.LockedMaxVersion != null)
{
var url = coreInfo?.ReleaseApiUrl;
var result = await downloadHandle.TryDownloadString(url, true, Global.AppName);
var result = await downloadHandle.TryDownloadString(url, blProxy, Global.AppName);
if (result.IsNullOrEmpty())
{
return new UpdateResult(false, "");
@@ -187,11 +187,29 @@ public class UpdateService(Config config, Func<bool, string, Task> updateFunc)
var gitHubRelease = preRelease ? gitHubReleases?.First() : gitHubReleases?.First(r => r.Prerelease == false);
tagName = gitHubRelease?.TagName;
//var body = gitHubRelease?.Body;
if (coreInfo?.LockedMaxVersion != null)
{
var lockedMaxVersion = coreInfo.LockedMaxVersion;
var remoteVersion = new SemanticVersion(tagName);
if (remoteVersion > lockedMaxVersion)
{
var fallbackRelease = gitHubReleases?
.Where(r => preRelease || !r.Prerelease)
.Select(r => new { Release = r, IsValid = SemanticVersion.TryParse(r.TagName, out var v), Version = v })
.Where(x => x.IsValid && x.Version <= coreInfo.LockedMaxVersion)
.MaxBy(x => x.Version)?
.Release;
gitHubRelease = fallbackRelease;
tagName = gitHubRelease?.TagName;
}
}
}
else
{
var url = Path.Combine(coreInfo.Url, "latest");
var lastUrl = await downloadHandle.UrlRedirectAsync(url, true);
var lastUrl = await downloadHandle.UrlRedirectAsync(url, blProxy);
if (lastUrl == null)
{
return new UpdateResult(false, "");
@@ -202,6 +220,9 @@ public class UpdateService(Config config, Func<bool, string, Task> updateFunc)
return new UpdateResult(true, new SemanticVersion(tagName));
}
[GeneratedRegex(@"v?(?<version>\d+\.\d+\.\d+(?:-[0-9a-zA-Z.-]+)?(?:\+[0-9a-zA-Z.-]+)?)", RegexOptions.IgnoreCase)]
private static partial Regex SemVerRegex();
private async Task<SemanticVersion> GetCoreVersion(ECoreType type)
{
try
@@ -227,23 +248,7 @@ public class UpdateService(Config config, Func<bool, string, Task> updateFunc)
var result = await Utils.GetCliWrapOutput(filePath, coreInfo.VersionArg);
var echo = result ?? "";
var version = string.Empty;
switch (type)
{
case ECoreType.v2fly:
case ECoreType.Xray:
case ECoreType.v2fly_v5:
version = Regex.Match(echo, $"{coreInfo.Match} ([0-9.]+) \\(").Groups[1].Value;
break;
case ECoreType.mihomo:
version = Regex.Match(echo, $"v[0-9.]+").Groups[0].Value;
break;
case ECoreType.sing_box:
version = Regex.Match(echo, $"([0-9.]+)").Groups[1].Value;
break;
}
var version = SemVerRegex().Match(echo).Groups["version"].Value;
return new SemanticVersion(version);
}
catch (Exception ex)
@@ -269,30 +274,25 @@ public class UpdateService(Config config, Func<bool, string, Task> updateFunc)
case ECoreType.v2fly:
case ECoreType.Xray:
case ECoreType.v2fly_v5:
{
curVersion = await GetCoreVersion(type);
message = string.Format(ResUI.IsLatestCore, type, curVersion.ToVersionString("v"));
url = string.Format(coreUrl, version.ToVersionString("v"));
break;
}
case ECoreType.mihomo:
{
curVersion = await GetCoreVersion(type);
message = string.Format(ResUI.IsLatestCore, type, curVersion);
url = string.Format(coreUrl, version.ToVersionString("v"));
message = string.Format(ResUI.IsLatestCore, type, curVersion.ToStandardVersionString("v"));
url = string.Format(coreUrl, version);
break;
}
case ECoreType.sing_box:
{
curVersion = await GetCoreVersion(type);
message = string.Format(ResUI.IsLatestCore, type, curVersion.ToVersionString("v"));
url = string.Format(coreUrl, version.ToVersionString("v"), version);
message = string.Format(ResUI.IsLatestCore, type, curVersion.ToStandardVersionString("v"));
url = string.Format(coreUrl, version, version.ToString().RemovePrefix("v"));
break;
}
case ECoreType.v2rayN:
{
curVersion = new SemanticVersion(Utils.GetVersionInfo());
message = string.Format(ResUI.IsLatestN, type, curVersion);
message = string.Format(ResUI.IsLatestN, type, curVersion.ToStandardVersionString("v"));
url = string.Format(coreUrl, version);
break;
}
@@ -300,7 +300,7 @@ public class UpdateService(Config config, Func<bool, string, Task> updateFunc)
throw new ArgumentException("Type");
}
if (curVersion >= version && version != new SemanticVersion(0, 0, 0))
if (curVersion >= version && !version.Equals(new SemanticVersion(0, 0, 0)))
{
return new UpdateResult(false, message);
}
@@ -531,7 +531,7 @@ public class UpdateService(Config config, Func<bool, string, Task> updateFunc)
};
}
private async Task DownloadGeoFiles(List<FileDownloadRequest> requests)
private async Task DownloadGeoFiles(List<FileDownloadRequest> requests, bool blProxy)
{
var tmpFilePathDict = new Dictionary<string, string>();
var tmpFileRequests = new List<FileDownloadRequest>();
@@ -586,7 +586,7 @@ public class UpdateService(Config config, Func<bool, string, Task> updateFunc)
_ = UpdateFunc(false, args.GetException().Message);
};
await downloadHandle.DownloadSmallFilesAsync(tmpFileRequests, true, TimeSpan.FromSeconds(_timeout));
await downloadHandle.DownloadSmallFilesAsync(tmpFileRequests, blProxy, TimeSpan.FromSeconds(_timeout));
}
#endregion Geo private
@@ -218,8 +218,7 @@ public partial class AddGroupServerViewModel : MyReactiveObject, ICloseable
public async Task UpdatePreviewList()
{
AllProfilePreviewItemsObs.Clear();
AllProfilePreviewItemsObs.AddRange(await GroupProfileManager.GetChildProfileItemsByProtocolExtra(GetUpdatedProtocolExtra()));
AllProfilePreviewItemsObs.ReplaceRange(await GroupProfileManager.GetChildProfileItemsByProtocolExtra(GetUpdatedProtocolExtra()));
}
private async Task SaveServerAsync()
@@ -70,6 +70,9 @@ public partial class AddServerViewModel : MyReactiveObject, ICloseable
[Reactive]
public partial int WgMtu { get; set; }
[Reactive]
public partial string WgDns { get; set; }
[Reactive]
public partial bool Uot { get; set; }
@@ -310,6 +313,7 @@ public partial class AddServerViewModel : MyReactiveObject, ICloseable
WgInterfaceAddress = protocolExtra.WgInterfaceAddress ?? string.Empty;
WgReserved = protocolExtra.WgReserved ?? string.Empty;
WgMtu = protocolExtra.WgMtu ?? 1280;
WgDns = protocolExtra.WgDns ?? string.Empty;
Uot = protocolExtra.Uot ?? false;
CongestionControl = protocolExtra.CongestionControl ?? string.Empty;
InsecureConcurrency = protocolExtra.InsecureConcurrency > 0 ? protocolExtra.InsecureConcurrency : null;
@@ -431,6 +435,7 @@ public partial class AddServerViewModel : MyReactiveObject, ICloseable
WgInterfaceAddress = WgInterfaceAddress.NullIfEmpty(),
WgReserved = WgReserved.NullIfEmpty(),
WgMtu = WgMtu >= 576 ? WgMtu : null,
WgDns = WgDns.NullIfEmpty(),
Uot = Uot ? true : null,
CongestionControl = CongestionControl.NullIfEmpty(),
InsecureConcurrency = InsecureConcurrency > 0 ? InsecureConcurrency : null,
@@ -13,6 +13,7 @@ public partial class CheckUpdateViewModel : MyReactiveObject
public ReactiveCommand<RxVoid, RxVoid> CheckUpdateCmd { get; }
public ReactiveCommand<RxVoid, RxVoid> CheckOnlyCmd { get; }
[Reactive] public partial bool EnableCheckPreReleaseUpdate { get; set; }
[Reactive] public partial bool EnableUpdateViaProxy { get; set; }
public CheckUpdateViewModel()
{
@@ -33,11 +34,13 @@ public partial class CheckUpdateViewModel : MyReactiveObject
});
EnableCheckPreReleaseUpdate = _config.CheckUpdateItem.CheckPreReleaseUpdate;
EnableUpdateViaProxy = _config.CheckUpdateItem.UpdateViaProxy;
this.WhenAnyValue(
x => x.EnableCheckPreReleaseUpdate,
y => y == true)
.Subscribe(c => _ = OnCheckPreReleaseUpdateChanged());
this.WhenAnyValue(x => x.EnableCheckPreReleaseUpdate)
.SubscribeAsync(async _ => await OnCheckPreReleaseUpdateChanged());
this.WhenAnyValue(x => x.EnableUpdateViaProxy)
.Subscribe(c => _ = OnUpdateViaProxyChanged());
RefreshCheckUpdateItems();
}
@@ -50,8 +53,7 @@ public partial class CheckUpdateViewModel : MyReactiveObject
models.Add(GetGeoFileCheckUpdateModel());
CheckUpdateModels.Clear();
CheckUpdateModels.AddRange(models);
CheckUpdateModels.ReplaceRange(models);
}
private CheckUpdateModel GetCheckUpdateModel(ECoreType coreType)
@@ -98,6 +100,16 @@ public partial class CheckUpdateViewModel : MyReactiveObject
await SaveSelectedCoreTypes();
}
private async Task OnUpdateViaProxyChanged()
{
if (_config.CheckUpdateItem.UpdateViaProxy == EnableUpdateViaProxy)
{
return;
}
_config.CheckUpdateItem.UpdateViaProxy = EnableUpdateViaProxy;
await SaveSelectedCoreTypes();
}
private async Task SaveSelectedCoreTypes()
{
_config.CheckUpdateItem.SelectedCoreTypes =
@@ -145,7 +157,7 @@ public partial class CheckUpdateViewModel : MyReactiveObject
}
var updateService = new UpdateService(_config, async (success, msg) => await Task.CompletedTask);
var result = await updateService.CheckHasUpdateOnly(item.CoreType.Value, EnableCheckPreReleaseUpdate);
var result = await updateService.CheckHasUpdateOnly(item.CoreType.Value, EnableCheckPreReleaseUpdate, EnableUpdateViaProxy);
if (result.Success && result.Version != null)
{
await UpdateView(item.CoreType, string.Format(ResUI.MsgCheckUpdateHasNewVersion, item.CoreType, result.Version));
@@ -193,7 +205,7 @@ public partial class CheckUpdateViewModel : MyReactiveObject
}
await CheckUpdateN(EnableCheckPreReleaseUpdate);
}
else if (item.CoreType == ECoreType.Xray)
else if (item.CoreType is ECoreType.Xray or ECoreType.sing_box)
{
await CheckUpdateCore(item, EnableCheckPreReleaseUpdate);
}
@@ -230,7 +242,7 @@ public partial class CheckUpdateViewModel : MyReactiveObject
UpdatedPlusPlus(null, "");
}
}
await new UpdateService(_config, _updateUI).UpdateGeoFileAll()
await new UpdateService(_config, _updateUI).UpdateGeoFileAll(EnableUpdateViaProxy)
.ContinueWith(t => UpdatedPlusPlus(null, ""));
}
@@ -245,7 +257,7 @@ public partial class CheckUpdateViewModel : MyReactiveObject
UpdatedPlusPlus(_v2rayN, msg);
}
}
await new UpdateService(_config, _updateUI).CheckUpdateGuiN(preRelease)
await new UpdateService(_config, _updateUI).CheckUpdateGuiN(preRelease, EnableUpdateViaProxy)
.ContinueWith(t => UpdatedPlusPlus(_v2rayN, ""));
}
@@ -263,7 +275,7 @@ public partial class CheckUpdateViewModel : MyReactiveObject
if (model.CoreType.HasValue)
{
await new UpdateService(_config, _updateUI).CheckUpdateCore(model.CoreType.Value, preRelease)
await new UpdateService(_config, _updateUI).CheckUpdateCore(model.CoreType.Value, preRelease, EnableUpdateViaProxy)
.ContinueWith(t => UpdatedPlusPlus(model.CoreType, ""));
}
}
@@ -2,33 +2,18 @@ namespace ServiceLib.ViewModels;
public partial class ClashConnectionsViewModel : MyReactiveObject
{
public BulkObservableCollection<ClashConnectionModel> ConnectionItems { get; } = [];
[Reactive]
public partial ClashConnectionModel SelectedSource { get; set; }
public ReactiveCommand<RxVoid, RxVoid> ConnectionCloseCmd { get; }
public ReactiveCommand<RxVoid, RxVoid> ConnectionCloseAllCmd { get; }
[Reactive]
public partial string HostFilter { get; set; }
[Reactive]
public partial bool AutoRefresh { get; set; }
public ClashConnectionsViewModel()
{
_config = AppManager.Instance.Config;
AutoRefresh = _config.ClashUIItem.ConnectionsAutoRefresh;
var canEditRemove = this.WhenAnyValue(
x => x.SelectedSource,
selectedSource => selectedSource != null && selectedSource.Id.IsNotEmpty());
x => x.SelectedSource,
selectedSource => selectedSource?.Id?.IsNotEmpty() == true);
this.WhenAnyValue(
x => x.AutoRefresh,
y => y == true)
.Subscribe(c => { _config.ClashUIItem.ConnectionsAutoRefresh = AutoRefresh; });
x => x.AutoRefresh)
.Subscribe(_ => { _config.ClashUIItem.ConnectionsAutoRefresh = AutoRefresh; });
ConnectionCloseCmd = ReactiveCommand.CreateFromTask(async () =>
{
await ClashConnectionClose(false);
@@ -39,17 +24,28 @@ public partial class ClashConnectionsViewModel : MyReactiveObject
await ClashConnectionClose(true);
});
_ = Init();
_ = Task.Factory.StartNew(
async () => await GetClashConnectionsTask(),
CancellationToken.None,
TaskCreationOptions.LongRunning,
TaskScheduler.Default
);
}
private async Task Init()
{
await DelayTestTask();
}
public BulkObservableCollection<ClashConnectionModel> ConnectionItems { get; } = [];
[Reactive] public partial ClashConnectionModel SelectedSource { get; set; }
public ReactiveCommand<RxVoid, RxVoid> ConnectionCloseCmd { get; }
public ReactiveCommand<RxVoid, RxVoid> ConnectionCloseAllCmd { get; }
[Reactive] public partial string HostFilter { get; set; }
[Reactive] public partial bool AutoRefresh { get; set; }
private async Task GetClashConnections()
{
var ret = await ClashApiManager.Instance.GetClashConnectionsAsync();
var ret = await ClashApiManager.Instance.GetConnections();
if (ret == null)
{
return;
@@ -63,13 +59,24 @@ public partial class ClashConnectionsViewModel : MyReactiveObject
public async Task RefreshConnections(List<ConnectionItem>? connections)
{
ConnectionItems.Clear();
var dtNow = DateTime.Now;
var lstModel = new List<ClashConnectionModel>();
foreach (var item in connections ?? [])
{
var host = $"{(item.metadata.host.IsNullOrEmpty() ? item.metadata.destinationIP : item.metadata.host)}:{item.metadata.destinationPort}";
if (item.metadata == null)
{
continue;
}
var dest = item.metadata.host.IsNullOrEmpty() ? item.metadata.destinationIP : item.metadata.host;
var hostSb = new StringBuilder();
hostSb.Append(dest);
hostSb.Append($":{item.metadata.destinationPort}");
if (!string.IsNullOrEmpty(item.metadata.sniffHost) &&
dest?.Equals(item.metadata.sniffHost, StringComparison.OrdinalIgnoreCase) == false)
{
hostSb.Append($" ({item.metadata.sniffHost})");
}
var host = hostSb.ToString();
if (HostFilter.IsNotEmpty() && !host.Contains(HostFilter))
{
continue;
@@ -83,17 +90,14 @@ public partial class ClashConnectionsViewModel : MyReactiveObject
Host = host,
Time = (dtNow - item.start).TotalSeconds < 0 ? 1 : (dtNow - item.start).TotalSeconds,
Elapsed = (dtNow - item.start).ToString(@"hh\:mm\:ss"),
Chain = $"{item.rule} , {string.Join("->", item.chains ?? [])}"
Chain = $"{item.rule} , {string.Join("->", item.chains ?? [])}",
ProcessPath = item.metadata.processPath,
};
lstModel.Add(model);
}
if (lstModel.Count <= 0)
{
return;
}
ConnectionItems.AddRange(lstModel);
ConnectionItems.ReplaceRange(lstModel);
await Task.CompletedTask;
}
@@ -103,47 +107,39 @@ public partial class ClashConnectionsViewModel : MyReactiveObject
if (!all)
{
var item = SelectedSource;
if (item is null)
if (string.IsNullOrEmpty(item?.Id))
{
return;
}
id = item.Id;
}
else
{
ConnectionItems.Clear();
}
await ClashApiManager.Instance.ClashConnectionClose(id);
await ClashApiManager.Instance.CloseConnection(id);
await GetClashConnections();
}
public async Task DelayTestTask()
public async Task GetClashConnectionsTask()
{
_ = Task.Run(async () =>
var numOfExecuted = 1;
while (true)
{
var numOfExecuted = 1;
while (true)
await Task.Delay(1000 * 5);
numOfExecuted++;
if (!(AutoRefresh && AppManager.Instance.ShowInTaskbar &&
AppManager.Instance.IsRunningCore(ECoreType.sing_box)))
{
await Task.Delay(1000 * 5);
numOfExecuted++;
if (!(AutoRefresh && AppManager.Instance.ShowInTaskbar && AppManager.Instance.IsRunningCore(ECoreType.sing_box)))
{
continue;
}
if (_config.ClashUIItem.ConnectionsRefreshInterval <= 0)
{
continue;
}
if (numOfExecuted % _config.ClashUIItem.ConnectionsRefreshInterval != 0)
{
continue;
}
await GetClashConnections();
continue;
}
});
await Task.CompletedTask;
if (_config.ClashUIItem.ConnectionsRefreshInterval <= 0)
{
continue;
}
if (numOfExecuted % _config.ClashUIItem.ConnectionsRefreshInterval != 0)
{
continue;
}
await GetClashConnections();
}
}
}
@@ -1,36 +1,9 @@
using static ServiceLib.Models.Dto.ClashProviders;
using static ServiceLib.Models.Dto.ClashProxies;
namespace ServiceLib.ViewModels;
public partial class ClashProxiesViewModel : MyReactiveObject
{
private Dictionary<string, ProxiesItem>? _proxies;
private Dictionary<string, ProvidersItem>? _providers;
private readonly int _delayTimeout = 99999999;
public BulkObservableCollection<ClashProxyModel> ProxyGroups { get; } = [];
public BulkObservableCollection<ClashProxyModel> ProxyDetails { get; } = [];
[Reactive]
public partial ClashProxyModel SelectedGroup { get; set; }
[Reactive]
public partial ClashProxyModel SelectedDetail { get; set; }
public ReactiveCommand<RxVoid, RxVoid> ProxiesReloadCmd { get; }
public ReactiveCommand<RxVoid, RxVoid> ProxiesDelayTestCmd { get; }
public ReactiveCommand<RxVoid, RxVoid> ProxiesDelayTestPartCmd { get; }
public ReactiveCommand<RxVoid, RxVoid> ProxiesSelectActivityCmd { get; }
[Reactive]
public partial int RuleModeSelected { get; set; }
[Reactive]
public partial int SortingSelected { get; set; }
[Reactive]
public partial bool AutoRefresh { get; set; }
private ClashItem _clashItem = new();
public ClashProxiesViewModel()
{
@@ -40,169 +13,158 @@ public partial class ClashProxiesViewModel : MyReactiveObject
{
await ProxiesReload();
});
ProxiesDelayTestCmd = ReactiveCommand.CreateFromTask(async () =>
ProxyDelayTestCmd = ReactiveCommand.CreateFromTask(async () =>
{
await ProxiesDelayTest(true);
if (!string.IsNullOrEmpty(SelectedDetail?.Name))
{
await TestProxyDelay(SelectedDetail.Name);
}
});
ProxiesDelayTestPartCmd = ReactiveCommand.CreateFromTask(async () =>
GroupProxiesDelayTestCmd = ReactiveCommand.CreateFromTask(async () =>
{
await ProxiesDelayTest(false);
await TestGroupProxiesDelay();
});
ProxiesSelectActivityCmd = ReactiveCommand.CreateFromTask(async () =>
{
await SetActiveProxy();
});
SelectedGroup = new();
SelectedDetail = new();
AutoRefresh = _config.ClashUIItem.ProxiesAutoRefresh;
SortingSelected = _config.ClashUIItem.ProxiesSorting;
RuleModeSelected = (int)_config.ClashUIItem.RuleMode;
RuleModeSelected = nameof(ERuleMode.Rule);
#region WhenAnyValue && ReactiveCommand
this.WhenAnyValue(
x => x.SelectedGroup,
y => y != null && y.Name.IsNotEmpty())
.Subscribe(RefreshProxyDetails);
this.WhenAnyValue(x => x.SelectedGroup)
.Where(y => y != null && y.Name.IsNotEmpty())
.Subscribe(_ => RefreshProxyDetails());
this.WhenAnyValue(
x => x.RuleModeSelected,
y => y >= 0)
.Subscribe(async c => await DoRuleModeSelected(c));
this.WhenAnyValue(x => x.RuleModeSelected)
.Where(y => !string.IsNullOrEmpty(y))
.Skip(1)
.SubscribeAsync(async x => await SetRuleMode(x));
this.WhenAnyValue(
x => x.SortingSelected,
y => y >= 0)
.Subscribe(DoSortingSelected);
this.WhenAnyValue(x => x.SortingSelected)
.Where(y => y >= 0)
.Subscribe(_ => DoSortingSelected());
this.WhenAnyValue(
x => x.AutoRefresh,
y => y == true)
.Subscribe(c => { _config.ClashUIItem.ProxiesAutoRefresh = AutoRefresh; });
this.WhenAnyValue(x => x.AutoRefresh)
.Where(y => y)
.Subscribe(_ => { _config.ClashUIItem.ProxiesAutoRefresh = AutoRefresh; });
#endregion WhenAnyValue && ReactiveCommand
_ = Init();
_ = Task.Factory.StartNew(
async () => await GetClashProxiesTask(),
CancellationToken.None,
TaskCreationOptions.LongRunning,
TaskScheduler.Default
);
}
private async Task Init()
{
await DelayTestTask();
}
public BulkObservableCollection<ClashProxyModel> ProxyGroups { get; } = [];
public BulkObservableCollection<ClashProxyModel> ProxyDetails { get; } = [];
private async Task DoRuleModeSelected(bool c)
{
if (!c)
{
return;
}
if (_config.ClashUIItem.RuleMode == (ERuleMode)RuleModeSelected)
{
return;
}
await SetRuleModeCheck((ERuleMode)RuleModeSelected);
}
public BulkObservableCollection<string> ClashModes { get; } = new(Enum.GetNames<ERuleMode>().ToList());
public async Task SetRuleModeCheck(ERuleMode mode)
{
if (_config.ClashUIItem.RuleMode == mode)
{
return;
}
await SetRuleMode(mode);
}
[Reactive] public partial ClashProxyModel? SelectedGroup { get; set; }
private void DoSortingSelected(bool c)
[Reactive] public partial ClashProxyModel? SelectedDetail { get; set; }
public ReactiveCommand<RxVoid, RxVoid> ProxiesReloadCmd { get; }
public ReactiveCommand<RxVoid, RxVoid> ProxyDelayTestCmd { get; }
public ReactiveCommand<RxVoid, RxVoid> GroupProxiesDelayTestCmd { get; }
public ReactiveCommand<RxVoid, RxVoid> ProxiesSelectActivityCmd { get; }
[Reactive] public partial string RuleModeSelected { get; set; }
[Reactive] public partial int SortingSelected { get; set; }
[Reactive] public partial bool AutoRefresh { get; set; }
private void DoSortingSelected()
{
if (!c)
{
return;
}
if (SortingSelected != _config.ClashUIItem.ProxiesSorting)
{
_config.ClashUIItem.ProxiesSorting = SortingSelected;
}
RefreshProxyDetails(c);
RefreshProxyDetails();
}
public async Task ProxiesReload()
{
await GetClashProxies(true);
await ProxiesDelayTest();
await GetClashProxies();
await GetClashModes();
}
#region task
public async Task GetClashProxiesTask()
{
var numOfExecuted = 1;
while (true)
{
await Task.Delay(1000 * 60);
numOfExecuted++;
if (!(AutoRefresh && AppManager.Instance.ShowInTaskbar &&
AppManager.Instance.IsRunningCore(ECoreType.sing_box)))
{
continue;
}
if (_config.ClashUIItem.ProxiesAutoDelayTestInterval <= 0)
{
continue;
}
if (numOfExecuted % _config.ClashUIItem.ProxiesAutoDelayTestInterval != 0)
{
continue;
}
await GetClashProxies();
}
}
#endregion task
#region proxy function
private async Task SetRuleMode(ERuleMode mode)
private async Task SetRuleMode(string mode)
{
_config.ClashUIItem.RuleMode = mode;
if (mode != ERuleMode.Unchanged)
{
Dictionary<string, string> headers = new()
{
{ "mode", mode.ToString().ToLower() }
};
await ClashApiManager.Instance.ClashConfigUpdate(headers);
}
await ClashApiManager.Instance.UpdateClashMode(mode);
}
private async Task GetClashProxies(bool refreshUI)
private async Task GetClashProxies()
{
var ret = await ClashApiManager.Instance.GetClashProxiesAsync();
if (ret?.Item1 == null || ret.Item2 == null)
var ret = await ClashApiManager.Instance.GetProxies();
if (ret?.IsEmpty() != false)
{
return;
}
_proxies = ret.Item1.proxies;
_providers = ret?.Item2.providers;
_clashItem = ret;
if (refreshUI)
{
RxSchedulers.MainThreadScheduler.Schedule(() => _ = RefreshProxyGroups());
}
RxSchedulers.MainThreadScheduler.Schedule(() => _ = RefreshProxyGroups());
}
public async Task RefreshProxyGroups()
{
if (_proxies == null)
if (_clashItem.IsEmpty())
{
return;
}
var selectedName = SelectedGroup?.Name;
ProxyGroups.Clear();
var lstProxyGroups = new List<ClashProxyModel>();
var proxyGroups = ClashApiManager.Instance.GetClashProxyGroups();
if (proxyGroups is { Count: > 0 })
{
foreach (var it in proxyGroups)
{
if (it.name.IsNullOrEmpty() || !_proxies.TryGetValue(it.name, out var item))
{
continue;
}
if (!Global.allowSelectType.Contains(item.type.ToLower()))
{
continue;
}
lstProxyGroups.Add(new ClashProxyModel()
{
Now = item.now,
Name = item.name,
Type = item.type
});
}
}
//from api
foreach (var kv in _proxies)
var globalName = "GLOBAL";
foreach (var kv in _clashItem.Proxies)
{
if (!Global.allowSelectType.Contains(kv.Value.type.ToLower()))
if (!Global.allowSelectType.Contains(kv.Value.type?.ToLower()))
{
continue;
}
if (kv.Key == globalName)
{
continue;
}
@@ -211,52 +173,49 @@ public partial class ClashProxiesViewModel : MyReactiveObject
{
continue;
}
lstProxyGroups.Add(new ClashProxyModel()
lstProxyGroups.Add(new ClashProxyModel
{
Now = kv.Value.now,
Name = kv.Key,
Type = kv.Value.type
Type = kv.Value.type,
});
}
if (_clashItem.Proxies.TryGetValue(globalName, out var globalProxy))
{
lstProxyGroups.Add(new ClashProxyModel
{
Now = globalProxy.now,
Name = globalName,
Type = globalProxy.type,
});
}
ProxyGroups.AddRange(lstProxyGroups);
ProxyGroups.ReplaceRange(lstProxyGroups);
if (ProxyGroups is { Count: > 0 })
{
if (selectedName != null && ProxyGroups.Any(t => t.Name == selectedName))
{
SelectedGroup = ProxyGroups.FirstOrDefault(t => t.Name == selectedName);
}
else
{
SelectedGroup = ProxyGroups.First();
}
SelectedGroup = ProxyGroups.FirstOrDefault(t => t.Name == selectedName) ?? ProxyGroups.First();
}
else
{
SelectedGroup = new();
SelectedGroup = null;
}
await Task.CompletedTask;
}
private void RefreshProxyDetails(bool c)
private void RefreshProxyDetails()
{
ProxyDetails.Clear();
if (!c)
{
return;
}
var name = SelectedGroup?.Name;
if (name.IsNullOrEmpty())
{
return;
}
if (_proxies == null)
if (_clashItem.IsEmpty())
{
return;
}
_proxies.TryGetValue(name, out var proxy);
_clashItem.Proxies.TryGetValue(name, out var proxy);
if (proxy?.all == null)
{
return;
@@ -271,7 +230,7 @@ public partial class ClashProxiesViewModel : MyReactiveObject
}
var delay = proxy2.history?.Count > 0 ? proxy2.history.Last().delay : -1;
lstDetails.Add(new ClashProxyModel()
lstDetails.Add(new ClashProxyModel
{
IsActive = item == proxy.now,
Name = item,
@@ -280,7 +239,7 @@ public partial class ClashProxiesViewModel : MyReactiveObject
DelayName = delay <= 0 ? string.Empty : $"{delay}ms",
});
}
//sort
// sort
switch (SortingSelected)
{
case 0:
@@ -290,101 +249,95 @@ public partial class ClashProxiesViewModel : MyReactiveObject
case 1:
lstDetails = lstDetails.OrderBy(t => t.Name).ToList();
break;
default:
break;
}
ProxyDetails.AddRange(lstDetails);
ProxyDetails.ReplaceRange(lstDetails);
}
private ProxiesItem? TryGetProxy(string name)
private ClashProxy? TryGetProxy(string? name)
{
if (_proxies == null)
if (name.IsNullOrEmpty())
{
return null;
}
_proxies.TryGetValue(name, out var proxy2);
if (proxy2 != null)
{
return proxy2;
}
//from providers
if (_providers != null)
{
foreach (var kv in _providers)
{
if (Global.proxyVehicleType.Contains(kv.Value.vehicleType.ToLower()))
{
var proxy3 = kv.Value.proxies.FirstOrDefault(t => t.name == name);
if (proxy3 != null)
{
return proxy3;
}
}
}
}
return null;
_clashItem.Proxies.TryGetValue(name, out var proxy2);
return proxy2;
}
public async Task SetActiveProxy()
{
if (SelectedGroup == null || SelectedGroup.Name.IsNullOrEmpty())
if (SelectedGroup.Name.IsNullOrEmpty())
{
return;
}
if (SelectedDetail == null || SelectedDetail.Name.IsNullOrEmpty())
if (SelectedDetail.Name.IsNullOrEmpty())
{
return;
}
var name = SelectedGroup.Name;
if (name.IsNullOrEmpty())
var groupName = SelectedGroup.Name;
if (groupName.IsNullOrEmpty())
{
return;
}
var nameNode = SelectedDetail.Name;
if (nameNode.IsNullOrEmpty())
var nodeName = SelectedDetail.Name;
if (nodeName.IsNullOrEmpty())
{
return;
}
var selectedProxy = TryGetProxy(name);
if (selectedProxy == null || selectedProxy.type != "Selector")
var selectedProxy = TryGetProxy(groupName);
if (selectedProxy is not { type: "Selector" })
{
NoticeManager.Instance.Enqueue(ResUI.OperationFailed);
return;
}
await ClashApiManager.Instance.ClashSetActiveProxy(name, nameNode);
selectedProxy.now = nameNode;
var group = ProxyGroups.FirstOrDefault(it => it.Name == SelectedGroup.Name);
if (group != null)
{
group.Now = nameNode;
var group2 = JsonUtils.DeepCopy(group);
ProxyGroups.Replace(group, group2);
SelectedGroup = group2;
}
await ClashApiManager.Instance.SetActiveProxy(groupName, nodeName);
await GetClashProxies();
NoticeManager.Instance.Enqueue(ResUI.OperationSuccess);
}
private async Task ProxiesDelayTest(bool blAll = true)
private async Task GetClashModes()
{
ClashApiManager.Instance.ClashProxiesDelayTest(blAll, ProxyDetails.ToList(), async (item, result) =>
var ret = await ClashApiManager.Instance.GetClashModes();
if (ret is not { Count: > 0 })
{
if (item == null || result.IsNullOrEmpty())
{
return;
}
return;
}
ClashModes.ReplaceRange(ret);
var currentMode = await ClashApiManager.Instance.GetClashMode();
if (currentMode.IsNullOrEmpty())
{
return;
}
RuleModeSelected = currentMode;
}
var model = new SpeedTestResult() { IndexId = item.Name, Delay = result };
RxSchedulers.MainThreadScheduler.Schedule(() =>
{
_ = ProxiesDelayTestResult(model);
});
await Task.CompletedTask;
private async Task TestProxyDelay(string name)
{
var result = await ClashApiManager.Instance.TestDelay(name, _clashItem);
var model = new SpeedTestResult
{
IndexId = name,
Delay = result.ToString(),
};
await ProxiesDelayTestResult(model);
}
private async Task TestGroupProxiesDelay()
{
var groupProxy = TryGetProxy(SelectedGroup.Name);
if (!Global.allowSelectType.Contains(groupProxy?.type))
{
return;
}
var options = new ParallelOptions
{
MaxDegreeOfParallelism = 4,
};
await Parallel.ForEachAsync(groupProxy?.all ?? [], options, async (name, _) =>
{
await TestProxyDelay(name);
});
await Task.CompletedTask;
}
public async Task ProxiesDelayTestResult(SpeedTestResult result)
@@ -394,56 +347,10 @@ public partial class ClashProxiesViewModel : MyReactiveObject
{
return;
}
var dicResult = JsonUtils.Deserialize<Dictionary<string, object>>(result.Delay);
if (dicResult != null && dicResult.TryGetValue("delay", out var value))
{
detail.Delay = Convert.ToInt32(value.ToString());
detail.DelayName = $"{detail.Delay}ms";
}
else if (dicResult != null && dicResult.TryGetValue("message", out var value1))
{
detail.Delay = _delayTimeout;
detail.DelayName = $"{value1}";
}
else
{
detail.Delay = _delayTimeout;
detail.DelayName = string.Empty;
}
detail.Delay = Convert.ToInt32(result.Delay);
detail.DelayName = $"{detail.Delay}ms";
await Task.CompletedTask;
}
#endregion proxy function
#region task
public async Task DelayTestTask()
{
_ = Task.Run(async () =>
{
var numOfExecuted = 1;
while (true)
{
await Task.Delay(1000 * 60);
numOfExecuted++;
if (!(AutoRefresh && AppManager.Instance.ShowInTaskbar && AppManager.Instance.IsRunningCore(ECoreType.sing_box)))
{
continue;
}
if (_config.ClashUIItem.ProxiesAutoDelayTestInterval <= 0)
{
continue;
}
if (numOfExecuted % _config.ClashUIItem.ProxiesAutoDelayTestInterval != 0)
{
continue;
}
await ProxiesDelayTest();
}
});
await Task.CompletedTask;
}
#endregion task
}
@@ -257,19 +257,19 @@ public partial class MainWindowViewModel : MyReactiveObject
AppEvents.AddServerViaClipboardRequested
.AsObservable()
.ObserveOn(RxSchedulers.MainThreadScheduler)
.Subscribe(async _ => await AddServerViaClipboardAsync(null));
.SubscribeAsync(async _ => await AddServerViaClipboardAsync(null));
AppEvents.HasUpdateNotified
.AsObservable()
.ObserveOn(RxSchedulers.MainThreadScheduler)
.Subscribe(async bl => BlNewUpdate = bl);
.Subscribe(bl => BlNewUpdate = bl);
#endregion AppEvents
ProfilesViewModel.RefreshServersRequested
.AsObservable()
.ObserveOn(RxSchedulers.MainThreadScheduler)
.Subscribe(async _ => await RefreshServers());
.SubscribeAsync(async _ => await RefreshServersDispatcherAsync());
var vmReloadRequestedList = new List<IObservable<RxVoid>>
{
@@ -282,23 +282,23 @@ public partial class MainWindowViewModel : MyReactiveObject
{
reloadRequested
.ObserveOn(RxSchedulers.MainThreadScheduler)
.Subscribe(async _ => await Reload());
.SubscribeAsync(async _ => await Reload());
}
StatusBarViewModel.AddServerViaScanRequested
.AsObservable()
.ObserveOn(RxSchedulers.MainThreadScheduler)
.Subscribe(async _ => await AddServerViaScanAsync());
.SubscribeAsync(async _ => await AddServerViaScanAsync());
StatusBarViewModel.AddServerViaClipboardRequested
.AsObservable()
.ObserveOn(RxSchedulers.MainThreadScheduler)
.Subscribe(async _ => await AddServerViaClipboardAsync(null));
.SubscribeAsync(async _ => await AddServerViaClipboardAsync(null));
StatusBarViewModel.ShowHideWindowRequested
.AsObservable()
.ObserveOn(RxSchedulers.MainThreadScheduler)
.Subscribe(async blShow =>
.SubscribeAsync(async blShow =>
{
await ShowHideWindowInteraction.HandleSafe(blShow);
});
@@ -306,12 +306,12 @@ public partial class MainWindowViewModel : MyReactiveObject
StatusBarViewModel.SetDefaultServerRequested
.AsObservable()
.ObserveOn(RxSchedulers.MainThreadScheduler)
.Subscribe(async indexId => await ProfilesViewModel.SetDefaultServer(indexId));
.SubscribeAsync(async indexId => await ProfilesViewModel.SetDefaultServer(indexId));
StatusBarViewModel.SubscriptionsUpdateRequested
.AsObservable()
.ObserveOn(RxSchedulers.MainThreadScheduler)
.Subscribe(async blProxy => await UpdateSubscriptionProcess("", blProxy));
.SubscribeAsync(async blProxy => await UpdateSubscriptionProcess("", blProxy));
_ = Init();
}
@@ -400,6 +400,8 @@ public partial class MainWindowViewModel : MyReactiveObject
#region Servers && Groups
private readonly SemaphoreSlim _refreshServersSemaphore = new(1, 1);
private async Task RefreshServers()
{
await ProfilesViewModel.RefreshServersBiz();
@@ -411,13 +413,21 @@ public partial class MainWindowViewModel : MyReactiveObject
private async Task RefreshServersDispatcherAsync()
{
//await Observable.Start(async () => await RefreshServers(), RxSchedulers.MainThreadScheduler);
await Signal.FromAsync(async () =>
await _refreshServersSemaphore.WaitAsync();
try
{
await Signal.FromAsync(async () =>
{
await RefreshServers();
return RxVoid.Default;
})
.SubscribeOn(RxSchedulers.MainThreadScheduler)
.ToTask();
.SubscribeOn(RxSchedulers.MainThreadScheduler)
.ToTask();
}
finally
{
_refreshServersSemaphore.Release();
}
}
private async Task RefreshSubscriptions()
@@ -690,7 +700,18 @@ public partial class MainWindowViewModel : MyReactiveObject
});
RxSchedulers.MainThreadScheduler.Schedule(async () =>
{
await StatusBarViewModel.TestServerAvailability();
var result = await StatusBarViewModel.TestServerAvailability();
if (result == null || profileItem.IndexId.IsNullOrEmpty())
{
return;
}
await ProfilesViewModel.SetSpeedTestResult(new()
{
IndexId = profileItem.IndexId,
IpInfo = result.Ip,
Delay = result.Time > 0 ? result.Time.ToString() : null
});
});
var showClashUI = AppManager.Instance.IsRunningCore(ECoreType.sing_box);
@@ -728,7 +749,10 @@ public partial class MainWindowViewModel : MyReactiveObject
RxSchedulers.MainThreadScheduler.Schedule(() =>
{
ShowClashUI = showClashUI;
TabMainSelectedIndex = showClashUI ? TabMainSelectedIndex : 0;
if (!showClashUI || TabMainSelectedIndex < 0)
{
TabMainSelectedIndex = 0;
}
});
}
+9 -11
View File
@@ -22,18 +22,16 @@ public partial class MsgViewModel : MyReactiveObject
AutoRefresh = _config.MsgUIItem.AutoRefresh ?? true;
this.WhenAnyValue(
x => x.MsgFilter)
.Subscribe(c => DoMsgFilter());
x => x.MsgFilter)
.Subscribe(c => DoMsgFilter());
this.WhenAnyValue(
x => x.AutoRefresh,
y => y == true)
.Subscribe(c => _config.MsgUIItem.AutoRefresh = AutoRefresh);
this.WhenAnyValue(x => x.AutoRefresh)
.Subscribe(_ => _config.MsgUIItem.AutoRefresh = AutoRefresh);
AppEvents.SendMsgViewRequested
.AsObservable()
//.ObserveOn(RxSchedulers.MainThreadScheduler)
.Subscribe(content => _ = AppendQueueMsg(content));
.AsObservable()
//.ObserveOn(RxSchedulers.MainThreadScheduler)
.Subscribe(content => _ = AppendQueueMsg(content));
}
public void FlushQueueMsg()
@@ -74,9 +72,9 @@ public partial class MsgViewModel : MyReactiveObject
{
try
{
await DispatcherShowMsgInteraction.HandleSafe(sb.ToString());
await DispatcherShowMsgInteraction.Handle(sb.ToString());
}
catch (Exception)
catch
{
_queueMsg.Enqueue(sb.ToString());
}
@@ -22,6 +22,9 @@ public partial class OptionSettingViewModel : MyReactiveObject, ICloseable
[Reactive] public partial string DefUserAgent { get; set; }
[Reactive] public partial string SendThrough { get; set; }
[Reactive] public partial string BindInterface { get; set; }
[Reactive] public partial int? Mux4RayConcurrency { get; set; }
[Reactive] public partial int? Mux4RayXudpConcurrency { get; set; }
[Reactive] public partial string Mux4RayXudpProxyUDP443 { get; set; }
[Reactive] public partial string Mux4SboxProtocol { get; set; }
[Reactive] public partial bool EnableCacheFile4Sbox { get; set; }
[Reactive] public partial int? HyUpMbps { get; set; }
@@ -152,6 +155,9 @@ public partial class OptionSettingViewModel : MyReactiveObject, ICloseable
DefUserAgent = _config.CoreBasicItem.DefUserAgent;
SendThrough = _config.CoreBasicItem.SendThrough ?? string.Empty;
BindInterface = _config.CoreBasicItem.BindInterface ?? string.Empty;
Mux4RayConcurrency = _config.Mux4RayItem.Concurrency;
Mux4RayXudpConcurrency = _config.Mux4RayItem.XudpConcurrency;
Mux4RayXudpProxyUDP443 = _config.Mux4RayItem.XudpProxyUDP443 ?? string.Empty;
Mux4SboxProtocol = _config.Mux4SboxItem.Protocol;
EnableCacheFile4Sbox = _config.CoreBasicItem.EnableCacheFile4Sbox;
HyUpMbps = _config.HysteriaItem.UpMbps;
@@ -327,6 +333,9 @@ public partial class OptionSettingViewModel : MyReactiveObject, ICloseable
_config.CoreBasicItem.DefUserAgent = DefUserAgent;
_config.CoreBasicItem.SendThrough = SendThrough.TrimEx();
_config.CoreBasicItem.BindInterface = BindInterface.TrimEx();
_config.Mux4RayItem.Concurrency = Mux4RayConcurrency > 0 ? Mux4RayConcurrency : null;
_config.Mux4RayItem.XudpConcurrency = Mux4RayXudpConcurrency > 0 ? Mux4RayXudpConcurrency : null;
_config.Mux4RayItem.XudpProxyUDP443 = Mux4RayXudpProxyUDP443.NullIfEmpty();
_config.Mux4SboxItem.Protocol = Mux4SboxProtocol;
_config.CoreBasicItem.EnableCacheFile4Sbox = EnableCacheFile4Sbox;
_config.HysteriaItem.UpMbps = HyUpMbps ?? 0;
@@ -61,24 +61,22 @@ public partial class ProfilesSelectViewModel : MyReactiveObject, ICloseable
SelectFinish();
});
this.WhenAnyValue(
x => x.SelectedSub,
y => y != null && !y.Remarks.IsNullOrEmpty() && _subIndexId != y.Id)
.Subscribe(async c => await SubSelectedChangedAsync(c));
this.WhenAnyValue(x => x.SelectedSub)
.Where(y => y != null && !y.Remarks.IsNullOrEmpty() && _subIndexId != y.Id)
.SubscribeAsync(async _ => await SubSelectedChangedAsync());
this.WhenAnyValue(
x => x.ServerFilter,
y => y != null && _serverFilter != y)
.Subscribe(async c => await ServerFilterChanged(c));
this.WhenAnyValue(x => x.ServerFilter)
.Where(y => y != null && _serverFilter != y)
.SubscribeAsync(async _ => await ServerFilterChanged());
// React to ConfigType filter changes
this.WhenAnyValue(x => x.FilterExclude)
.Skip(1)
.Subscribe(async _ => await RefreshServersBiz());
.SubscribeAsync(async _ => await RefreshServers());
this.WhenAnyValue(x => x.FilterConfigTypes)
.Skip(1)
.Subscribe(async _ => await RefreshServersBiz());
.SubscribeAsync(async _ => await RefreshServers());
#endregion WhenAnyValue && ReactiveCommand
@@ -128,12 +126,8 @@ public partial class ProfilesSelectViewModel : MyReactiveObject, ICloseable
#region Servers && Groups
private async Task SubSelectedChangedAsync(bool c)
private async Task SubSelectedChangedAsync()
{
if (!c)
{
return;
}
_subIndexId = SelectedSub?.Id;
await RefreshServers();
@@ -141,12 +135,8 @@ public partial class ProfilesSelectViewModel : MyReactiveObject, ICloseable
await ProfilesFocusInteraction.HandleSafe(RxVoid.Default);
}
private async Task ServerFilterChanged(bool c)
private async Task ServerFilterChanged()
{
if (!c)
{
return;
}
_serverFilter = ServerFilter;
if (_serverFilter.IsNullOrEmpty())
{
@@ -156,15 +146,20 @@ public partial class ProfilesSelectViewModel : MyReactiveObject, ICloseable
public async Task RefreshServers()
{
await RefreshServersBiz();
await Signal.FromAsync(async () =>
{
await RefreshServersBiz();
return RxVoid.Default;
})
.SubscribeOn(RxSchedulers.MainThreadScheduler)
.ToTask();
}
private async Task RefreshServersBiz()
{
var lstModel = await GetProfileItemsEx(_subIndexId, _serverFilter);
ProfileItems.Clear();
ProfileItems.AddRange(lstModel);
ProfileItems.ReplaceRange(lstModel);
if (lstModel.Count > 0)
{
var selected = lstModel.FirstOrDefault(t => t.IndexId == _config.IndexId);
@@ -177,8 +172,7 @@ public partial class ProfilesSelectViewModel : MyReactiveObject, ICloseable
var subItems = await AppManager.Instance.SubItems();
subItems.Insert(0, new SubItem { Remarks = ResUI.AllGroupServers });
SubItems.Clear();
SubItems.AddRange(subItems);
SubItems.ReplaceRange(subItems);
SelectedSub = (_config.SubIndexId.IsNotEmpty()
? subItems.FirstOrDefault(t => t.Id == _config.SubIndexId)
@@ -188,7 +182,10 @@ public partial class ProfilesSelectViewModel : MyReactiveObject, ICloseable
private async Task<List<ProfileItemModel>?> GetProfileItemsEx(string subid, string filter)
{
var lstModel = await AppManager.Instance.ProfileModels(_subIndexId, filter);
var lstProfileExs = await ProfileExManager.Instance.GetProfileExs();
lstModel = (from t in lstModel
join t3 in lstProfileExs on t.IndexId equals t3.IndexId into t3b
from t33 in t3b.DefaultIfEmpty()
select new ProfileItemModel
{
IndexId = t.IndexId,
@@ -202,6 +199,12 @@ public partial class ProfilesSelectViewModel : MyReactiveObject, ICloseable
Subid = t.Subid,
SubRemarks = t.SubRemarks,
IsActive = t.IndexId == _config.IndexId,
Sort = t33?.Sort ?? 0,
Delay = t33?.Delay ?? 0,
Speed = t33?.Speed ?? 0,
DelayVal = t33?.Delay != 0 ? $"{t33?.Delay}" : string.Empty,
SpeedVal = t33?.Speed > 0 ? $"{t33?.Speed}" : t33?.Message ?? string.Empty,
IpInfo = t33?.IpInfo ?? string.Empty,
}).OrderBy(t => t.Sort).ToList();
// Apply ConfigType filter (include or exclude)
@@ -298,8 +301,7 @@ public partial class ProfilesSelectViewModel : MyReactiveObject, ICloseable
: ProfileItems.OrderByDescending(KeySelector, comparer);
var list = sorted.ToList();
ProfileItems.Clear();
ProfileItems.AddRange(list);
ProfileItems.ReplaceRange(list);
_dicHeaderSort[colName] = !asc;
@@ -15,7 +15,6 @@ public partial class ProfilesViewModel : MyReactiveObject
#region private prop
private List<ProfileItem> _lstProfile;
private string _serverFilter = string.Empty;
private readonly Dictionary<string, bool> _dicHeaderSort = new();
private SpeedtestService? _speedtestService;
@@ -103,19 +102,16 @@ public partial class ProfilesViewModel : MyReactiveObject
x => x.SelectedProfile,
selectedSource => selectedSource != null && !selectedSource.IndexId.IsNullOrEmpty());
this.WhenAnyValue(
x => x.SelectedSub,
y => y != null && !y.Remarks.IsNullOrEmpty() && _config.SubIndexId != y.Id)
.Subscribe(async c => await SubSelectedChangedAsync(c));
this.WhenAnyValue(
x => x.SelectedMoveToGroup,
y => y != null && !y.Remarks.IsNullOrEmpty())
.Subscribe(async c => await MoveToGroup(c));
this.WhenAnyValue(x => x.SelectedSub)
.Where(y => y != null && !y.Remarks.IsNullOrEmpty() && _config.SubIndexId != y.Id)
.SubscribeAsync(async _ => await SubSelectedChangedAsync());
this.WhenAnyValue(x => x.SelectedMoveToGroup)
.Where(y => y != null && !y.Remarks.IsNullOrEmpty())
.SubscribeAsync(async _ => await MoveToGroup());
this.WhenAnyValue(
x => x.ServerFilter,
y => y != null && _serverFilter != y)
.Subscribe(async c => await ServerFilterChanged(c));
this.WhenAnyValue(x => x.ServerFilter)
.Where(y => y != null && _serverFilter != y)
.SubscribeAsync(async _ => await ServerFilterChanged());
//servers delete
EditServerCmd = ReactiveCommand.CreateFromTask(async () =>
@@ -249,7 +245,7 @@ public partial class ProfilesViewModel : MyReactiveObject
AppEvents.DispatcherStatisticsRequested
.AsObservable()
.ObserveOn(RxSchedulers.MainThreadScheduler)
.Subscribe(async result => await UpdateStatistics(result));
.SubscribeAsync(async result => await UpdateStatistics(result));
#endregion AppEvents
@@ -335,12 +331,8 @@ public partial class ProfilesViewModel : MyReactiveObject
#region Servers && Groups
private async Task SubSelectedChangedAsync(bool c)
private async Task SubSelectedChangedAsync()
{
if (!c)
{
return;
}
_config.SubIndexId = SelectedSub?.Id;
await RefreshServers();
@@ -348,12 +340,8 @@ public partial class ProfilesViewModel : MyReactiveObject
await ProfilesFocusInteraction.HandleSafe(RxVoid.Default);
}
private async Task ServerFilterChanged(bool c)
private async Task ServerFilterChanged()
{
if (!c)
{
return;
}
_serverFilter = ServerFilter;
if (_serverFilter.IsNullOrEmpty())
{
@@ -373,10 +361,8 @@ public partial class ProfilesViewModel : MyReactiveObject
public async Task RefreshServersBiz()
{
var lstModel = await GetProfileItemsEx(_config.SubIndexId, _serverFilter);
_lstProfile = JsonUtils.Deserialize<List<ProfileItem>>(JsonUtils.Serialize(lstModel)) ?? [];
ProfileItems.Clear();
ProfileItems.AddRange(lstModel ?? []);
ProfileItems.ReplaceRange(lstModel ?? []);
if (lstModel?.Count > 0)
{
ProfileItemModel? selected = null;
@@ -397,8 +383,7 @@ public partial class ProfilesViewModel : MyReactiveObject
var subItems = await AppManager.Instance.SubItems();
subItems.Insert(0, new SubItem { Remarks = ResUI.AllGroupServers });
SubItems.Clear();
SubItems.AddRange(subItems);
SubItems.ReplaceRange(subItems);
SelectedSub = (_config.SubIndexId.IsNotEmpty()
? subItems.FirstOrDefault(t => t.Id == _config.SubIndexId)
@@ -672,13 +657,8 @@ public partial class ProfilesViewModel : MyReactiveObject
}
//move server
private async Task MoveToGroup(bool c)
private async Task MoveToGroup()
{
if (!c)
{
return;
}
var lstSelected = await GetProfileItems(true);
if (lstSelected == null)
{
@@ -695,19 +675,15 @@ public partial class ProfilesViewModel : MyReactiveObject
public async Task MoveServer(EMove eMove)
{
var item = _lstProfile.FirstOrDefault(t => t.IndexId == SelectedProfile.IndexId);
if (item is null)
var lstProfile = ProfileItems?.Select(t => t.IndexId).ToList() ?? [];
var index = lstProfile.IndexOf(SelectedProfile.IndexId);
if (index < 0)
{
NoticeManager.Instance.Enqueue(ResUI.PleaseSelectServer);
return;
}
var index = _lstProfile.IndexOf(item);
if (index < 0)
{
return;
}
if (await ConfigHandler.MoveServer(_config, _lstProfile, index, eMove) == 0)
if (await ConfigHandler.MoveServer(_config, lstProfile, index, eMove) == 0)
{
await RefreshServers();
}
@@ -718,7 +694,8 @@ public partial class ProfilesViewModel : MyReactiveObject
var targetIndex = ProfileItems.IndexOf(targetItem);
if (startIndex >= 0 && targetIndex >= 0 && startIndex != targetIndex)
{
if (await ConfigHandler.MoveServer(_config, _lstProfile, startIndex, EMove.Position, targetIndex) == 0)
var lstProfile = ProfileItems?.Select(t => t.IndexId).ToList() ?? [];
if (await ConfigHandler.MoveServer(_config, lstProfile, startIndex, EMove.Position, targetIndex) == 0)
{
await RefreshServers();
}
@@ -901,7 +878,7 @@ public partial class ProfilesViewModel : MyReactiveObject
if (await AppManager.Instance.WindowDialog.ShowDialogAsync(subEditViewModel) == true)
{
await RefreshSubscriptions();
await SubSelectedChangedAsync(true);
await SubSelectedChangedAsync();
}
}
@@ -920,7 +897,7 @@ public partial class ProfilesViewModel : MyReactiveObject
await ConfigHandler.DeleteSubItem(_config, item.Id);
await RefreshSubscriptions();
await SubSelectedChangedAsync(true);
await SubSelectedChangedAsync();
}
#endregion Subscription
@@ -100,8 +100,6 @@ public partial class RoutingRuleSettingViewModel : MyReactiveObject, ICloseable
public void RefreshRulesItems()
{
RulesItems.Clear();
var models = new List<RulesItemModel>();
foreach (var item in _rules)
{
@@ -120,7 +118,7 @@ public partial class RoutingRuleSettingViewModel : MyReactiveObject, ICloseable
};
models.Add(it);
}
RulesItems.AddRange(models);
RulesItems.ReplaceRange(models);
}
public async Task RuleEditAsync(bool blNew)
@@ -61,10 +61,10 @@ public partial class RoutingSettingViewModel : MyReactiveObject
x => x.DomainStrategy4Singbox)
.Skip(1)
.DistinctUntilChanged()
.Subscribe(x =>
.SubscribeAsync(async x =>
{
IsModified = true;
_ = SaveSettingsAsync();
await SaveSettingsAsync();
});
}
@@ -83,7 +83,6 @@ public partial class RoutingSettingViewModel : MyReactiveObject
public async Task RefreshRoutingItems()
{
RoutingItems.Clear();
var models = new List<RoutingItemModel>();
var routings = await AppManager.Instance.RoutingItems();
@@ -102,7 +101,7 @@ public partial class RoutingSettingViewModel : MyReactiveObject
};
models.Add(it);
}
RoutingItems.AddRange(models);
RoutingItems.ReplaceRange(models);
}
/// <summary>
@@ -123,26 +123,21 @@ public partial class StatusBarViewModel : MyReactiveObject
#region WhenAnyValue && ReactiveCommand
this.WhenAnyValue(
x => x.SelectedRouting,
y => y != null && !y.Remarks.IsNullOrEmpty())
.Subscribe(async c => await RoutingSelectedChangedAsync(c));
this.WhenAnyValue(x => x.SelectedRouting)
.Where(y => y != null && !y.Remarks.IsNullOrEmpty())
.SubscribeAsync(async _ => await RoutingSelectedChangedAsync());
this.WhenAnyValue(
x => x.SelectedServer,
y => y != null && !y.Text.IsNullOrEmpty())
.Subscribe(ServerSelectedChanged);
this.WhenAnyValue(x => x.SelectedServer)
.Where(y => y != null && !y.Text.IsNullOrEmpty())
.Subscribe(_ => ServerSelectedChanged());
SystemProxySelected = (int)_config.SystemProxyItem.SysProxyType;
this.WhenAnyValue(
x => x.SystemProxySelected,
y => y >= 0)
.Subscribe(async c => await DoSystemProxySelected(c));
this.WhenAnyValue(x => x.SystemProxySelected)
.Where(y => y >= 0)
.SubscribeAsync(async _ => await DoSystemProxySelected());
this.WhenAnyValue(
x => x.EnableTun,
y => y == true)
.Subscribe(async c => await DoEnableTun(c));
this.WhenAnyValue(x => x.EnableTun)
.SubscribeAsync(async _ => await DoEnableTun());
CopyProxyCmdToClipboardCmd = ReactiveCommand.CreateFromTask(async () =>
{
@@ -207,12 +202,12 @@ public partial class StatusBarViewModel : MyReactiveObject
AppEvents.DispatcherStatisticsRequested
.AsObservable()
.ObserveOn(RxSchedulers.MainThreadScheduler)
.Subscribe(async result => await UpdateStatistics(result));
.SubscribeAsync(async result => await UpdateStatistics(result));
AppEvents.SysProxyChangeRequested
.AsObservable()
.ObserveOn(RxSchedulers.MainThreadScheduler)
.Subscribe(async result => await SetListenerType(result));
.SubscribeAsync(async result => await SetListenerType(result));
#endregion AppEvents
@@ -300,19 +295,14 @@ public partial class StatusBarViewModel : MyReactiveObject
var models = lstModel.Select(it => new ComboItem { ID = it.IndexId, Text = it.GetSummary() }).ToList();
BlServers = true;
Servers.Clear();
Servers.AddRange(models);
Servers.ReplaceRange(models);
// Update the ItemsSource before SelectedItem so a collection reset does not clear the tray selection.
SelectedServer = models.FirstOrDefault(it => it.ID == _config.IndexId) ?? new();
}
private void ServerSelectedChanged(bool c)
private void ServerSelectedChanged()
{
if (!c)
{
return;
}
if (SelectedServer == null)
{
return;
@@ -324,20 +314,32 @@ public partial class StatusBarViewModel : MyReactiveObject
SetDefaultServerRequested.Publish(SelectedServer.ID);
}
public async Task TestServerAvailability()
public async Task<AvailabilityCheckResult?> TestServerAvailability()
{
var item = await ConfigHandler.GetDefaultServer(_config);
if (item == null)
{
return;
return null;
}
await TestServerAvailabilitySub(ResUI.Speedtesting);
var msg = await Task.Run(ConnectionHandler.RunAvailabilityCheck);
var result = await Task.Run(ConnectionHandler.RunAvailabilityCheck);
var msg = string.Format(ResUI.TestMeOutput, result.Time, result.Ip);
var ip = result.GetValidIp();
if (ip.IsNotEmpty())
{
ProfileExManager.Instance.SetTestIpInfo(item.IndexId, ip);
}
if (result.Time > 0)
{
ProfileExManager.Instance.SetTestDelay(item.IndexId, result.Time);
}
NoticeManager.Instance.SendMessageEx(msg);
await TestServerAvailabilitySub(msg);
return result;
}
private async Task TestServerAvailabilitySub(string msg)
@@ -390,19 +392,13 @@ public partial class StatusBarViewModel : MyReactiveObject
{
var routings = await AppManager.Instance.RoutingItems();
RoutingItems.Clear();
RoutingItems.AddRange(routings);
RoutingItems.ReplaceRange(routings);
SelectedRouting = routings.FirstOrDefault(t => t.IsActive == true);
}
private async Task RoutingSelectedChangedAsync(bool c)
private async Task RoutingSelectedChangedAsync()
{
if (!c)
{
return;
}
if (SelectedRouting == null)
{
return;
@@ -422,12 +418,8 @@ public partial class StatusBarViewModel : MyReactiveObject
}
}
private async Task DoSystemProxySelected(bool c)
private async Task DoSystemProxySelected()
{
if (!c)
{
return;
}
if (_config.SystemProxyItem.SysProxyType == (ESysProxyType)SystemProxySelected)
{
return;
@@ -435,7 +427,7 @@ public partial class StatusBarViewModel : MyReactiveObject
await SetListenerType((ESysProxyType)SystemProxySelected);
}
private async Task DoEnableTun(bool c)
private async Task DoEnableTun()
{
if (_config.TunModeItem.EnableTun == EnableTun)
{
@@ -77,6 +77,12 @@ public partial class SubEditViewModel : MyReactiveObject, ICloseable
}
}
if (!HttpRequestHeadersHelper.TryParse(SelectedSource.RequestHeaders, out _))
{
NoticeManager.Instance.Enqueue(ResUI.SubRequestHeadersInvalid);
return;
}
SelectedSource.CustomCoreType = Enum.TryParse<ECoreType>(CustomCoreType, out var coreType) ? coreType : null;
SelectedSource.PrevProfile = PrevProfile;
SelectedSource.NextProfile = NextProfile;
@@ -55,8 +55,7 @@ public partial class SubSettingViewModel : MyReactiveObject
public async Task RefreshSubItems()
{
SubItems.Clear();
SubItems.AddRange(await AppManager.Instance.SubItems());
SubItems.ReplaceRange(await AppManager.Instance.SubItems());
}
public async Task EditSubAsync(bool blNew)
@@ -1,3 +1,4 @@
using System.Runtime.CompilerServices;
using Avalonia.Controls.Templates;
using v2rayN.Desktop.ViewModels;
using v2rayN.Desktop.Views;
@@ -9,6 +10,7 @@ public class SimpleViewLocator : IDataTemplate
private static readonly Lazy<SimpleViewLocator> _instance = new(() => new SimpleViewLocator());
private readonly Dictionary<Type, Func<Control?>> _locator = new();
private readonly ConditionalWeakTable<object, Control> _cachedViews = new();
private SimpleViewLocator()
{
@@ -45,9 +47,18 @@ public class SimpleViewLocator : IDataTemplate
return new TextBlock { Text = "No VM provided" };
}
_locator.TryGetValue(data.GetType(), out var factory);
var vmType = data.GetType();
if (!_locator.TryGetValue(vmType, out var factory))
{
return new TextBlock { Text = $"VM Not Registered: {vmType}" };
}
return factory?.Invoke() ?? new TextBlock { Text = $"VM Not Registered: {data.GetType()}" };
if (ShouldCache(vmType))
{
return _cachedViews.GetValue(data, _ => CreateView(factory, vmType));
}
return CreateView(factory, vmType);
}
public bool Match(object? data)
@@ -55,6 +66,19 @@ public class SimpleViewLocator : IDataTemplate
return data is MyReactiveObject;
}
private static bool ShouldCache(Type vmType)
{
return vmType == typeof(MsgViewModel)
|| vmType == typeof(ClashProxiesViewModel)
|| vmType == typeof(ClashConnectionsViewModel)
|| vmType == typeof(ProfilesViewModel);
}
private static Control CreateView(Func<Control?> factory, Type vmType)
{
return factory.Invoke() ?? new TextBlock { Text = $"View Factory Returned Null: {vmType}" };
}
public void RegisterViewFactory<TViewModel>(Func<Control> factory) where TViewModel : class
{
_locator.Add(typeof(TViewModel), factory);
+1
View File
@@ -22,6 +22,7 @@ global using ReactiveUI;
global using ReactiveUI.Avalonia;
global using ReactiveUI.Primitives;
global using ReactiveUI.Primitives.Disposables;
global using ReactiveUI.Primitives.Extensions;
global using ReactiveUI.SourceGenerators;
global using ServiceLib;
global using ServiceLib.Base;
@@ -35,39 +35,38 @@ public partial class ThemeSettingViewModel : MyReactiveObject
CurrentLanguage = _config.UiItem.CurrentLanguage;
this.WhenAnyValue(x => x.CurrentTheme)
.Subscribe(c =>
.Where(y => y.IsNotEmpty())
.SubscribeAsync(async _ =>
{
if (_config.UiItem.CurrentTheme != CurrentTheme)
{
_config.UiItem.CurrentTheme = CurrentTheme;
ModifyTheme();
_ = ConfigHandler.SaveConfig(_config);
await ConfigHandler.SaveConfig(_config);
}
});
this.WhenAnyValue(
x => x.CurrentFontSize,
y => y > 0)
.Subscribe(c =>
this.WhenAnyValue(x => x.CurrentFontSize)
.Where(y => y > 0)
.SubscribeAsync(async _ =>
{
if (_config.UiItem.CurrentFontSize != CurrentFontSize && CurrentFontSize >= Global.MinFontSize)
{
_config.UiItem.CurrentFontSize = CurrentFontSize;
ModifyFontSize();
_ = ConfigHandler.SaveConfig(_config);
await ConfigHandler.SaveConfig(_config);
}
});
this.WhenAnyValue(
x => x.CurrentLanguage,
y => y != null && !y.IsNullOrEmpty())
.Subscribe(c =>
this.WhenAnyValue(x => x.CurrentLanguage)
.Where(y => !y.IsNullOrEmpty())
.SubscribeAsync(async _ =>
{
if (CurrentLanguage.IsNotEmpty() && _config.UiItem.CurrentLanguage != CurrentLanguage)
{
_config.UiItem.CurrentLanguage = CurrentLanguage;
Thread.CurrentThread.CurrentUICulture = new(CurrentLanguage);
_ = ConfigHandler.SaveConfig(_config);
await ConfigHandler.SaveConfig(_config);
NoticeManager.Instance.Enqueue(ResUI.NeedRebootTips);
}
});
@@ -580,7 +580,7 @@
Grid.Row="2"
ColumnDefinitions="300,Auto"
IsVisible="False"
RowDefinitions="Auto,Auto,Auto,Auto,Auto,Auto,Auto">
RowDefinitions="Auto,Auto,Auto,Auto,Auto,Auto,Auto,Auto">
<TextBlock
Grid.Row="1"
@@ -649,7 +649,7 @@
Grid.Column="1"
Width="400"
Margin="{StaticResource Margin4}"
PlaceholderText="Ipv4,Ipv6" />
PlaceholderText="Ipv4, Ipv6" />
<TextBlock
Grid.Row="6"
@@ -665,6 +665,21 @@
Margin="{StaticResource Margin4}"
HorizontalAlignment="Left"
PlaceholderText="1280" />
<TextBlock
Grid.Row="7"
Grid.Column="0"
Margin="{StaticResource Margin4}"
VerticalAlignment="Center"
Text="{x:Static resx:ResUI.TbDNS}" />
<TextBox
x:Name="txtDns"
Grid.Row="7"
Grid.Column="1"
Width="400"
Margin="{StaticResource Margin4}"
HorizontalAlignment="Left"
PlaceholderText="1.1.1.1, 2606:4700:4700::1111" />
</Grid>
<Grid
x:Name="gridAnytls"
@@ -125,6 +125,7 @@ public partial class AddServerWindow : WindowBase<AddServerViewModel>
this.Bind(ViewModel, vm => vm.WgReserved, v => v.txtPath9.Text).DisposeWith(currentTypeDisposables);
this.Bind(ViewModel, vm => vm.WgInterfaceAddress, v => v.txtRequestHost9.Text).DisposeWith(currentTypeDisposables);
this.Bind(ViewModel, vm => vm.WgMtu, v => v.txtShortId9.Text).DisposeWith(currentTypeDisposables);
this.Bind(ViewModel, vm => vm.WgDns, v => v.txtDns.Text).DisposeWith(currentTypeDisposables);
break;
case EConfigType.Anytls:
@@ -25,6 +25,16 @@
HorizontalAlignment="Center"
Orientation="Horizontal">
<TextBlock
Margin="{StaticResource Margin4}"
HorizontalAlignment="Left"
VerticalAlignment="Center"
Text="{x:Static resx:ResUI.TbSettingsEnableUpdateViaProxy}" />
<ToggleSwitch
x:Name="togEnableUpdateViaProxy"
Margin="{StaticResource Margin4}"
HorizontalAlignment="Left" />
<TextBlock
Margin="{StaticResource Margin4}"
HorizontalAlignment="Left"
@@ -11,6 +11,7 @@ public partial class CheckUpdateView : ReactiveUserControl<CheckUpdateViewModel>
this.OneWayBind(ViewModel, vm => vm.CheckUpdateModels, v => v.lstCheckUpdates.ItemsSource).DisposeWith(disposables);
this.Bind(ViewModel, vm => vm.EnableCheckPreReleaseUpdate, v => v.togEnableCheckPreReleaseUpdate.IsChecked).DisposeWith(disposables);
this.Bind(ViewModel, vm => vm.EnableUpdateViaProxy, v => v.togEnableUpdateViaProxy.IsChecked).DisposeWith(disposables);
this.BindCommand(ViewModel, vm => vm.CheckOnlyCmd, v => v.btnCheckOnly).DisposeWith(disposables);
this.BindCommand(ViewModel, vm => vm.CheckUpdateCmd, v => v.btnCheckUpdate).DisposeWith(disposables);
});
@@ -93,6 +93,11 @@
Binding="{Binding Type}"
Header="{x:Static resx:ResUI.TbSortingType}"
Tag="Type" />
<DataGridTextColumn
Width="100"
Binding="{Binding ProcessPath}"
Header="{x:Static resx:ResUI.TbSortingProcess}"
Tag="ProcessPath" />
<DataGridTextColumn
Width="100"
Binding="{Binding Elapsed}"
@@ -32,9 +32,11 @@
x:Name="cmbRulemode"
Width="100"
Margin="{StaticResource MarginLr8}">
<ComboBoxItem Content="{x:Static resx:ResUI.menuModeRule}" />
<ComboBoxItem Content="{x:Static resx:ResUI.menuModeGlobal}" />
<ComboBoxItem Content="{x:Static resx:ResUI.menuModeDirect}" />
<ComboBox.ItemTemplate>
<DataTemplate x:DataType="x:String">
<TextBlock Text="{Binding}" />
</DataTemplate>
</ComboBox.ItemTemplate>
</ComboBox>
<TextBlock
@@ -61,10 +63,10 @@
</Button>
<Button
x:Name="menuProxiesDelaytest"
x:Name="menuGroupProxiesDelaytest"
Margin="{StaticResource MarginLr8}"
Classes="IconButton Success"
ToolTip.Tip="{x:Static resx:ResUI.menuProxiesDelaytest}">
ToolTip.Tip="{x:Static resx:ResUI.menuDelaytest}">
<Button.Content>
<PathIcon Data="{StaticResource SemiIconBolt}" />
</Button.Content>
@@ -111,7 +113,7 @@
<ListBox x:Name="lstProxyDetails" ItemsSource="{Binding ProxyDetails}">
<ItemsControl.ContextMenu>
<ContextMenu>
<MenuItem x:Name="menuProxiesDelaytestPart" Header="{x:Static resx:ResUI.menuProxiesDelaytestPart}" />
<MenuItem x:Name="menuProxyDelaytest" Header="{x:Static resx:ResUI.menuDelaytest}" />
<MenuItem
x:Name="menuProxiesSelectActivity"
Header="{x:Static resx:ResUI.menuProxiesSelectActivity}"
@@ -17,12 +17,13 @@ public partial class ClashProxiesView : ReactiveUserControl<ClashProxiesViewMode
this.Bind(ViewModel, vm => vm.SelectedDetail, v => v.lstProxyDetails.SelectedItem).DisposeWith(disposables);
this.BindCommand(ViewModel, vm => vm.ProxiesReloadCmd, v => v.menuProxiesReload).DisposeWith(disposables);
this.BindCommand(ViewModel, vm => vm.ProxiesDelayTestCmd, v => v.menuProxiesDelaytest).DisposeWith(disposables);
this.BindCommand(ViewModel, vm => vm.GroupProxiesDelayTestCmd, v => v.menuGroupProxiesDelaytest).DisposeWith(disposables);
this.BindCommand(ViewModel, vm => vm.ProxiesDelayTestPartCmd, v => v.menuProxiesDelaytestPart).DisposeWith(disposables);
this.BindCommand(ViewModel, vm => vm.ProxyDelayTestCmd, v => v.menuProxyDelaytest).DisposeWith(disposables);
this.BindCommand(ViewModel, vm => vm.ProxiesSelectActivityCmd, v => v.menuProxiesSelectActivity).DisposeWith(disposables);
this.Bind(ViewModel, vm => vm.RuleModeSelected, v => v.cmbRulemode.SelectedIndex).DisposeWith(disposables);
this.OneWayBind(ViewModel, vm => vm.ClashModes, v => v.cmbRulemode.ItemsSource).DisposeWith(disposables);
this.Bind(ViewModel, vm => vm.RuleModeSelected, v => v.cmbRulemode.SelectedItem).DisposeWith(disposables);
this.Bind(ViewModel, vm => vm.SortingSelected, v => v.cmbSorting.SelectedIndex).DisposeWith(disposables);
this.Bind(ViewModel, vm => vm.AutoRefresh, v => v.togAutoRefresh.IsChecked).DisposeWith(disposables);
});
@@ -405,6 +405,8 @@ public partial class MainWindow : WindowBase<MainWindowViewModel>
var currentLayoutDisposables = new MultipleDisposable();
_layoutBindingsDisposable.Create(currentLayoutDisposables);
ClearLayoutContent();
gridMain.IsVisible = orientation == EGirdOrientation.Horizontal;
gridMain1.IsVisible = orientation == EGirdOrientation.Vertical;
gridMain2.IsVisible = orientation == EGirdOrientation.Tab;
@@ -448,6 +450,24 @@ public partial class MainWindow : WindowBase<MainWindowViewModel>
RestoreUI();
}
private void ClearLayoutContent()
{
tabProfiles.Content = null;
tabMsgView.Content = null;
tabClashProxies.Content = null;
tabClashConnections.Content = null;
tabProfiles1.Content = null;
tabMsgView1.Content = null;
tabClashProxies1.Content = null;
tabClashConnections1.Content = null;
tabProfiles2.Content = null;
tabMsgView2.Content = null;
tabClashProxies2.Content = null;
tabClashConnections2.Content = null;
}
private void AddHelpMenuItem()
{
var coreInfo = CoreInfoManager.Instance.GetCoreInfo();
@@ -50,6 +50,9 @@ public partial class MsgView : ReactiveUserControl<MsgViewModel>
if (togScrollToEnd.IsChecked ?? true)
{
txtMsg.ScrollToEnd();
Dispatcher.UIThread.Invoke(() =>
txtMsg.ScrollTo(txtMsg.LineCount, 0, AvaloniaEdit.Rendering.VisualYPosition.TextBottom, txtMsg.Bounds.Height, 0),
DispatcherPriority.Background);
}
}

Some files were not shown because too many files have changed in this diff Show More