Compare commits

..

73 Commits

Author SHA1 Message Date
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
2dust af0eb9ed14 Code clean 2026-08-22 17:32:26 +08:00
DHR60 0436081436 Revert "fix(tun): automatically exclude proxy node IPs and resolved domains from TUN routing to prevent loops (#9974) (#10012)" (#10013)
This reverts commit 2be63d1655.
2026-08-22 15:02:24 +08:00
2dust 2a89a2e597 up 7.24.8 2026-08-22 15:01:25 +08:00
Mangoo 2be63d1655 fix(tun): automatically exclude proxy node IPs and resolved domains from TUN routing to prevent loops (#9974) (#10012) 2026-08-22 14:49:27 +08:00
DHR60 74bd28bbd0 Fix test (#10007) 2026-08-22 10:25:57 +08:00
DHR60 be501cbd2a Fix dependabot path (#10008) 2026-08-22 10:20:41 +08:00
Miheichev Aleksandr Sergeevich e20b4dbfab chore(deps): update CliWrap and TUnit, drop unused test pins (#10005)
* chore(deps): update CliWrap and TUnit

- CliWrap                  3.10.4      -> 3.10.5
- TUnit                    1.65.0      -> 1.65.38
- TUnit.Assertions.Should  1.65.0-beta -> 1.65.38-beta

TUnit.Assertions.Should is versioned in lockstep with TUnit and published with
a -beta suffix. Its 1.65.0-beta package depends on TUnit.Assertions 1.65.0-beta,
so it has to move together with TUnit to keep the whole TUnit stack resolved on
a single version (TUnit.Assertions 1.65.38 after this change).

* chore(deps): drop unused AwesomeAssertions and xunit.v3 pins

Since the migration to TUnit (c9e843aa) no project references AwesomeAssertions
or xunit.v3 any more, so these two PackageVersion entries no longer pin
anything: neither package appears in any project.assets.json, directly or
transitively.

Verified by restoring, building and publishing the whole solution with and
without the two entries: the resolved package graph (769 entries across all
projects) and the published output of every CI target - v2rayN win-x64,
v2rayN.Desktop win-x64 and linux-x64, AmazTool win-x64, all self-contained -
are byte-identical, and ServiceLib.Tests stays at 69/69.
2026-08-22 10:19:29 +08:00
DHR60 728cbe70f2 Add block AAAA query support (#10004) 2026-08-22 10:18:48 +08:00
DHR60 26a86f9bd2 Fix (#10002) 2026-08-22 10:17:09 +08:00
DHR60 c66c55b05e Fix dnd (#10003)
* Fix dnd

* Fix
2026-08-22 10:13:21 +08:00
DHR60 ebb4bd5daa Optimize file download (#9952)
* Optimize file download

* Optimize func call
2026-08-21 14:15:54 +08:00
dayepao 14e27f3d23 fix: preserve Mihomo Reality short-id as YAML string (#9964) 2026-08-20 10:47:24 +08:00
DHR60 2d514ff480 Fix (#9991) 2026-08-19 13:48:59 +08:00
DHR60 11bd52b6a5 Fix (#9987) 2026-08-19 09:56:38 +08:00
JieXu 3b3fe8def3 Update build.yml (#9986)
* Update build.yml

* Update build-windows-x86.yml
2026-08-19 09:56:21 +08:00
DHR60 c9e843aa3b TUnit (#9981) 2026-08-19 09:54:51 +08:00
DHR60 7be264c65c Log with caller (#9976) 2026-08-17 14:33:33 +08:00
Miheichev Aleksandr Sergeevich 5e6660aeee chore(deps): update xunit.v3 and NLog, and drop the VSTest packages xunit replaces (#9975)
* chore(deps): update xunit.v3 to 4.0.0 and drop the VSTest packages

xunit.v3 4.0.0 moves from Microsoft.Testing.Platform v1 to v2, and MTP v2 drops the VSTest bridge on the .NET 10 SDK, so anything routed through VSTest now fails before a single test runs.

Rather than bridging back to VSTest, the two VSTest-era packages are removed. The test project is already an executable carrying xunit's own in-process runner, so Microsoft.NET.Test.Sdk and xunit.runner.visualstudio have nothing left to contribute, and without them no opt-in file is needed anywhere.

No source or test changes are required: every 4.0.0 breaking change is in the extensibility and runner APIs, and the suite uses only [Fact], [Theory] and [InlineData].

* ci: run the tests directly and let versions float on their major

test.yml requested the 8.0.x SDK while every project targets net10.0, which an 8.0 SDK cannot build (NETSDK1045), and it invoked dotnet test, which needs the VSTest bridge that MTP v2 has dropped. It now runs the test executable, which needs no adapter and no test SDK.

All three setup-dotnet steps ask for 10.x with quality ga, so a new .NET 10 patch or feature band is picked up automatically while previews and release candidates stay out of builds. setup-dotnet and upload-artifact were the only actions pinned to an exact patch; they now track their major tag like the other seven.

* chore(deps): update NLog to 6.2.0

A minor release with no API change on the surface this project uses. Verified beyond compilation: Logging.Setup builds its FileTarget, and both SaveLog overloads write through it at runtime with the expected layout.
2026-08-17 14:33:18 +08:00
JieXu 5b27f4836f Update package-rhel-riscv.sh (#9971)
* Update package-rhel-riscv.sh

* Update package-rhel-loong.sh

* Update package-debian-riscv.sh

* Update package-debian-loong.sh
2026-08-16 10:08:08 +08:00
2dust e0ad2c46e6 up 7.24.7 2026-08-15 10:14:52 +08:00
2dust 230a2f6773 Bug fix
https://github.com/2dust/v2rayN/issues/9936
2026-08-14 12:08:43 +08:00
白龙 e7cc4c832e Fix tray server selection being cleared after refresh (#9927) (#9928)
* Fix tray server selection after refresh (#9927)

* Update StatusBarViewModel.cs

---------

Co-authored-by: 2dust <31833384+2dust@users.noreply.github.com>
2026-08-14 10:38:27 +08:00
DHR60 fb7033aab5 Add hy2 ech support (#9954) 2026-08-14 10:22:31 +08:00
DHR60 b6cc3913ac Perf hy2 fmt (#9953) 2026-08-14 10:22:10 +08:00
DHR60 bc06a5dc21 Fix (#9951)
* Fix

* Fix

* Add HandleSafe
2026-08-14 10:20:58 +08:00
Chen, Ting-An 9c1951178c fix(i18n): polish Traditional Chinese DNS settings (#9947)
Replace Simplified Chinese and ambiguous translations in the DNS and full configuration template settings with natural Taiwan terminology. Clarify related descriptions without changing resource keys or application behavior.
2026-08-14 10:20:16 +08:00
DHR60 b3df42b9ba Sync finalmask order (#9943) 2026-08-14 10:19:56 +08:00
liuclare e01717d832 Narrow the TUN self-address drop rule to single addresses (#9935)
The rule added in #9897 takes the TUN inbound's `address` verbatim as `ip_cidr`,
so a /30 or /126 interface prefix becomes the match range.

sing-tun derives the TUN's DNS entry from the address right after the interface's
own and hands it to the system resolver: Windows through luid.SetDNS in
tun_windows.go, Linux through systemd-resolved in tun_linux.go, both guarded only
by AutoRoute && !EXP_DisableDNSHijack. HasNextAddress keeps that address inside
the interface prefix, every preset in Global.TunIPv4Address is a /30 and every
IPv6 preset a /126, and the sing-box system stack rejects single-address
prefixes, so there is no configuration where it falls outside.

Queries from the system resolver then hit the drop rule and time out with no
response and no ICMP. Name resolution fails for the whole system while the proxy
path itself stays healthy, which makes it read as a DNS outage rather than a
routing rule. Reported in #9934 and #9926.

Matching each address on its own keeps what #9897 set out to block - the loop it
diagnosed was addressed to the interface address itself - and leaves the DNS
entry to sing-box.

Also restores the two regression tests #9897 came with, removed by eff58459
(#9817) while its implementation and template fix stayed in place.
ShouldRejectTrafficToTunOwnAddresses now asserts the single-address form and
additionally pins the prefix length, so it covers both the loop it was written
for and the resolver address it must not cover.

Verified on Linux by running sing-box directly from a generated config, changing
only this rule's prefix length between runs:

  ip_cidr ["172.18.0.1/30"]   getent hosts www.google.com -> empty, 3/3
  ip_cidr ["172.18.0.1/32"]   getent hosts www.google.com -> resolved, 3/3

dig against a public resolver, naked-IP HTTPS and the local mixed port were
unaffected in both runs. End to end, a build of this branch emits
drop ip_cidr ["172.18.0.1/32"] and system resolution works while its TUN is up.

Co-authored-by: liuclare <177657698+liuclare@users.noreply.github.com>
Co-authored-by: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-08-10 15:09:04 +08:00
DHR60 8b4063b44b Set tun "route only" to true (#9933) 2026-08-09 20:53:00 +08:00
DHR60 3136f573dd Revert "Fix Windows TUN restart issue (#9925)" (#9932)
This reverts commit f347fe1c03.
2026-08-09 20:52:36 +08:00
liuclare e101b1d7b0 Always route IPv6 into Xray TUN regardless of EnableIPv6Address (#9930)
* Always route IPv6 into Xray TUN regardless of EnableIPv6Address

EnableIPv6Address controls whether the TUN interface is assigned an IPv6
address, but it also gated whether ::/0 was added to autoSystemRoutingTable.
With the default (false), IPv6 had no route pointing at the TUN device and
followed the system default route instead, leaving the tunnel unproxied and
exposing the host's real IPv6 address.

The embedded template SampleTunInbound already declares both families; the
generated config discarded it. #9843 restored ::/0 only inside the
EnableIPv6Address == true branch, so the false branch still leaks.

Route both families unconditionally and let the option control only the
interface address. The same conditional existed a second time in the
RouteExcludeAddress branch and is fixed as well.

Fixes #9929

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>

* Add regression tests for IPv6 routing in the Xray TUN inbound

Both assertions fail on 31044f44 and pass with the fix:

  Tun_ShouldRouteIPv6IntoTunnel(enableIPv6Address: False)
    Expected collection {"0.0.0.0/0"} to contain "::/0".

  TunRouteExcludeAddress_ShouldIncludeIPv6Ranges
    Expected collection {...44 IPv4 ranges...} to have an item matching x.Contains(:).

The theory also covers enableIPv6Address: true, which passes on both revisions,
so the tests only fail while the defect is present. The gateway count assertion
pins the intended split of responsibilities: EnableIPv6Address governs the
interface address, never the routing table.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>

---------

Co-authored-by: liuclare <177657698+liuclare@users.noreply.github.com>
Co-authored-by: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-08-09 20:15:18 +08:00
2dust 31044f449d up 7.24.6 2026-08-08 20:38:18 +08:00
2dust eacf8b0784 Reuse inbound config and fix tun sniffing flag
https://github.com/2dust/v2rayN/issues/9921
2026-08-08 20:36:18 +08:00
Mangoo f347fe1c03 Fix Windows TUN restart issue (#9925) 2026-08-08 20:20:41 +08:00
JieXu 71208eb711 Update build-windows-x86.yml (#9924) 2026-08-08 20:01:13 +08:00
tt2563 339eaa6bfd Update ResUI.zh-Hant.resx (#9923)
* Update ResUI.zh-Hant.resx

Update Traditional Chinese translation

* Fix typo in outbound/endpoint message
2026-08-08 19:58:55 +08:00
Miheichev Aleksandr Sergeevich 717ef7f0a7 i18n(ru): add missing translations and remove trailing periods (#9916)
* i18n(ru): add missing Russian translations

Translate the 10 strings missing from ResUI.ru.resx (proxy dial
resolution strategy, Happy Eyeballs, IPv4/IPv6 address labels, and
the custom outbound group). Russian now has full key parity with
ResUI.resx (580/580); placeholder consistency verified for all keys.

Technical terms and config values (outbound, endpoint, UseIP, Happy
Eyeballs) are kept untranslated, matching the zh-Hans locale.

* i18n(ru): remove trailing periods from translations

Align with the Russian UI convention of omitting sentence-final
periods in labels, tooltips and messages (32 values). Inner
punctuation and ellipses are kept; no wording changes.

* i18n(ru): translate TbFakeIPTips left in English

The key existed in ResUI.ru.resx but its value was the untranslated
English text. Translate it following the established glossary (FakeIP
and sing-box remain untranslated).
2026-08-07 14:19:59 +08:00
2dust 4a9d83f9dc Fix duplicated wording in UI resource strings
https://github.com/2dust/v2rayN/pull/9914
2026-08-07 14:13:32 +08:00
JieXu 1ac14d0903 Update package-osx.sh (#9913)
* Update Directory.Packages.props

* Update package-osx.sh
2026-08-07 14:07:44 +08:00
Miheichev Aleksandr Sergeevich 08d423e1b4 docs: link minimum OS requirements from README (#9912)
Replace the requirements table with a single bilingual line under
"Supported Platforms" pointing to the wiki page (Release files
introduction), per the review feedback in the PR: the README stays
minimal and the wiki remains the single source of truth.
2026-08-07 14:07:22 +08:00
2dust 9638b4c368 Fix custom config import
https://github.com/2dust/v2rayN/issues/9902
2026-08-06 09:29:33 +08:00
Miheichev Aleksandr Sergeevich 1034ce6e02 chore(deps): update NuGet dependencies (#9832)
* chore(deps): bump Avalonia.Desktop, CliWrap, Semi.Avalonia and Repobot.SQLite

- Avalonia.Desktop          12.1.0    -> 12.1.1
- CliWrap                   3.10.2    -> 3.10.4
- Semi.Avalonia             12.1.0    -> 12.1.0.1
- Semi.Avalonia.DataGrid    12.1.0    -> 12.1.0.1
- Repobot.SQLite.Unofficial 3.53.3.10 -> 3.53.4

Left unchanged on purpose:

- ReactiveUI.Avalonia: nuget.org reports 14.7.1 as the highest version, but it
  is unlisted and belongs to the old versioning line (netstandard2.0/net6.0/
  net7.0, Avalonia >= 11.0.0, ReactiveUI >= 19.4.1). Moving to it would revert
  both #9148 and #9678.
- SkiaSharp.NativeAssets.Linux: 4.151.0 is available, but Avalonia.Skia 12.1.1
  resolves the managed SkiaSharp to 3.119.4. Pairing 4.x native assets with a
  3.x managed binding risks native entry point failures on Linux; 3.119.4 is
  the latest release on the 3.x branch.

* chore(deps): bump ReactiveUI to 24.1.0 and ReactiveUI.SourceGenerators to 3.2.0

- ReactiveUI                  24.0.0 -> 24.1.0
- ReactiveUI.WPF              24.0.0 -> 24.1.0
- ReactiveUI.SourceGenerators 3.1.0  -> 3.2.0

ReactiveUI and ReactiveUI.WPF move together: ReactiveUI.WPF 24.1.0 requires
ReactiveUI >= 24.1.0. ReactiveUI.Avalonia 12.1.0 declares a ReactiveUI >= 24.0.0
minimum, so it resolves against 24.1.0 without changes; ReactiveUI.Primitives
(7.1.0) and Splat (20.2.0) are unaffected.

* chore(deps): bump Avalonia.Controls.DataGrid and ReactiveUI.Avalonia

- Avalonia.Controls.DataGrid 12.1.0 -> 12.1.2
- ReactiveUI.Avalonia        12.1.0 -> 12.1.1

ReactiveUI.Avalonia 12.1.1 requires Avalonia >= 12.1.1 and ReactiveUI >= 24.1.0,
both of which are already in place after the previous commits on this branch.
2026-08-06 08:24:21 +08:00
136 changed files with 3755 additions and 1901 deletions
+1 -1
View File
@@ -6,6 +6,6 @@ updates:
interval: "daily" interval: "daily"
- package-ecosystem: "nuget" - package-ecosystem: "nuget"
directory: "/" directory: "/v2rayN"
schedule: schedule:
interval: "daily" interval: "daily"
+2 -2
View File
@@ -52,7 +52,7 @@ jobs:
dotnet --list-sdks 2>$null; $LASTEXITCODE=0 dotnet --list-sdks 2>$null; $LASTEXITCODE=0
- name: Setup .NET 10.0.1xx - name: Setup .NET 10.0.1xx
uses: actions/setup-dotnet@v6.0.0 uses: actions/setup-dotnet@v6
with: with:
dotnet-version: 10.0.1xx dotnet-version: 10.0.1xx
@@ -146,7 +146,7 @@ jobs:
$xrayTag = "v$xrayVer" $xrayTag = "v$xrayVer"
$singTag = "v$singVer" $singTag = "v$singVer"
$xrayUrl = "https://github.com/XTLS/Xray-core/releases/download/$xrayTag/Xray-windows-32.zip" $xrayUrl = "https://github.com/autorepobot/Xray/releases/download/$xrayTag/Xray-windows-32.zip"
$singUrl = "https://github.com/SagerNet/sing-box/releases/download/$singTag/sing-box-$singVer-windows-386.zip" $singUrl = "https://github.com/SagerNet/sing-box/releases/download/$singTag/sing-box-$singVer-windows-386.zip"
Write-Host "Bundled Xray version: $xrayVer" Write-Host "Bundled Xray version: $xrayVer"
+3 -3
View File
@@ -69,9 +69,9 @@ jobs:
dotnet --list-sdks 2>$null; $LASTEXITCODE=0 dotnet --list-sdks 2>$null; $LASTEXITCODE=0
- name: Setup .NET - name: Setup .NET
uses: actions/setup-dotnet@v6.0.0 uses: actions/setup-dotnet@v6
with: with:
dotnet-version: '10.0.1xx' dotnet-version: 10.0.1xx
- name: Build v2rayN - name: Build v2rayN
shell: bash shell: bash
@@ -89,7 +89,7 @@ jobs:
find ${{ matrix.arch }} -type f -name '*.pdb' -delete find ${{ matrix.arch }} -type f -name '*.pdb' -delete
- name: Upload build artifacts - name: Upload build artifacts
uses: actions/upload-artifact@v7.0.1 uses: actions/upload-artifact@v7
with: with:
name: ${{ matrix.arch }} name: ${{ matrix.arch }}
path: ${{ matrix.arch }} path: ${{ matrix.arch }}
+32 -9
View File
@@ -1,29 +1,52 @@
name: Code Test name: Code Test
on: 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: pull_request:
branches: branches:
- master - master
paths: paths:
- 'v2rayN/ServiceLib/Services/CoreConfig/**' - 'v2rayN/ServiceLib/**'
- 'v2rayN/ServiceLib/Handler/Fmt/**' - 'v2rayN/ServiceLib.UdpTest/**'
- 'v2rayN/ServiceLib.Tests/**'
- 'v2rayN/Directory.Build.*'
- 'v2rayN/Directory.Packages.props'
- 'global.json'
- '.github/workflows/test.yml' - '.github/workflows/test.yml'
permissions:
checks: write
pull-requests: write
jobs: jobs:
test: test:
runs-on: ubuntu-latest runs-on: ubuntu-latest
steps: steps:
- name: Checkout - name: Checkout
uses: actions/checkout@v7 uses: actions/checkout@v7
with:
submodules: 'recursive'
fetch-depth: '0'
- name: Setup .NET - name: Setup .NET
uses: actions/setup-dotnet@v6.0.0 uses: actions/setup-dotnet@v6
with: with:
dotnet-version: '8.0.x' dotnet-version: '10.x'
dotnet-quality: 'ga'
- name: Test Code - name: Test Code
working-directory: ./v2rayN run: dotnet test --project ./v2rayN/ServiceLib.Tests -c Release --results-directory ./TestResults -- --report-trx
run: dotnet test ./ServiceLib.Tests
- name: Comment PR with results
if: always()
uses: EnricoMi/publish-unit-test-result-action@v2
with:
files: ./TestResults/*.trx
comment_mode: failures
+2
View File
@@ -50,6 +50,8 @@ Read the Wiki for usage guides and configuration details.
| Linux | ✅ | - | ✅ | ✅ | ✅ | | Linux | ✅ | - | ✅ | ✅ | ✅ |
| macOS | ✅ | - | ✅ | - | - | | macOS | ✅ | - | ✅ | - | - |
Minimum OS requirements: [Release files introduction](https://github.com/2dust/v2rayN/wiki/Release-files-introduction) / 最低系统要求:[发布文件介绍](https://github.com/2dust/v2rayN/wiki/Release-files-introduction)
--- ---
## GPG Verification / GPG 签名校验 ## GPG Verification / GPG 签名校验
+5
View File
@@ -0,0 +1,5 @@
{
"test": {
"runner": "Microsoft.Testing.Platform"
}
}
+2 -2
View File
@@ -13,8 +13,8 @@ PKGROOT="v2rayN-publish"
PROJECT_HINT="v2rayN.Desktop/v2rayN.Desktop.csproj" PROJECT_HINT="v2rayN.Desktop/v2rayN.Desktop.csproj"
OUTPUT_DIR="${HOME}/debbuild" OUTPUT_DIR="${HOME}/debbuild"
DOTNET_TFM="net10.0" DOTNET_TFM="net10.0"
DOTNET_LOONGARCH_VERSION="10.0.110" DOTNET_LOONGARCH_VERSION="10.0.111"
DOTNET_LOONGARCH_TAG="v10.0.110-loongarch64" DOTNET_LOONGARCH_TAG="v10.0.111-loongarch64"
DOTNET_LOONGARCH_BASE="https://github.com/loongson/dotnet/releases/download" DOTNET_LOONGARCH_BASE="https://github.com/loongson/dotnet/releases/download"
DOTNET_LOONGARCH_FILE="dotnet-sdk-${DOTNET_LOONGARCH_VERSION}-linux-loongarch64.tar.gz" DOTNET_LOONGARCH_FILE="dotnet-sdk-${DOTNET_LOONGARCH_VERSION}-linux-loongarch64.tar.gz"
DOTNET_SDK_URL="${DOTNET_LOONGARCH_BASE}/${DOTNET_LOONGARCH_TAG}/${DOTNET_LOONGARCH_FILE}" DOTNET_SDK_URL="${DOTNET_LOONGARCH_BASE}/${DOTNET_LOONGARCH_TAG}/${DOTNET_LOONGARCH_FILE}"
+1 -1
View File
@@ -12,7 +12,7 @@ MIN_KERNEL="5.10"
PKGROOT="v2rayN-publish" PKGROOT="v2rayN-publish"
PROJECT_HINT="v2rayN.Desktop/v2rayN.Desktop.csproj" PROJECT_HINT="v2rayN.Desktop/v2rayN.Desktop.csproj"
OUTPUT_DIR="${HOME}/debbuild" OUTPUT_DIR="${HOME}/debbuild"
DOTNET_RISCV_VERSION="10.0.110" DOTNET_RISCV_VERSION="10.0.111"
DOTNET_RISCV_BASE="https://github.com/xujiegb/dotnet-riscv/releases/download" DOTNET_RISCV_BASE="https://github.com/xujiegb/dotnet-riscv/releases/download"
DOTNET_RISCV_FILE="dotnet-sdk-${DOTNET_RISCV_VERSION}-linux-riscv64.tar.gz" DOTNET_RISCV_FILE="dotnet-sdk-${DOTNET_RISCV_VERSION}-linux-riscv64.tar.gz"
DOTNET_SDK_URL="${DOTNET_RISCV_BASE}/${DOTNET_RISCV_VERSION}/${DOTNET_RISCV_FILE}" DOTNET_SDK_URL="${DOTNET_RISCV_BASE}/${DOTNET_RISCV_VERSION}/${DOTNET_RISCV_FILE}"
+1 -1
View File
@@ -54,7 +54,7 @@ cat >"$PackagePath/v2rayN.app/Contents/Info.plist" <<-EOF
<key>NSHighResolutionCapable</key> <key>NSHighResolutionCapable</key>
<true/> <true/>
<key>LSMinimumSystemVersion</key> <key>LSMinimumSystemVersion</key>
<string>13.7</string> <string>13.6</string>
</dict> </dict>
</plist> </plist>
EOF EOF
+2 -2
View File
@@ -12,8 +12,8 @@ MIN_KERNEL="6.12"
PKGROOT="v2rayN-publish" PKGROOT="v2rayN-publish"
PROJECT_HINT="v2rayN.Desktop/v2rayN.Desktop.csproj" PROJECT_HINT="v2rayN.Desktop/v2rayN.Desktop.csproj"
RPM_TOPDIR="${HOME}/rpmbuild" RPM_TOPDIR="${HOME}/rpmbuild"
DOTNET_LOONGARCH_VERSION="10.0.110" DOTNET_LOONGARCH_VERSION="10.0.111"
DOTNET_LOONGARCH_TAG="v10.0.110-loongarch64" DOTNET_LOONGARCH_TAG="v10.0.111-loongarch64"
DOTNET_LOONGARCH_BASE="https://github.com/loongson/dotnet/releases/download" DOTNET_LOONGARCH_BASE="https://github.com/loongson/dotnet/releases/download"
DOTNET_LOONGARCH_FILE="dotnet-sdk-${DOTNET_LOONGARCH_VERSION}-linux-loongarch64.tar.gz" DOTNET_LOONGARCH_FILE="dotnet-sdk-${DOTNET_LOONGARCH_VERSION}-linux-loongarch64.tar.gz"
DOTNET_SDK_URL="${DOTNET_LOONGARCH_BASE}/${DOTNET_LOONGARCH_TAG}/${DOTNET_LOONGARCH_FILE}" DOTNET_SDK_URL="${DOTNET_LOONGARCH_BASE}/${DOTNET_LOONGARCH_TAG}/${DOTNET_LOONGARCH_FILE}"
+1 -1
View File
@@ -12,7 +12,7 @@ MIN_KERNEL="5.10"
PKGROOT="v2rayN-publish" PKGROOT="v2rayN-publish"
PROJECT_HINT="v2rayN.Desktop/v2rayN.Desktop.csproj" PROJECT_HINT="v2rayN.Desktop/v2rayN.Desktop.csproj"
RPM_TOPDIR="${HOME}/rpmbuild" RPM_TOPDIR="${HOME}/rpmbuild"
DOTNET_RISCV_VERSION="10.0.110" DOTNET_RISCV_VERSION="10.0.111"
DOTNET_RISCV_BASE="https://github.com/xujiegb/dotnet-riscv/releases/download" DOTNET_RISCV_BASE="https://github.com/xujiegb/dotnet-riscv/releases/download"
DOTNET_RISCV_FILE="dotnet-sdk-${DOTNET_RISCV_VERSION}-linux-riscv64.tar.gz" DOTNET_RISCV_FILE="dotnet-sdk-${DOTNET_RISCV_VERSION}-linux-riscv64.tar.gz"
DOTNET_SDK_URL="${DOTNET_RISCV_BASE}/${DOTNET_RISCV_VERSION}/${DOTNET_RISCV_FILE}" DOTNET_SDK_URL="${DOTNET_RISCV_BASE}/${DOTNET_RISCV_VERSION}/${DOTNET_RISCV_FILE}"
+1 -1
View File
@@ -1,7 +1,7 @@
<Project> <Project>
<PropertyGroup> <PropertyGroup>
<Version>7.24.5</Version> <Version>7.25.0</Version>
</PropertyGroup> </PropertyGroup>
<PropertyGroup> <PropertyGroup>
+14 -16
View File
@@ -6,32 +6,30 @@
</PropertyGroup> </PropertyGroup>
<ItemGroup> <ItemGroup>
<PackageVersion Include="Avalonia.AvaloniaEdit" Version="12.0.0" /> <PackageVersion Include="Avalonia.AvaloniaEdit" Version="12.0.0" />
<PackageVersion Include="Avalonia.Controls.DataGrid" Version="12.1.0" /> <PackageVersion Include="Avalonia.Controls.DataGrid" Version="12.1.2" />
<PackageVersion Include="Avalonia.Desktop" Version="12.1.0" /> <PackageVersion Include="Avalonia.Desktop" Version="12.1.2" />
<PackageVersion Include="AvaloniaUI.DiagnosticsSupport" Version="2.2.3" /> <PackageVersion Include="AvaloniaUI.DiagnosticsSupport" Version="2.2.3" />
<PackageVersion Include="AwesomeAssertions" Version="9.5.0" />
<PackageVersion Include="DialogHost.Avalonia" Version="0.12.3" /> <PackageVersion Include="DialogHost.Avalonia" Version="0.12.3" />
<PackageVersion Include="IPNetwork2" Version="4.3.0" /> <PackageVersion Include="IPNetwork2" Version="4.3.0" />
<PackageVersion Include="ReactiveUI.Avalonia" Version="12.1.0" /> <PackageVersion Include="ReactiveUI.Avalonia" Version="12.1.1" />
<PackageVersion Include="CliWrap" Version="3.10.2" /> <PackageVersion Include="CliWrap" Version="3.10.5" />
<PackageVersion Include="Downloader" Version="5.9.5" /> <PackageVersion Include="Downloader" Version="5.9.6" />
<PackageVersion Include="Microsoft.NET.Test.Sdk" Version="18.8.1" />
<PackageVersion Include="H.NotifyIcon.Wpf" Version="2.4.1" /> <PackageVersion Include="H.NotifyIcon.Wpf" Version="2.4.1" />
<PackageVersion Include="MaterialDesignThemes" Version="5.3.2" /> <PackageVersion Include="MaterialDesignThemes" Version="5.3.2" />
<PackageVersion Include="QRCoder" Version="1.8.0" /> <PackageVersion Include="QRCoder" Version="1.8.0" />
<PackageVersion Include="ReactiveUI" Version="24.0.0" /> <PackageVersion Include="ReactiveUI" Version="24.2.0" />
<PackageVersion Include="ReactiveUI.SourceGenerators" Version="3.1.0" /> <PackageVersion Include="ReactiveUI.SourceGenerators" Version="3.2.0" />
<PackageVersion Include="ReactiveUI.WPF" Version="24.0.0" /> <PackageVersion Include="ReactiveUI.WPF" Version="24.2.0" />
<PackageVersion Include="Semi.Avalonia" Version="12.1.0" /> <PackageVersion Include="Semi.Avalonia" Version="12.1.0.1" />
<PackageVersion Include="Semi.Avalonia.AvaloniaEdit" Version="12.0.0" /> <PackageVersion Include="Semi.Avalonia.AvaloniaEdit" Version="12.0.0" />
<PackageVersion Include="Semi.Avalonia.DataGrid" Version="12.1.0" /> <PackageVersion Include="Semi.Avalonia.DataGrid" Version="12.1.0.1" />
<PackageVersion Include="NLog" Version="6.1.4" /> <PackageVersion Include="NLog" Version="6.2.0" />
<PackageVersion Include="sqlite-net-e" Version="1.11.285" /> <PackageVersion Include="sqlite-net-e" Version="1.11.285" />
<PackageVersion Include="Repobot.SQLite.Unofficial" Version="3.53.3.10" /> <PackageVersion Include="Repobot.SQLite.Unofficial" Version="3.53.4.1" />
<PackageVersion Include="TaskScheduler" Version="2.12.2" /> <PackageVersion Include="TaskScheduler" Version="2.12.2" />
<PackageVersion Include="TUnit" Version="1.65.68" />
<PackageVersion Include="TUnit.Assertions.Should" Version="1.65.38-beta" />
<PackageVersion Include="WebDav.Client" Version="2.9.0" /> <PackageVersion Include="WebDav.Client" Version="2.9.0" />
<PackageVersion Include="xunit.runner.visualstudio" Version="3.1.5" />
<PackageVersion Include="xunit.v3" Version="3.2.2" />
<PackageVersion Include="YamlDotNet" Version="18.1.0" /> <PackageVersion Include="YamlDotNet" Version="18.1.0" />
<PackageVersion Include="ZXing.Net.Bindings.SkiaSharp" Version="0.16.22" /> <PackageVersion Include="ZXing.Net.Bindings.SkiaSharp" Version="0.16.22" />
<PackageVersion Include="SkiaSharp.NativeAssets.Linux" Version="3.119.4" /> <PackageVersion Include="SkiaSharp.NativeAssets.Linux" Version="3.119.4" />
@@ -1,15 +1,8 @@
using AwesomeAssertions;
using ServiceLib.Enums;
using ServiceLib.Handler.Builder;
using ServiceLib.Helper;
using ServiceLib.Models;
using Xunit;
namespace ServiceLib.Tests.CoreConfig.Context; namespace ServiceLib.Tests.CoreConfig.Context;
public class CoreConfigContextBuilderTests public class CoreConfigContextBuilderTests
{ {
[Fact] [Test]
public async Task ResolveNodeAsync_DirectCycleDependency_ShouldFailWithCycleError() public async Task ResolveNodeAsync_DirectCycleDependency_ShouldFailWithCycleError()
{ {
var config = CoreConfigTestFactory.CreateConfig(); var config = CoreConfigTestFactory.CreateConfig();
@@ -27,13 +20,13 @@ public class CoreConfigContextBuilderTests
var (_, validatorResult) = await CoreConfigContextBuilder.ResolveNodeAsync(context, groupA, false); var (_, validatorResult) = await CoreConfigContextBuilder.ResolveNodeAsync(context, groupA, false);
validatorResult.Success.Should().BeFalse(); await validatorResult.Success.Should().BeFalse();
validatorResult.Errors.Should().Contain(msg => ContainsCycleDependencyMessage(msg)); await validatorResult.Errors.Should().Contain(ContainsCycleDependencyMessage);
context.AllProxiesMap.Should().NotContainKey(groupA.IndexId); await context.AllProxiesMap.Should().NotContainKey(groupA.IndexId);
context.AllProxiesMap.Should().NotContainKey(groupB.IndexId); await context.AllProxiesMap.Should().NotContainKey(groupB.IndexId);
} }
[Fact] [Test]
public async Task ResolveNodeAsync_IndirectCycleDependency_ShouldFailWithCycleError() public async Task ResolveNodeAsync_IndirectCycleDependency_ShouldFailWithCycleError()
{ {
var config = CoreConfigTestFactory.CreateConfig(); var config = CoreConfigTestFactory.CreateConfig();
@@ -53,14 +46,14 @@ public class CoreConfigContextBuilderTests
var (_, validatorResult) = await CoreConfigContextBuilder.ResolveNodeAsync(context, groupA, false); var (_, validatorResult) = await CoreConfigContextBuilder.ResolveNodeAsync(context, groupA, false);
validatorResult.Success.Should().BeFalse(); await validatorResult.Success.Should().BeFalse();
validatorResult.Errors.Should().Contain(msg => ContainsCycleDependencyMessage(msg)); await validatorResult.Errors.Should().Contain(ContainsCycleDependencyMessage);
context.AllProxiesMap.Should().NotContainKey(groupA.IndexId); await context.AllProxiesMap.Should().NotContainKey(groupA.IndexId);
context.AllProxiesMap.Should().NotContainKey(groupB.IndexId); await context.AllProxiesMap.Should().NotContainKey(groupB.IndexId);
context.AllProxiesMap.Should().NotContainKey(groupC.IndexId); await context.AllProxiesMap.Should().NotContainKey(groupC.IndexId);
} }
[Fact] [Test]
public async Task ResolveNodeAsync_CycleWithValidBranch_ShouldSkipCycleAndKeepValidChild() public async Task ResolveNodeAsync_CycleWithValidBranch_ShouldSkipCycleAndKeepValidChild()
{ {
var config = CoreConfigTestFactory.CreateConfig(); var config = CoreConfigTestFactory.CreateConfig();
@@ -80,14 +73,14 @@ public class CoreConfigContextBuilderTests
var (_, validatorResult) = await CoreConfigContextBuilder.ResolveNodeAsync(context, groupA, false); var (_, validatorResult) = await CoreConfigContextBuilder.ResolveNodeAsync(context, groupA, false);
validatorResult.Success.Should().BeTrue(); await validatorResult.Success.Should().BeTrue();
validatorResult.Errors.Should().BeEmpty(); await validatorResult.Errors.Should().BeEmpty();
validatorResult.Warnings.Should().Contain(msg => ContainsCycleDependencyMessage(msg)); await validatorResult.Warnings.Should().Contain(ContainsCycleDependencyMessage);
context.AllProxiesMap.Should().ContainKey(leaf.IndexId); await context.AllProxiesMap.Should().ContainKey(leaf.IndexId);
context.AllProxiesMap.Should().ContainKey(groupA.IndexId); await context.AllProxiesMap.Should().ContainKey(groupA.IndexId);
context.AllProxiesMap.Should().NotContainKey(groupB.IndexId); await context.AllProxiesMap.Should().NotContainKey(groupB.IndexId);
groupA.GetProtocolExtra().ChildItems.Should().Be(leaf.IndexId); await groupA.GetProtocolExtra().ChildItems.Should().BeEqualTo(leaf.IndexId);
} }
private static string NewId(string prefix) private static string NewId(string prefix)
@@ -205,7 +205,8 @@ internal static class CoreConfigTestFactory
return node; 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 return new CoreConfigContext
{ {
@@ -226,6 +227,7 @@ internal static class CoreConfigTestFactory
FullConfigTemplate = null, FullConfigTemplate = null,
IsTunEnabled = config.TunModeItem.EnableTun, IsTunEnabled = config.TunModeItem.EnableTun,
ProtectDomainList = [], ProtectDomainList = [],
HasGlobalIPv6Address = hasGlobalIPv6Address,
}; };
} }
@@ -251,4 +253,12 @@ internal static class CoreConfigTestFactory
config.TunModeItem.RouteExcludeAddress = ["10.0.0.1/32", "192.168.1.0/24", "fc00::/7"]; config.TunModeItem.RouteExcludeAddress = ["10.0.0.1/32", "192.168.1.0/24", "fc00::/7"];
return config; return config;
} }
public static Config CreateConfigWithTun(ECoreType coreType, bool enableIPv6Address)
{
var config = CreateConfig(coreType);
config.TunModeItem.EnableTun = true;
config.TunModeItem.EnableIPv6Address = enableIPv6Address;
return config;
}
} }
@@ -1,19 +1,9 @@
using AwesomeAssertions;
using ServiceLib.Common;
using ServiceLib.Enums;
using ServiceLib.Handler.Fmt;
using ServiceLib.Manager;
using ServiceLib.Models;
using ServiceLib.Models.Dto;
using ServiceLib.Services.CoreConfig;
using Xunit;
namespace ServiceLib.Tests.CoreConfig.Singbox; namespace ServiceLib.Tests.CoreConfig.Singbox;
public class CoreConfigSingboxServiceTests public class CoreConfigSingboxServiceTests
{ {
[Fact] [Test]
public void GenerateClientConfigContent_ShouldGenerateBasicProxyConfig() public async Task GenerateClientConfigContent_ShouldGenerateBasicProxyConfig()
{ {
var config = CoreConfigTestFactory.CreateConfig(ECoreType.sing_box); var config = CoreConfigTestFactory.CreateConfig(ECoreType.sing_box);
CoreConfigTestFactory.BindAppManagerConfig(config); CoreConfigTestFactory.BindAppManagerConfig(config);
@@ -22,17 +12,17 @@ public class CoreConfigSingboxServiceTests
var result = new CoreConfigSingboxService(context).GenerateClientConfigContent(); var result = new CoreConfigSingboxService(context).GenerateClientConfigContent();
result.Success.Should().BeTrue($"ret msg: {result.Msg}"); await result.Success.Should().BeTrue().Because($"ret msg: {result.Msg}");
result.Data.Should().NotBeNull(); await result.Data.Should().NotBeNull();
var singboxConfig = JsonUtils.Deserialize<SingboxConfig>(result.Data!.ToString()); var singboxConfig = JsonUtils.Deserialize<SingboxConfig>(result.Data!.ToString());
singboxConfig.Should().NotBeNull(); await singboxConfig.Should().NotBeNull();
singboxConfig!.outbounds.Should().Contain(o => o.tag == Global.ProxyTag && o.type == "socks"); await singboxConfig!.outbounds.Should().Contain(o => o.tag == Global.ProxyTag && o.type == "socks");
singboxConfig.inbounds.Should().Contain(i => i.type == nameof(EInboundProtocol.mixed)); await singboxConfig.inbounds.Should().Contain(i => i.type == nameof(EInboundProtocol.mixed));
} }
[Fact] [Test]
public void GenerateClientConfigContent_TunWithLoopbackPreSocks_ShouldKeepMixedInbound() public async Task GenerateClientConfigContent_TunWithLoopbackPreSocks_ShouldKeepMixedInbound()
{ {
var config = CoreConfigTestFactory.CreateConfig(ECoreType.sing_box); var config = CoreConfigTestFactory.CreateConfig(ECoreType.sing_box);
CoreConfigTestFactory.BindAppManagerConfig(config); CoreConfigTestFactory.BindAppManagerConfig(config);
@@ -46,18 +36,100 @@ public class CoreConfigSingboxServiceTests
var result = new CoreConfigSingboxService(context).GenerateClientConfigContent(); var result = new CoreConfigSingboxService(context).GenerateClientConfigContent();
result.Success.Should().BeTrue($"ret msg: {result.Msg}"); await result.Success.Should().BeTrue().Because($"ret msg: {result.Msg}");
var cfg = JsonUtils.Deserialize<SingboxConfig>(result.Data!.ToString())!; var cfg = JsonUtils.Deserialize<SingboxConfig>(result.Data!.ToString())!;
cfg.inbounds.Should().Contain(i => await cfg.inbounds.Should().Contain(i =>
i.type == nameof(EInboundProtocol.mixed) i.type == nameof(EInboundProtocol.mixed)
&& i.listen == Global.Loopback && i.listen == Global.Loopback
&& i.listen_port == AppManager.Instance.GetLocalPort(EInboundProtocol.socks)); && i.listen_port == AppManager.Instance.GetLocalPort(EInboundProtocol.socks));
cfg.inbounds.Should().Contain(i => i.type == "tun"); await cfg.inbounds.Should().Contain(i => i.type == "tun");
} }
[Fact] [Test]
public void GenerateClientConfigContent_BindInterface_ShouldUseDialBindInterface() public async Task GenerateClientConfigContent_TunEnabled_ShouldKeepEmbeddedTunRules()
{
// The embedded tun rules reject local-network noise (NetBIOS/mDNS, multicast).
// They are deserialized into List<Rule4Sbox>, so a schema mismatch in the
// embedded template makes JsonUtils.Deserialize return null and silently
// drops every one of them.
var config = CoreConfigTestFactory.CreateConfig(ECoreType.sing_box);
config.TunModeItem.EnableTun = true;
CoreConfigTestFactory.BindAppManagerConfig(config);
var node = CoreConfigTestFactory.CreateVmessNode(ECoreType.sing_box);
var context = CoreConfigTestFactory.CreateContext(config, node, ECoreType.sing_box) with
{
IsTunEnabled = true,
};
var result = new CoreConfigSingboxService(context).GenerateClientConfigContent();
await result.Success.Should().BeTrue().Because($"ret msg: {result.Msg}");
var cfg = JsonUtils.Deserialize<SingboxConfig>(result.Data!.ToString())!;
await cfg.route.rules.Should().Contain(
r => r.action == "reject"
&& r.network != null && r.network.Contains("udp")
&& r.port != null && r.port.Contains(5353),
"the embedded tun rules must reject mDNS/NetBIOS noise");
await cfg.route.rules.Should().Contain(
r => r.action == "reject"
&& r.ip_cidr != null && r.ip_cidr.Contains("224.0.0.0/3"),
"the embedded tun rules must reject multicast traffic");
}
[Test]
public async Task GenerateClientConfigContent_TunEnabled_ShouldRejectTrafficToTunOwnAddresses()
{
// Regression test: traffic addressed to the TUN interface's own addresses must
// never reach an outbound. auto_route hijacks the default route, so `direct`
// writes such a packet straight back into the TUN, which routes it to the
// outbound again - an infinite loop that pins a CPU core. Observed in the wild
// with WebRTC ICE connectivity checks against the TUN's own fc00::/7 ULA
// address, sustaining ~8k packets/s out of the TUN interface.
var config = CoreConfigTestFactory.CreateConfig(ECoreType.sing_box);
config.TunModeItem.EnableTun = true;
config.TunModeItem.EnableIPv6Address = true;
CoreConfigTestFactory.BindAppManagerConfig(config);
var node = CoreConfigTestFactory.CreateVmessNode(ECoreType.sing_box);
var context = CoreConfigTestFactory.CreateContext(config, node, ECoreType.sing_box) with
{
IsTunEnabled = true,
};
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 tun = cfg.inbounds.First(i => i.type == "tun");
//tun.address.Should().NotBeNullOrEmpty();
await tun.address.Should().NotBeNull();
await tun.address.Should().NotBeEmpty();
foreach (var address in tun.address!)
{
var self = IPAddress.Parse(address.Split('/').First());
var hostBits = self.AddressFamily == AddressFamily.InterNetworkV6 ? 128 : 32;
var expected = $"{self}/{hostBits}";
await cfg.route.rules.Should().Contain(
r => r.action == "reject" && r.ip_cidr != null && r.ip_cidr.Contains(expected),
$"traffic to the TUN's own address '{address}' must be rejected, not routed");
}
// The match has to stay on the addresses themselves. sing-tun derives the TUN's DNS
// entry from the address right after the interface's own, and every prefix offered
// here leaves room for it, so a prefix match would drop system name lookups too.
var dropRule = cfg.route.rules.First(r =>
r.action == "reject" && r.method == "drop" && r.ip_cidr?.Count > 0);
//dropRule.ip_cidr!.Should().OnlyContain(c =>
// c.EndsWith("/32", StringComparison.Ordinal) || c.EndsWith("/128", StringComparison.Ordinal));
await dropRule.ip_cidr.Should().All(c => c.EndsWith("/32", StringComparison.Ordinal) || c.EndsWith("/128", StringComparison.Ordinal));
}
[Test]
public async Task GenerateClientConfigContent_BindInterface_ShouldUseDialBindInterface()
{ {
var config = CoreConfigTestFactory.CreateConfig(ECoreType.sing_box); var config = CoreConfigTestFactory.CreateConfig(ECoreType.sing_box);
config.CoreBasicItem.BindInterface = "eth0"; config.CoreBasicItem.BindInterface = "eth0";
@@ -71,16 +143,16 @@ public class CoreConfigSingboxServiceTests
var result = new CoreConfigSingboxService(context).GenerateClientConfigContent(); var result = new CoreConfigSingboxService(context).GenerateClientConfigContent();
result.Success.Should().BeTrue($"ret msg: {result.Msg}"); await result.Success.Should().BeTrue().Because($"ret msg: {result.Msg}");
var cfg = JsonUtils.Deserialize<SingboxConfig>(result.Data!.ToString())!; var cfg = JsonUtils.Deserialize<SingboxConfig>(result.Data!.ToString())!;
var proxy = cfg.outbounds.First(o => o.tag == Global.ProxyTag); var proxy = cfg.outbounds.First(o => o.tag == Global.ProxyTag);
proxy.bind_interface.Should().Be("eth0"); await proxy.bind_interface.Should().BeEqualTo("eth0");
proxy.detour.Should().BeNullOrEmpty(); await proxy.detour.Should().BeNull().Or.BeEmpty();
} }
[Fact] [Test]
public void GenerateClientConfigContent_PolicyGroup_ShouldExpandChildrenAndBuildSelector() public async Task GenerateClientConfigContent_PolicyGroup_ShouldExpandChildrenAndBuildSelector()
{ {
var config = CoreConfigTestFactory.CreateConfig(ECoreType.sing_box); var config = CoreConfigTestFactory.CreateConfig(ECoreType.sing_box);
CoreConfigTestFactory.BindAppManagerConfig(config); CoreConfigTestFactory.BindAppManagerConfig(config);
@@ -97,17 +169,17 @@ public class CoreConfigSingboxServiceTests
var result = new CoreConfigSingboxService(context).GenerateClientConfigContent(); var result = new CoreConfigSingboxService(context).GenerateClientConfigContent();
result.Success.Should().BeTrue($"ret msg: {result.Msg}"); await result.Success.Should().BeTrue().Because($"ret msg: {result.Msg}");
var cfg = JsonUtils.Deserialize<SingboxConfig>(result.Data!.ToString())!; var cfg = JsonUtils.Deserialize<SingboxConfig>(result.Data!.ToString())!;
cfg.outbounds.Should().Contain(o => o.tag == Global.ProxyTag && o.type == "selector"); await cfg.outbounds.Should().Contain(o => o.tag == Global.ProxyTag && o.type == "selector");
cfg.outbounds.Should().Contain(o => o.tag == $"{Global.ProxyTag}-auto" && o.type == "urltest"); await cfg.outbounds.Should().Contain(o => o.tag == $"{Global.ProxyTag}-auto" && o.type == "urltest");
cfg.outbounds.Should().Contain(o => o.tag.StartsWith("proxy-1-", StringComparison.Ordinal)); await cfg.outbounds.Should().Contain(o => o.tag.StartsWith("proxy-1-", StringComparison.Ordinal));
cfg.outbounds.Should().Contain(o => o.tag.StartsWith("proxy-2-", StringComparison.Ordinal)); await cfg.outbounds.Should().Contain(o => o.tag.StartsWith("proxy-2-", StringComparison.Ordinal));
} }
[Fact] [Test]
public void GenerateClientConfigContent_ProxyChain_ShouldBuildDetourChain() public async Task GenerateClientConfigContent_ProxyChain_ShouldBuildDetourChain()
{ {
var config = CoreConfigTestFactory.CreateConfig(ECoreType.sing_box); var config = CoreConfigTestFactory.CreateConfig(ECoreType.sing_box);
CoreConfigTestFactory.BindAppManagerConfig(config); CoreConfigTestFactory.BindAppManagerConfig(config);
@@ -124,18 +196,18 @@ public class CoreConfigSingboxServiceTests
var result = new CoreConfigSingboxService(context).GenerateClientConfigContent(); var result = new CoreConfigSingboxService(context).GenerateClientConfigContent();
result.Success.Should().BeTrue($"ret msg: {result.Msg}"); await result.Success.Should().BeTrue().Because($"ret msg: {result.Msg}");
var cfg = JsonUtils.Deserialize<SingboxConfig>(result.Data!.ToString())!; var cfg = JsonUtils.Deserialize<SingboxConfig>(result.Data!.ToString())!;
cfg.outbounds.Should().Contain(o => o.tag == Global.ProxyTag && o.type == "socks"); await cfg.outbounds.Should().Contain(o => o.tag == Global.ProxyTag && o.type == "socks");
cfg.outbounds.Should().Contain(o => o.tag.StartsWith("chain-proxy-1-", StringComparison.Ordinal)); await cfg.outbounds.Should().Contain(o => o.tag.StartsWith("chain-proxy-1-", StringComparison.Ordinal));
cfg.outbounds.Should().Contain(o => await cfg.outbounds.Should().Contain(o =>
o.tag == Global.ProxyTag && o.tag == Global.ProxyTag &&
(o.detour ?? string.Empty).StartsWith("chain-proxy-1-", StringComparison.Ordinal)); (o.detour ?? string.Empty).StartsWith("chain-proxy-1-", StringComparison.Ordinal));
} }
[Fact] [Test]
public void GenerateClientConfigContent_PolicyGroupWithProxyChain_ShouldBuildCombinedOutbounds() public async Task GenerateClientConfigContent_PolicyGroupWithProxyChain_ShouldBuildCombinedOutbounds()
{ {
var config = CoreConfigTestFactory.CreateConfig(ECoreType.sing_box); var config = CoreConfigTestFactory.CreateConfig(ECoreType.sing_box);
CoreConfigTestFactory.BindAppManagerConfig(config); CoreConfigTestFactory.BindAppManagerConfig(config);
@@ -157,18 +229,18 @@ public class CoreConfigSingboxServiceTests
var result = new CoreConfigSingboxService(context).GenerateClientConfigContent(); var result = new CoreConfigSingboxService(context).GenerateClientConfigContent();
result.Success.Should().BeTrue($"ret msg: {result.Msg}"); await result.Success.Should().BeTrue().Because($"ret msg: {result.Msg}");
var cfg = JsonUtils.Deserialize<SingboxConfig>(result.Data!.ToString())!; var cfg = JsonUtils.Deserialize<SingboxConfig>(result.Data!.ToString())!;
cfg.outbounds.Should().Contain(o => o.tag == Global.ProxyTag && o.type == "selector"); await cfg.outbounds.Should().Contain(o => o.tag == Global.ProxyTag && o.type == "selector");
cfg.outbounds.Should().Contain(o => o.tag == $"{Global.ProxyTag}-auto" && o.type == "urltest"); await cfg.outbounds.Should().Contain(o => o.tag == $"{Global.ProxyTag}-auto" && o.type == "urltest");
cfg.outbounds.Should().Contain(o => o.tag.StartsWith("proxy-1-", StringComparison.Ordinal)); await cfg.outbounds.Should().Contain(o => o.tag.StartsWith("proxy-1-", StringComparison.Ordinal));
cfg.outbounds.Should().Contain(o => o.tag.StartsWith("chain-proxy-1-", StringComparison.Ordinal)); await cfg.outbounds.Should().Contain(o => o.tag.StartsWith("chain-proxy-1-", StringComparison.Ordinal));
cfg.outbounds.Should().Contain(o => o.tag.StartsWith("proxy-2-", StringComparison.Ordinal)); await cfg.outbounds.Should().Contain(o => o.tag.StartsWith("proxy-2-", StringComparison.Ordinal));
} }
[Fact] [Test]
public void GenerateClientConfigContent_ProxyChainWithPolicyGroup_ShouldBuildClonedChainBranches() public async Task GenerateClientConfigContent_ProxyChainWithPolicyGroup_ShouldBuildClonedChainBranches()
{ {
var config = CoreConfigTestFactory.CreateConfig(ECoreType.sing_box); var config = CoreConfigTestFactory.CreateConfig(ECoreType.sing_box);
CoreConfigTestFactory.BindAppManagerConfig(config); CoreConfigTestFactory.BindAppManagerConfig(config);
@@ -190,25 +262,25 @@ public class CoreConfigSingboxServiceTests
var result = new CoreConfigSingboxService(context).GenerateClientConfigContent(); var result = new CoreConfigSingboxService(context).GenerateClientConfigContent();
result.Success.Should().BeTrue($"ret msg: {result.Msg}"); await result.Success.Should().BeTrue().Because($"ret msg: {result.Msg}");
var cfg = JsonUtils.Deserialize<SingboxConfig>(result.Data!.ToString())!; var cfg = JsonUtils.Deserialize<SingboxConfig>(result.Data!.ToString())!;
cfg.outbounds.Should().Contain(o => o.tag == Global.ProxyTag && o.type == "selector"); await cfg.outbounds.Should().Contain(o => o.tag == Global.ProxyTag && o.type == "selector");
cfg.outbounds.Should().Contain(o => o.tag == $"{Global.ProxyTag}-auto" && o.type == "urltest"); await cfg.outbounds.Should().Contain(o => o.tag == $"{Global.ProxyTag}-auto" && o.type == "urltest");
cfg.outbounds.Should().Contain(o => o.tag.StartsWith("chain-proxy-1-group-1-", StringComparison.Ordinal)); await cfg.outbounds.Should().Contain(o => o.tag.StartsWith("chain-proxy-1-group-1-", StringComparison.Ordinal));
cfg.outbounds.Should().Contain(o => o.tag.StartsWith("chain-proxy-1-group-2-", StringComparison.Ordinal)); await cfg.outbounds.Should().Contain(o => o.tag.StartsWith("chain-proxy-1-group-2-", StringComparison.Ordinal));
var proxyCloneCount = cfg.outbounds.Count(o => o.tag.StartsWith("proxy-clone-", StringComparison.Ordinal)); var proxyCloneCount = cfg.outbounds.Count(o => o.tag.StartsWith("proxy-clone-", StringComparison.Ordinal));
proxyCloneCount.Should().Be(2); await proxyCloneCount.Should().BeEqualTo(2);
var allCloneDetoursPointToGroupBranches = cfg.outbounds var allCloneDetoursPointToGroupBranches = cfg.outbounds
.Where(o => o.tag.StartsWith("proxy-clone-", StringComparison.Ordinal)) .Where(o => o.tag.StartsWith("proxy-clone-", StringComparison.Ordinal))
.All(o => (o.detour ?? string.Empty).StartsWith("chain-proxy-1-group-", StringComparison.Ordinal)); .All(o => (o.detour ?? string.Empty).StartsWith("chain-proxy-1-group-", StringComparison.Ordinal));
allCloneDetoursPointToGroupBranches.Should().BeTrue(); await allCloneDetoursPointToGroupBranches.Should().BeTrue();
} }
[Fact] [Test]
public void GenerateClientConfigContent_RoutingSplit_DirectAndBlock_ShouldApplyRules() public async Task GenerateClientConfigContent_RoutingSplit_DirectAndBlock_ShouldApplyRules()
{ {
var config = CoreConfigTestFactory.CreateConfig(ECoreType.sing_box); var config = CoreConfigTestFactory.CreateConfig(ECoreType.sing_box);
CoreConfigTestFactory.BindAppManagerConfig(config); CoreConfigTestFactory.BindAppManagerConfig(config);
@@ -244,24 +316,24 @@ public class CoreConfigSingboxServiceTests
var result = new CoreConfigSingboxService(context).GenerateClientConfigContent(); var result = new CoreConfigSingboxService(context).GenerateClientConfigContent();
result.Success.Should().BeTrue($"ret msg: {result.Msg}"); await result.Success.Should().BeTrue().Because($"ret msg: {result.Msg}");
var cfg = JsonUtils.Deserialize<SingboxConfig>(result.Data!.ToString())!; var cfg = JsonUtils.Deserialize<SingboxConfig>(result.Data!.ToString())!;
var hasDirectRule = cfg.route.rules.Any(r => var hasDirectRule = cfg.route.rules.Any(r =>
r.domain != null r.domain != null
&& r.domain.Contains("direct.example.com") && r.domain.Contains("direct.example.com")
&& r.outbound == Global.DirectTag); && r.outbound == Global.DirectTag);
hasDirectRule.Should().BeTrue(); await hasDirectRule.Should().BeTrue();
var hasBlockRule = cfg.route.rules.Any(r => var hasBlockRule = cfg.route.rules.Any(r =>
r.domain != null r.domain != null
&& r.domain.Contains("block.example.com") && r.domain.Contains("block.example.com")
&& r.action == "reject"); && r.action == "reject");
hasBlockRule.Should().BeTrue(); await hasBlockRule.Should().BeTrue();
} }
[Fact] [Test]
public void GenerateClientConfigContent_RoutingSplit_ByRemark_ShouldGenerateTargetOutbound() public async Task GenerateClientConfigContent_RoutingSplit_ByRemark_ShouldGenerateTargetOutbound()
{ {
var config = CoreConfigTestFactory.CreateConfig(ECoreType.sing_box); var config = CoreConfigTestFactory.CreateConfig(ECoreType.sing_box);
CoreConfigTestFactory.BindAppManagerConfig(config); CoreConfigTestFactory.BindAppManagerConfig(config);
@@ -293,65 +365,21 @@ public class CoreConfigSingboxServiceTests
var result = new CoreConfigSingboxService(context).GenerateClientConfigContent(); var result = new CoreConfigSingboxService(context).GenerateClientConfigContent();
result.Success.Should().BeTrue($"ret msg: {result.Msg}"); await result.Success.Should().BeTrue().Because($"ret msg: {result.Msg}");
var cfg = JsonUtils.Deserialize<SingboxConfig>(result.Data!.ToString())!; var cfg = JsonUtils.Deserialize<SingboxConfig>(result.Data!.ToString())!;
var expectedPrefix = $"{routeNode.IndexId}-{Global.ProxyTag}-{routeNode.Remarks}"; var expectedPrefix = $"{routeNode.IndexId}-{Global.ProxyTag}-{routeNode.Remarks}";
cfg.outbounds.Should().Contain(o => o.tag.StartsWith(expectedPrefix, StringComparison.Ordinal)); await cfg.outbounds.Should().Contain(o => o.tag.StartsWith(expectedPrefix, StringComparison.Ordinal));
var hasRouteRule = cfg.route.rules.Any(r => var hasRouteRule = cfg.route.rules.Any(r =>
r.domain != null r.domain != null
&& r.domain.Contains("route.example.com") && r.domain.Contains("route.example.com")
&& (r.outbound ?? string.Empty).StartsWith(expectedPrefix, StringComparison.Ordinal)); && (r.outbound ?? string.Empty).StartsWith(expectedPrefix, StringComparison.Ordinal));
hasRouteRule.Should().BeTrue(); await hasRouteRule.Should().BeTrue();
} }
[Fact] [Test]
public void GenerateClientConfigContent_DirectExpectedIPs_ShouldApplyGeoipAndCidrToDirectDnsRule() public async Task GenerateClientConfigContent_BootstrapDNS_ShouldConfigurePureIPResolver()
{
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();
result.Success.Should().BeTrue($"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;
hasExpectedRule.Should().BeTrue();
}
[Fact]
public void GenerateClientConfigContent_BootstrapDNS_ShouldConfigurePureIPResolver()
{ {
var bootstrapDns = "8.8.8.8"; var bootstrapDns = "8.8.8.8";
var config = CoreConfigTestFactory.CreateConfigWithBootstrapDNS(ECoreType.sing_box, bootstrapDns); var config = CoreConfigTestFactory.CreateConfigWithBootstrapDNS(ECoreType.sing_box, bootstrapDns);
@@ -362,17 +390,17 @@ public class CoreConfigSingboxServiceTests
var result = new CoreConfigSingboxService(context).GenerateClientConfigContent(); var result = new CoreConfigSingboxService(context).GenerateClientConfigContent();
result.Success.Should().BeTrue($"ret msg: {result.Msg}"); await result.Success.Should().BeTrue().Because($"ret msg: {result.Msg}");
config.SimpleDNSItem.BootstrapDNS.Should().Be(bootstrapDns); await config.SimpleDNSItem.BootstrapDNS.Should().BeEqualTo(bootstrapDns);
var cfg = JsonUtils.Deserialize<SingboxConfig>(result.Data!.ToString())!; var cfg = JsonUtils.Deserialize<SingboxConfig>(result.Data!.ToString())!;
var bootstrapServer = cfg.dns.servers?.FirstOrDefault(s => s.tag == Global.SingboxLocalDNSTag); var bootstrapServer = cfg.dns?.servers.FirstOrDefault(s => s.tag == Global.SingboxLocalDNSTag);
bootstrapServer.Should().NotBeNull(); await bootstrapServer.Should().NotBeNull();
(bootstrapServer?.server ?? string.Empty).Should().Contain(bootstrapDns); await bootstrapServer!.server.Should().Contain(bootstrapDns);
} }
[Fact] [Test]
public void GenerateClientConfigContent_DnsFallback_LastRuleDirect_ShouldUseDirectFinalDns() public async Task GenerateClientConfigContent_DnsFallback_LastRuleDirect_ShouldUseDirectFinalDns()
{ {
var config = CoreConfigTestFactory.CreateConfig(ECoreType.sing_box); var config = CoreConfigTestFactory.CreateConfig(ECoreType.sing_box);
config.SimpleDNSItem.DirectDNS = "1.1.1.1"; config.SimpleDNSItem.DirectDNS = "1.1.1.1";
@@ -405,14 +433,14 @@ public class CoreConfigSingboxServiceTests
var result = new CoreConfigSingboxService(context).GenerateClientConfigContent(); var result = new CoreConfigSingboxService(context).GenerateClientConfigContent();
result.Success.Should().BeTrue($"ret msg: {result.Msg}"); await result.Success.Should().BeTrue().Because($"ret msg: {result.Msg}");
var cfg = JsonUtils.Deserialize<SingboxConfig>(result.Data!.ToString())!; var cfg = JsonUtils.Deserialize<SingboxConfig>(result.Data!.ToString())!;
cfg.dns.final.Should().Be(Global.SingboxDirectDNSTag); await cfg.dns.final.Should().BeEqualTo(Global.SingboxDirectDNSTag);
} }
[Fact] [Test]
public void GenerateClientConfigContent_DirectExpectedIPs_NonMatchingRegion_ShouldNotApplyExpectedRule() public async Task GenerateClientConfigContent_DirectExpectedIPs_NonMatchingRegion_ShouldNotApplyExpectedRule()
{ {
var config = var config =
CoreConfigTestFactory.CreateConfigWithDirectExpectedIPs(ECoreType.sing_box, "192.168.0.0/16,geoip:cn"); CoreConfigTestFactory.CreateConfigWithDirectExpectedIPs(ECoreType.sing_box, "192.168.0.0/16,geoip:cn");
@@ -442,21 +470,21 @@ public class CoreConfigSingboxServiceTests
var result = new CoreConfigSingboxService(context).GenerateClientConfigContent(); var result = new CoreConfigSingboxService(context).GenerateClientConfigContent();
result.Success.Should().BeTrue($"ret msg: {result.Msg}"); await result.Success.Should().BeTrue().Because($"ret msg: {result.Msg}");
var cfg = JsonUtils.Deserialize<SingboxConfig>(result.Data!.ToString())!; var cfg = JsonUtils.Deserialize<SingboxConfig>(result.Data!.ToString())!;
var hasExpectedRule = cfg.dns.rules?.Any(r => var hasExpectedRule = cfg.dns.rules?.Any(r =>
r.server == Global.SingboxDirectDNSTag r.server == Global.SingboxDirectDNSTag
&& r.ip_cidr?.Contains("192.168.0.0/16") == true && r.ip_cidr?.Contains("192.168.0.0/16") == true
&& r.rule_set?.Contains("geoip-cn") == true) ?? false; && r.rule_set?.Contains("geoip-cn") == true) ?? false;
hasExpectedRule.Should().BeFalse(); await hasExpectedRule.Should().BeFalse();
} }
[Theory] [Test]
[InlineData("geosite:cn", "geosite-cn")] [Arguments("geosite:cn", "geosite-cn")]
[InlineData("geosite:geolocation-cn", "geosite-geolocation-cn")] [Arguments("geosite:geolocation-cn", "geosite-geolocation-cn")]
[InlineData("geosite:tld-cn", "geosite-tld-cn")] [Arguments("geosite:tld-cn", "geosite-tld-cn")]
public void GenerateClientConfigContent_DirectExpectedIPs_RegionVariant_ShouldApplyExpectedRule(string domainTag, public async Task GenerateClientConfigContent_DirectExpectedIPs_RegionVariant_ShouldApplyExpectedRule(string domainTag,
string expectedRuleSetTag) string expectedRuleSetTag)
{ {
var config = var config =
@@ -472,31 +500,38 @@ public class CoreConfigSingboxServiceTests
Remarks = "dns-direct-variant", Remarks = "dns-direct-variant",
RuleSet = JsonUtils.Serialize(new List<RulesItem> RuleSet = JsonUtils.Serialize(new List<RulesItem>
{ {
new()
{
Enabled = true, RuleType = ERuleType.DNS, OutboundTag = Global.ProxyTag, Domain = ["geosite:google"],
},
new() new()
{ {
Enabled = true, RuleType = ERuleType.DNS, OutboundTag = Global.DirectTag, Domain = [domainTag], Enabled = true, RuleType = ERuleType.DNS, OutboundTag = Global.DirectTag, Domain = [domainTag],
} },
}), }),
DomainStrategy = Global.AsIs, DomainStrategy = Global.AsIs,
DomainStrategy4Singbox = string.Empty, DomainStrategy4Singbox = string.Empty,
} },
}; };
var result = new CoreConfigSingboxService(context).GenerateClientConfigContent(); var result = new CoreConfigSingboxService(context).GenerateClientConfigContent();
result.Success.Should().BeTrue($"ret msg: {result.Msg}"); await result.Success.Should().BeTrue().Because($"ret msg: {result.Msg}");
var cfg = JsonUtils.Deserialize<SingboxConfig>(result.Data!.ToString())!; var cfg = JsonUtils.Deserialize<SingboxConfig>(result.Data!.ToString())!;
var hasExpectedRule = cfg.dns.rules?.Any(r => var hasExpectedRule = (cfg.dns.rules?.Any(r =>
r.server == Global.SingboxDirectDNSTag r.ip_cidr?.Contains("192.168.0.0/16") == true
&& r.ip_cidr?.Contains("192.168.0.0/16") == true && r.action == "respond"
&& r.rule_set?.Contains(expectedRuleSetTag) == true && r.rule_set?.Contains("geoip-cn") == true) ?? false)
&& r.rule_set?.Contains("geoip-cn") == true) ?? false; && (cfg.dns.rules?.Any(r =>
hasExpectedRule.Should().BeTrue(); r.server == Global.SingboxDirectDNSTag
&& r.rule_set?.Contains(expectedRuleSetTag) == true
&& r.action == "evaluate") ?? false);
await hasExpectedRule.Should().BeTrue();
} }
[Fact] [Test]
public void GenerateClientConfigContent_Hosts_ShouldPopulateHostsServerAndDomainResolver() public async Task GenerateClientConfigContent_Hosts_ShouldPopulateHostsServerAndDomainResolver()
{ {
var config = CoreConfigTestFactory.CreateConfig(ECoreType.sing_box); var config = CoreConfigTestFactory.CreateConfig(ECoreType.sing_box);
config.SimpleDNSItem.Hosts = "resolver.example 1.1.1.1"; config.SimpleDNSItem.Hosts = "resolver.example 1.1.1.1";
@@ -508,21 +543,21 @@ public class CoreConfigSingboxServiceTests
var result = new CoreConfigSingboxService(context).GenerateClientConfigContent(); var result = new CoreConfigSingboxService(context).GenerateClientConfigContent();
result.Success.Should().BeTrue($"ret msg: {result.Msg}"); await result.Success.Should().BeTrue().Because($"ret msg: {result.Msg}");
var cfg = JsonUtils.Deserialize<SingboxConfig>(result.Data!.ToString())!; var cfg = JsonUtils.Deserialize<SingboxConfig>(result.Data!.ToString())!;
var hostsServer = cfg.dns.servers.FirstOrDefault(s => s.tag == Global.SingboxHostsDNSTag); var hostsServer = cfg.dns.servers.FirstOrDefault(s => s.tag == Global.SingboxHostsDNSTag);
hostsServer.Should().NotBeNull(); await hostsServer.Should().NotBeNull();
hostsServer!.predefined.Should().ContainKey("resolver.example"); await hostsServer!.predefined.Should().ContainKey("resolver.example");
hostsServer.predefined!["resolver.example"].Should().Contain("1.1.1.1"); await hostsServer.predefined!["resolver.example"].Should().Contain("1.1.1.1");
var directServer = cfg.dns.servers.FirstOrDefault(s => s.tag == Global.SingboxDirectDNSTag); var directServer = cfg.dns.servers.FirstOrDefault(s => s.tag == Global.SingboxDirectDNSTag);
directServer.Should().NotBeNull(); await directServer.Should().NotBeNull();
directServer!.domain_resolver.Should().Be(Global.SingboxHostsDNSTag); await directServer!.domain_resolver.Should().BeEqualTo(Global.SingboxHostsDNSTag);
} }
[Fact] [Test]
public void GenerateClientConfigContent_RawDnsEnabled_ShouldUseCustomDnsAndInjectLocalResolver() public async Task GenerateClientConfigContent_RawDnsEnabled_ShouldUseCustomDnsAndInjectLocalResolver()
{ {
var config = CoreConfigTestFactory.CreateConfig(ECoreType.sing_box); var config = CoreConfigTestFactory.CreateConfig(ECoreType.sing_box);
CoreConfigTestFactory.BindAppManagerConfig(config); CoreConfigTestFactory.BindAppManagerConfig(config);
@@ -551,22 +586,76 @@ public class CoreConfigSingboxServiceTests
var result = new CoreConfigSingboxService(context).GenerateClientConfigContent(); var result = new CoreConfigSingboxService(context).GenerateClientConfigContent();
result.Success.Should().BeTrue($"ret msg: {result.Msg}"); await result.Success.Should().BeTrue().Because($"ret msg: {result.Msg}");
var cfg = JsonUtils.Deserialize<SingboxConfig>(result.Data!.ToString())!; var cfg = JsonUtils.Deserialize<SingboxConfig>(result.Data!.ToString())!;
cfg.dns.servers.Should().Contain(s => s.tag == "remote" && s.type == "udp" && s.server == "8.8.8.8"); await cfg.dns.servers.Should().Contain(s => s.tag == "remote" && s.type == "udp" && s.server == "8.8.8.8");
cfg.dns.servers.Should().Contain(s => s.tag == Global.SingboxLocalDNSTag); await cfg.dns.servers.Should().Contain(s => s.tag == Global.SingboxLocalDNSTag);
cfg.dns.rules.Should().Contain(r => r.clash_mode == nameof(ERuleMode.Global)); await cfg.dns.rules.Should().Contain(r => r.clash_mode == nameof(ERuleMode.Global));
cfg.dns.rules.Should().Contain(r => r.clash_mode == nameof(ERuleMode.Direct)); await cfg.dns.rules.Should().Contain(r => r.clash_mode == nameof(ERuleMode.Direct));
} }
[Fact] [Test]
public void GenerateClientConfigContent_Hysteria2Realm_ShouldEmitHttpsServerUrl() [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()
{ {
var shareLink = var shareLink =
"hysteria2+realm://public@realm.hy2.io/my-realm-id?auth=uuid&stun=turn.cloudflare.com%3A3478&sni=cloudflare.com&pinSHA256=xxx#Realm-Test"; "hysteria2+realm://public@realm.hy2.io/my-realm-id?auth=uuid&stun=turn.cloudflare.com%3A3478&sni=cloudflare.com&pinSHA256=xxx#Realm-Test";
var node = Hysteria2Fmt.ResolveRealm(shareLink, out _); var node = Hysteria2Fmt.ResolveRealm(shareLink, out _);
node.Should().NotBeNull(); await node.Should().NotBeNull();
node!.CoreType = ECoreType.sing_box; node!.CoreType = ECoreType.sing_box;
var config = CoreConfigTestFactory.CreateConfig(ECoreType.sing_box); var config = CoreConfigTestFactory.CreateConfig(ECoreType.sing_box);
@@ -579,22 +668,22 @@ public class CoreConfigSingboxServiceTests
var result = new CoreConfigSingboxService(context).GenerateClientConfigContent(); var result = new CoreConfigSingboxService(context).GenerateClientConfigContent();
result.Success.Should().BeTrue($"ret msg: {result.Msg}"); await result.Success.Should().BeTrue().Because($"ret msg: {result.Msg}");
var cfg = JsonUtils.Deserialize<SingboxConfig>(result.Data!.ToString())!; var cfg = JsonUtils.Deserialize<SingboxConfig>(result.Data!.ToString())!;
var proxy = cfg.outbounds.First(o => o.tag == Global.ProxyTag); var proxy = cfg.outbounds.First(o => o.tag == Global.ProxyTag);
proxy.type.Should().Be("hysteria2"); await proxy.type.Should().BeEqualTo("hysteria2");
proxy.realm.Should().NotBeNull(); await proxy.realm.Should().NotBeNull();
proxy.realm!.server_url.Should().StartWith("https://"); await proxy.realm!.server_url.Should().StartWith("https://");
proxy.realm.server_url.Should().Contain("realm.hy2.io"); await proxy.realm.server_url.Should().Contain("realm.hy2.io");
proxy.realm.token.Should().Be("public"); await proxy.realm.token.Should().BeEqualTo("public");
proxy.realm.realm_id.Should().Be("my-realm-id"); await proxy.realm.realm_id.Should().BeEqualTo("my-realm-id");
proxy.realm.stun_servers.Should().Contain("turn.cloudflare.com:3478"); await proxy.realm.stun_servers.Should().Contain("turn.cloudflare.com:3478");
proxy.server.Should().BeNull(); await proxy.server.Should().BeNull();
} }
[Fact] [Test]
public void GenerateClientConfigContent_TunSystemStackWithIpv6_ShouldUsePrefixWithPeerAddress() public async Task GenerateClientConfigContent_TunSystemStackWithIpv6_ShouldUsePrefixWithPeerAddress()
{ {
// Regression test for #9820: sing-box fails with "need one more IPv6 address in // Regression test for #9820: sing-box fails with "need one more IPv6 address in
// first prefix for system stack" when the TUN inbound uses a /128 IPv6 prefix. // first prefix for system stack" when the TUN inbound uses a /128 IPv6 prefix.
@@ -612,23 +701,24 @@ public class CoreConfigSingboxServiceTests
var result = new CoreConfigSingboxService(context).GenerateClientConfigContent(); var result = new CoreConfigSingboxService(context).GenerateClientConfigContent();
result.Success.Should().BeTrue($"ret msg: {result.Msg}"); await result.Success.Should().BeTrue().Because($"ret msg: {result.Msg}");
var cfg = JsonUtils.Deserialize<SingboxConfig>(result.Data!.ToString())!; var cfg = JsonUtils.Deserialize<SingboxConfig>(result.Data!.ToString())!;
var tun = cfg.inbounds.First(i => i.type == "tun"); var tun = cfg.inbounds.First(i => i.type == "tun");
tun.address.Should().NotBeNullOrEmpty(); await tun.address.Should().NotBeNull();
await tun.address.Should().NotBeEmpty();
foreach (var address in tun.address!) foreach (var address in tun.address!)
{ {
var prefixLength = int.Parse(address[(address.LastIndexOf('/') + 1)..]); var prefixLength = int.Parse(address[(address.LastIndexOf('/') + 1)..]);
var isIpv6 = address.Contains(':'); var isIpv6 = address.Contains(':');
prefixLength.Should().BeLessThanOrEqualTo(isIpv6 ? 126 : 30, await prefixLength.Should().BeLessThanOrEqualTo(isIpv6 ? 126 : 30,
$"'{address}' must leave room for the peer address the system stack derives"); $"'{address}' must leave room for the peer address the system stack derives");
} }
} }
[Fact] [Test]
public void GenerateClientConfigContent_CustomOutbound_ShouldReplaceWithUserCustomOutboundJson() public async Task GenerateClientConfigContent_CustomOutbound_ShouldReplaceWithUserCustomOutboundJson()
{ {
var config = CoreConfigTestFactory.CreateConfig(ECoreType.sing_box); var config = CoreConfigTestFactory.CreateConfig(ECoreType.sing_box);
CoreConfigTestFactory.BindAppManagerConfig(config); CoreConfigTestFactory.BindAppManagerConfig(config);
@@ -649,17 +739,17 @@ public class CoreConfigSingboxServiceTests
var result = new CoreConfigSingboxService(context).GenerateClientConfigContent(); var result = new CoreConfigSingboxService(context).GenerateClientConfigContent();
result.Success.Should().BeTrue($"ret msg: {result.Msg}"); await result.Success.Should().BeTrue().Because($"ret msg: {result.Msg}");
result.Data.Should().NotBeNull(); await result.Data.Should().NotBeNull();
var cfg = JsonUtils.Deserialize<SingboxConfig>(result.Data!.ToString()); var cfg = JsonUtils.Deserialize<SingboxConfig>(result.Data!.ToString());
cfg.Should().NotBeNull(); await cfg.Should().NotBeNull();
var proxyOutbound = cfg!.outbounds.FirstOrDefault(o => o.tag == Global.ProxyTag); var proxyOutbound = cfg!.outbounds.FirstOrDefault(o => o.tag == Global.ProxyTag);
proxyOutbound.Should().NotBeNull(); await proxyOutbound.Should().NotBeNull();
proxyOutbound!.type.Should().Be("shadowsocks"); await proxyOutbound!.type.Should().BeEqualTo("shadowsocks");
proxyOutbound.server.Should().Be("1.2.3.4"); await proxyOutbound.server.Should().BeEqualTo("1.2.3.4");
proxyOutbound.server_port.Should().Be(8388); await proxyOutbound.server_port.Should().BeEqualTo(8388);
proxyOutbound.method.Should().Be("aes-128-gcm"); await proxyOutbound.method.Should().BeEqualTo("aes-128-gcm");
proxyOutbound.password.Should().Be("custom_password"); await proxyOutbound.password.Should().BeEqualTo("custom_password");
} }
} }
@@ -1,16 +1,9 @@
using AwesomeAssertions;
using ServiceLib.Common;
using ServiceLib.Enums;
using ServiceLib.Models;
using ServiceLib.Services.CoreConfig;
using Xunit;
namespace ServiceLib.Tests.CoreConfig.V2ray; namespace ServiceLib.Tests.CoreConfig.V2ray;
public class CoreConfigV2rayServiceTests public class CoreConfigV2rayServiceTests
{ {
[Fact] [Test]
public void GenerateClientConfigContent_ShouldGenerateBasicProxyConfig() public async Task GenerateClientConfigContent_ShouldGenerateBasicProxyConfig()
{ {
var config = CoreConfigTestFactory.CreateConfig(ECoreType.Xray); var config = CoreConfigTestFactory.CreateConfig(ECoreType.Xray);
CoreConfigTestFactory.BindAppManagerConfig(config); CoreConfigTestFactory.BindAppManagerConfig(config);
@@ -19,17 +12,17 @@ public class CoreConfigV2rayServiceTests
var result = new CoreConfigV2rayService(context).GenerateClientConfigContent(); var result = new CoreConfigV2rayService(context).GenerateClientConfigContent();
result.Success.Should().BeTrue(); await result.Success.Should().BeTrue();
result.Data.Should().NotBeNull(); await result.Data.Should().NotBeNull();
var v2rayConfig = JsonUtils.Deserialize<V2rayConfig>(result.Data!.ToString()); var v2rayConfig = JsonUtils.Deserialize<V2rayConfig>(result.Data!.ToString());
v2rayConfig.Should().NotBeNull(); await v2rayConfig.Should().NotBeNull();
v2rayConfig!.outbounds.Should().Contain(o => o.tag == Global.ProxyTag && o.protocol == "vmess"); await v2rayConfig!.outbounds.Should().Contain(o => o.tag == Global.ProxyTag && o.protocol == "vmess");
v2rayConfig.inbounds.Should().Contain(i => i.protocol == nameof(EInboundProtocol.mixed)); await v2rayConfig.inbounds.Should().Contain(i => i.protocol == nameof(EInboundProtocol.mixed));
} }
[Fact] [Test]
public void GenerateClientConfigContent_HttpOutbound_ShouldEmitHeadersInSettings() public async Task GenerateClientConfigContent_HttpOutbound_ShouldEmitHeadersInSettings()
{ {
var config = CoreConfigTestFactory.CreateConfig(ECoreType.Xray); var config = CoreConfigTestFactory.CreateConfig(ECoreType.Xray);
CoreConfigTestFactory.BindAppManagerConfig(config); CoreConfigTestFactory.BindAppManagerConfig(config);
@@ -42,27 +35,27 @@ public class CoreConfigV2rayServiceTests
var result = new CoreConfigV2rayService(context).GenerateClientConfigContent(); var result = new CoreConfigV2rayService(context).GenerateClientConfigContent();
result.Success.Should().BeTrue(); await result.Success.Should().BeTrue();
var cfg = JsonUtils.Deserialize<V2rayConfig>(result.Data!.ToString())!; var cfg = JsonUtils.Deserialize<V2rayConfig>(result.Data!.ToString())!;
var outbound = cfg.outbounds.First(o => o.tag == Global.ProxyTag && o.protocol == "http"); var outbound = cfg.outbounds.First(o => o.tag == Global.ProxyTag && o.protocol == "http");
outbound.settings.address?.ToString().Should().Be("proxy.example.com"); await outbound.settings.address!.ToString().Should().BeEqualTo("proxy.example.com");
outbound.settings.port.Should().Be(8080); await outbound.settings.port.Should().BeEqualTo(8080);
outbound.settings.user.Should().Be("user"); await outbound.settings.user.Should().BeEqualTo("user");
outbound.settings.pass.Should().Be("pass"); await outbound.settings.pass.Should().BeEqualTo("pass");
outbound.settings.level.Should().Be(1); await outbound.settings.level.Should().BeEqualTo(1);
outbound.settings.headers.Should().NotBeNull(); await outbound.settings.headers.Should().NotBeNull();
var headers = JsonUtils.ParseJson(outbound.settings.headers.ToString()); var headers = JsonUtils.ParseJson(outbound.settings.headers!.ToString());
headers["User-Agent"]!.GetValue<string>().Should().Be("v2rayN"); await headers["User-Agent"]!.GetValue<string>().Should().BeEqualTo("v2rayN");
headers["Set-Cookie"]!.AsArray() await headers["Set-Cookie"]!.AsArray()
.Select(item => item!.GetValue<string>()) .Select(item => item!.GetValue<string>())
.Should().Equal("a=1", "b=2"); .Should().BeEquivalentTo(["a=1", "b=2"]);
outbound.settings.servers.Should().BeNull(); await outbound.settings.servers.Should().BeNull();
outbound.settings.vnext.Should().BeNull(); await outbound.settings.vnext.Should().BeNull();
} }
[Fact] [Test]
public void GenerateClientConfigContent_PolicyGroup_ShouldExpandChildrenAndBuildBalancer() public async Task GenerateClientConfigContent_PolicyGroup_ShouldExpandChildrenAndBuildBalancer()
{ {
var config = CoreConfigTestFactory.CreateConfig(ECoreType.Xray); var config = CoreConfigTestFactory.CreateConfig(ECoreType.Xray);
CoreConfigTestFactory.BindAppManagerConfig(config); CoreConfigTestFactory.BindAppManagerConfig(config);
@@ -79,17 +72,17 @@ public class CoreConfigV2rayServiceTests
var result = new CoreConfigV2rayService(context).GenerateClientConfigContent(); var result = new CoreConfigV2rayService(context).GenerateClientConfigContent();
result.Success.Should().BeTrue(); await result.Success.Should().BeTrue();
var cfg = JsonUtils.Deserialize<V2rayConfig>(result.Data!.ToString())!; var cfg = JsonUtils.Deserialize<V2rayConfig>(result.Data!.ToString())!;
cfg.outbounds.Should().Contain(o => o.tag.StartsWith("proxy-1-", StringComparison.Ordinal)); await cfg.outbounds.Should().Contain(o => o.tag.StartsWith("proxy-1-", StringComparison.Ordinal));
cfg.outbounds.Should().Contain(o => o.tag.StartsWith("proxy-2-", StringComparison.Ordinal)); await cfg.outbounds.Should().Contain(o => o.tag.StartsWith("proxy-2-", StringComparison.Ordinal));
cfg.routing.balancers.Should().NotBeNull(); await cfg.routing.balancers.Should().NotBeNull();
cfg.routing.balancers!.Should().Contain(b => b.tag == Global.ProxyTag + Global.BalancerTagSuffix); await cfg.routing.balancers!.Should().Contain(b => b.tag == Global.ProxyTag + Global.BalancerTagSuffix);
} }
[Fact] [Test]
public void GenerateClientConfigContent_ProxyChain_ShouldBuildDialerProxyChain() public async Task GenerateClientConfigContent_ProxyChain_ShouldBuildDialerProxyChain()
{ {
var config = CoreConfigTestFactory.CreateConfig(ECoreType.Xray); var config = CoreConfigTestFactory.CreateConfig(ECoreType.Xray);
CoreConfigTestFactory.BindAppManagerConfig(config); CoreConfigTestFactory.BindAppManagerConfig(config);
@@ -105,21 +98,21 @@ public class CoreConfigV2rayServiceTests
var result = new CoreConfigV2rayService(context).GenerateClientConfigContent(); var result = new CoreConfigV2rayService(context).GenerateClientConfigContent();
result.Success.Should().BeTrue(); await result.Success.Should().BeTrue();
var cfg = JsonUtils.Deserialize<V2rayConfig>(result.Data!.ToString())!; var cfg = JsonUtils.Deserialize<V2rayConfig>(result.Data!.ToString())!;
cfg.outbounds.Should().Contain(o => o.tag.StartsWith("chain-proxy-1-", StringComparison.Ordinal)); await cfg.outbounds.Should().Contain(o => o.tag.StartsWith("chain-proxy-1-", StringComparison.Ordinal));
var hasDialerChain = cfg.outbounds.Any(o => var hasDialerChain = cfg.outbounds.Any(o =>
o.tag == Global.ProxyTag o.tag == Global.ProxyTag
&& o.streamSettings is not null && o.streamSettings is not null
&& o.streamSettings.sockopt is not null && o.streamSettings.sockopt is not null
&& (o.streamSettings.sockopt.dialerProxy ?? string.Empty).StartsWith("chain-proxy-1-", && (o.streamSettings.sockopt.dialerProxy ?? string.Empty).StartsWith("chain-proxy-1-",
StringComparison.Ordinal)); StringComparison.Ordinal));
hasDialerChain.Should().BeTrue(); await hasDialerChain.Should().BeTrue();
} }
[Fact] [Test]
public void GenerateClientConfigContent_PolicyGroupWithProxyChain_ShouldBuildCombinedOutbounds() public async Task GenerateClientConfigContent_PolicyGroupWithProxyChain_ShouldBuildCombinedOutbounds()
{ {
var config = CoreConfigTestFactory.CreateConfig(ECoreType.Xray); var config = CoreConfigTestFactory.CreateConfig(ECoreType.Xray);
CoreConfigTestFactory.BindAppManagerConfig(config); CoreConfigTestFactory.BindAppManagerConfig(config);
@@ -140,18 +133,18 @@ public class CoreConfigV2rayServiceTests
var result = new CoreConfigV2rayService(context).GenerateClientConfigContent(); var result = new CoreConfigV2rayService(context).GenerateClientConfigContent();
result.Success.Should().BeTrue(); await result.Success.Should().BeTrue();
var cfg = JsonUtils.Deserialize<V2rayConfig>(result.Data!.ToString())!; var cfg = JsonUtils.Deserialize<V2rayConfig>(result.Data!.ToString())!;
cfg.outbounds.Should().Contain(o => o.tag.StartsWith("proxy-1-", StringComparison.Ordinal)); await cfg.outbounds.Should().Contain(o => o.tag.StartsWith("proxy-1-", StringComparison.Ordinal));
cfg.outbounds.Should().Contain(o => o.tag.StartsWith("chain-proxy-1-", StringComparison.Ordinal)); await cfg.outbounds.Should().Contain(o => o.tag.StartsWith("chain-proxy-1-", StringComparison.Ordinal));
cfg.outbounds.Should().Contain(o => o.tag.StartsWith("proxy-2-", StringComparison.Ordinal)); await cfg.outbounds.Should().Contain(o => o.tag.StartsWith("proxy-2-", StringComparison.Ordinal));
cfg.routing.balancers.Should().NotBeNull(); await cfg.routing.balancers.Should().NotBeNull();
cfg.routing.balancers!.Should().Contain(b => b.tag == Global.ProxyTag + Global.BalancerTagSuffix); await cfg.routing.balancers!.Should().Contain(b => b.tag == Global.ProxyTag + Global.BalancerTagSuffix);
} }
[Fact] [Test]
public void GenerateClientConfigContent_ProxyChainWithPolicyGroup_ShouldBuildClonedChainBranches() public async Task GenerateClientConfigContent_ProxyChainWithPolicyGroup_ShouldBuildClonedChainBranches()
{ {
var config = CoreConfigTestFactory.CreateConfig(ECoreType.Xray); var config = CoreConfigTestFactory.CreateConfig(ECoreType.Xray);
CoreConfigTestFactory.BindAppManagerConfig(config); CoreConfigTestFactory.BindAppManagerConfig(config);
@@ -173,27 +166,27 @@ public class CoreConfigV2rayServiceTests
var result = new CoreConfigV2rayService(context).GenerateClientConfigContent(); var result = new CoreConfigV2rayService(context).GenerateClientConfigContent();
result.Success.Should().BeTrue(); await result.Success.Should().BeTrue();
var cfg = JsonUtils.Deserialize<V2rayConfig>(result.Data!.ToString())!; var cfg = JsonUtils.Deserialize<V2rayConfig>(result.Data!.ToString())!;
cfg.outbounds.Should().Contain(o => o.tag.StartsWith("chain-proxy-1-group-1-", StringComparison.Ordinal)); await cfg.outbounds.Should().Contain(o => o.tag.StartsWith("chain-proxy-1-group-1-", StringComparison.Ordinal));
cfg.outbounds.Should().Contain(o => o.tag.StartsWith("chain-proxy-1-group-2-", StringComparison.Ordinal)); await cfg.outbounds.Should().Contain(o => o.tag.StartsWith("chain-proxy-1-group-2-", StringComparison.Ordinal));
var proxyCloneCount = cfg.outbounds.Count(o => o.tag.StartsWith("proxy-clone-", StringComparison.Ordinal)); var proxyCloneCount = cfg.outbounds.Count(o => o.tag.StartsWith("proxy-clone-", StringComparison.Ordinal));
proxyCloneCount.Should().Be(2); await proxyCloneCount.Should().BeEqualTo(2);
var allCloneDialersPointToGroupBranches = cfg.outbounds var allCloneDialersPointToGroupBranches = cfg.outbounds
.Where(o => o.tag.StartsWith("proxy-clone-", StringComparison.Ordinal)) .Where(o => o.tag.StartsWith("proxy-clone-", StringComparison.Ordinal))
.All(o => (o.streamSettings?.sockopt?.dialerProxy ?? string.Empty).StartsWith("chain-proxy-1-group-", .All(o => (o.streamSettings?.sockopt?.dialerProxy ?? string.Empty).StartsWith("chain-proxy-1-group-",
StringComparison.Ordinal)); StringComparison.Ordinal));
allCloneDialersPointToGroupBranches.Should().BeTrue(); await allCloneDialersPointToGroupBranches.Should().BeTrue();
cfg.routing.balancers.Should().NotBeNull(); await cfg.routing.balancers.Should().NotBeNull();
cfg.routing.balancers!.Should().Contain(b => b.tag == Global.ProxyTag + Global.BalancerTagSuffix); await cfg.routing.balancers!.Should().Contain(b => b.tag == Global.ProxyTag + Global.BalancerTagSuffix);
} }
[Fact] [Test]
public void GenerateClientConfigContent_RoutingSplit_DirectAndBlock_ShouldApplyRules() public async Task GenerateClientConfigContent_RoutingSplit_DirectAndBlock_ShouldApplyRules()
{ {
var config = CoreConfigTestFactory.CreateConfig(ECoreType.Xray); var config = CoreConfigTestFactory.CreateConfig(ECoreType.Xray);
CoreConfigTestFactory.BindAppManagerConfig(config); CoreConfigTestFactory.BindAppManagerConfig(config);
@@ -229,24 +222,24 @@ public class CoreConfigV2rayServiceTests
var result = new CoreConfigV2rayService(context).GenerateClientConfigContent(); var result = new CoreConfigV2rayService(context).GenerateClientConfigContent();
result.Success.Should().BeTrue(); await result.Success.Should().BeTrue();
var cfg = JsonUtils.Deserialize<V2rayConfig>(result.Data!.ToString())!; var cfg = JsonUtils.Deserialize<V2rayConfig>(result.Data!.ToString())!;
var hasDirectRule = cfg.routing.rules.Any(r => var hasDirectRule = cfg.routing.rules.Any(r =>
r.domain != null r.domain != null
&& r.domain.Contains("full:direct.example.com") && r.domain.Contains("full:direct.example.com")
&& r.outboundTag == Global.DirectTag); && r.outboundTag == Global.DirectTag);
hasDirectRule.Should().BeTrue(); await hasDirectRule.Should().BeTrue();
var hasBlockRule = cfg.routing.rules.Any(r => var hasBlockRule = cfg.routing.rules.Any(r =>
r.domain != null r.domain != null
&& r.domain.Contains("full:block.example.com") && r.domain.Contains("full:block.example.com")
&& r.outboundTag == Global.BlockTag); && r.outboundTag == Global.BlockTag);
hasBlockRule.Should().BeTrue(); await hasBlockRule.Should().BeTrue();
} }
[Fact] [Test]
public void GenerateClientConfigContent_RoutingSplit_ByRemark_ShouldGenerateTargetOutbound() public async Task GenerateClientConfigContent_RoutingSplit_ByRemark_ShouldGenerateTargetOutbound()
{ {
var config = CoreConfigTestFactory.CreateConfig(ECoreType.Xray); var config = CoreConfigTestFactory.CreateConfig(ECoreType.Xray);
CoreConfigTestFactory.BindAppManagerConfig(config); CoreConfigTestFactory.BindAppManagerConfig(config);
@@ -278,20 +271,20 @@ public class CoreConfigV2rayServiceTests
var result = new CoreConfigV2rayService(context).GenerateClientConfigContent(); var result = new CoreConfigV2rayService(context).GenerateClientConfigContent();
result.Success.Should().BeTrue(); await result.Success.Should().BeTrue();
var cfg = JsonUtils.Deserialize<V2rayConfig>(result.Data!.ToString())!; var cfg = JsonUtils.Deserialize<V2rayConfig>(result.Data!.ToString())!;
var expectedPrefix = $"{routeNode.IndexId}-{Global.ProxyTag}-{routeNode.Remarks}"; var expectedPrefix = $"{routeNode.IndexId}-{Global.ProxyTag}-{routeNode.Remarks}";
cfg.outbounds.Should().Contain(o => o.tag.StartsWith(expectedPrefix, StringComparison.Ordinal)); await cfg.outbounds.Should().Contain(o => o.tag.StartsWith(expectedPrefix, StringComparison.Ordinal));
var hasRouteRule = cfg.routing.rules.Any(r => var hasRouteRule = cfg.routing.rules.Any(r =>
r.domain != null r.domain != null
&& r.domain.Contains("full:route.example.com") && r.domain.Contains("full:route.example.com")
&& (r.outboundTag ?? string.Empty).StartsWith(expectedPrefix, StringComparison.Ordinal)); && (r.outboundTag ?? string.Empty).StartsWith(expectedPrefix, StringComparison.Ordinal));
hasRouteRule.Should().BeTrue(); await hasRouteRule.Should().BeTrue();
} }
[Fact] [Test]
public void GenerateClientConfigContent_DirectExpectedIPs_ShouldApplyExpectedIPsToDirectDnsServer() public async Task GenerateClientConfigContent_DirectExpectedIPs_ShouldApplyExpectedIPsToDirectDnsServer()
{ {
var config = CoreConfigTestFactory.CreateConfigWithDirectExpectedIPs(ECoreType.Xray, "192.168.0.0/16,geoip:cn"); var config = CoreConfigTestFactory.CreateConfigWithDirectExpectedIPs(ECoreType.Xray, "192.168.0.0/16,geoip:cn");
CoreConfigTestFactory.BindAppManagerConfig(config); CoreConfigTestFactory.BindAppManagerConfig(config);
@@ -320,7 +313,7 @@ public class CoreConfigV2rayServiceTests
var result = new CoreConfigV2rayService(context).GenerateClientConfigContent(); var result = new CoreConfigV2rayService(context).GenerateClientConfigContent();
result.Success.Should().BeTrue(); await result.Success.Should().BeTrue();
var cfg = JsonUtils.Deserialize<V2rayConfig>(result.Data!.ToString())!; var cfg = JsonUtils.Deserialize<V2rayConfig>(result.Data!.ToString())!;
var dns = JsonUtils.Deserialize<Dns4Ray>(JsonUtils.Serialize(cfg.dns))!; var dns = JsonUtils.Deserialize<Dns4Ray>(JsonUtils.Serialize(cfg.dns))!;
@@ -335,11 +328,11 @@ public class CoreConfigV2rayServiceTests
&& s.domains?.Contains("geosite:cn") == true && s.domains?.Contains("geosite:cn") == true
&& s.expectedIPs?.Contains("192.168.0.0/16") == true && s.expectedIPs?.Contains("192.168.0.0/16") == true
&& s.expectedIPs?.Contains("geoip:cn") == true); && s.expectedIPs?.Contains("geoip:cn") == true);
hasExpectedServer.Should().BeTrue(); await hasExpectedServer.Should().BeTrue();
} }
[Fact] [Test]
public void GenerateClientConfigContent_BootstrapDNS_ShouldApplyToDnsServerDomains() public async Task GenerateClientConfigContent_BootstrapDNS_ShouldApplyToDnsServerDomains()
{ {
var bootstrapDns = "8.8.8.8"; var bootstrapDns = "8.8.8.8";
var config = CoreConfigTestFactory.CreateConfigWithBootstrapDNS(ECoreType.Xray, bootstrapDns); var config = CoreConfigTestFactory.CreateConfigWithBootstrapDNS(ECoreType.Xray, bootstrapDns);
@@ -352,7 +345,7 @@ public class CoreConfigV2rayServiceTests
var result = new CoreConfigV2rayService(context).GenerateClientConfigContent(); var result = new CoreConfigV2rayService(context).GenerateClientConfigContent();
result.Success.Should().BeTrue(); await result.Success.Should().BeTrue();
var cfg = JsonUtils.Deserialize<V2rayConfig>(result.Data!.ToString())!; var cfg = JsonUtils.Deserialize<V2rayConfig>(result.Data!.ToString())!;
var dns = JsonUtils.Deserialize<Dns4Ray>(JsonUtils.Serialize(cfg.dns))!; var dns = JsonUtils.Deserialize<Dns4Ray>(JsonUtils.Serialize(cfg.dns))!;
@@ -366,11 +359,11 @@ public class CoreConfigV2rayServiceTests
s.address == bootstrapDns s.address == bootstrapDns
&& s.domains?.Contains("full:dns-direct.example") == true && s.domains?.Contains("full:dns-direct.example") == true
&& s.domains?.Contains("full:dns-remote.example") == true); && s.domains?.Contains("full:dns-remote.example") == true);
hasBootstrapServer.Should().BeTrue(); await hasBootstrapServer.Should().BeTrue();
} }
[Fact] [Test]
public void GenerateClientConfigContent_DnsFallback_LastRuleDirect_ShouldUseDirectDnsServers() public async Task GenerateClientConfigContent_DnsFallback_LastRuleDirect_ShouldUseDirectDnsServers()
{ {
var config = CoreConfigTestFactory.CreateConfig(ECoreType.Xray); var config = CoreConfigTestFactory.CreateConfig(ECoreType.Xray);
config.SimpleDNSItem.DirectDNS = "1.1.1.1"; config.SimpleDNSItem.DirectDNS = "1.1.1.1";
@@ -403,7 +396,7 @@ public class CoreConfigV2rayServiceTests
var result = new CoreConfigV2rayService(context).GenerateClientConfigContent(); var result = new CoreConfigV2rayService(context).GenerateClientConfigContent();
result.Success.Should().BeTrue(); await result.Success.Should().BeTrue();
var cfg = JsonUtils.Deserialize<V2rayConfig>(result.Data!.ToString())!; var cfg = JsonUtils.Deserialize<V2rayConfig>(result.Data!.ToString())!;
var dns = JsonUtils.Deserialize<Dns4Ray>(JsonUtils.Serialize(cfg.dns))!; var dns = JsonUtils.Deserialize<Dns4Ray>(JsonUtils.Serialize(cfg.dns))!;
var dnsServers = dns.servers var dnsServers = dns.servers
@@ -415,14 +408,14 @@ public class CoreConfigV2rayServiceTests
var hasDirectFallback = dnsServers.Any(s => var hasDirectFallback = dnsServers.Any(s =>
(s.tag ?? string.Empty).StartsWith(Global.DirectDnsTag, StringComparison.Ordinal) (s.tag ?? string.Empty).StartsWith(Global.DirectDnsTag, StringComparison.Ordinal)
&& s.address == "1.1.1.1"); && s.address == "1.1.1.1");
hasDirectFallback.Should().BeTrue(); await hasDirectFallback.Should().BeTrue();
var hasRemoteFallback = dnsServers.Any(s => s.address == "9.9.9.9"); var hasRemoteFallback = dnsServers.Any(s => s.address == "9.9.9.9");
hasRemoteFallback.Should().BeFalse(); await hasRemoteFallback.Should().BeFalse();
} }
[Fact] [Test]
public void GenerateClientConfigContent_DirectExpectedIPs_NonMatchingRegion_ShouldNotApplyExpectedIPs() public async Task GenerateClientConfigContent_DirectExpectedIPs_NonMatchingRegion_ShouldNotApplyExpectedIPs()
{ {
var config = CoreConfigTestFactory.CreateConfigWithDirectExpectedIPs(ECoreType.Xray, "192.168.0.0/16,geoip:cn"); var config = CoreConfigTestFactory.CreateConfigWithDirectExpectedIPs(ECoreType.Xray, "192.168.0.0/16,geoip:cn");
CoreConfigTestFactory.BindAppManagerConfig(config); CoreConfigTestFactory.BindAppManagerConfig(config);
@@ -451,7 +444,7 @@ public class CoreConfigV2rayServiceTests
var result = new CoreConfigV2rayService(context).GenerateClientConfigContent(); var result = new CoreConfigV2rayService(context).GenerateClientConfigContent();
result.Success.Should().BeTrue(); await result.Success.Should().BeTrue();
var cfg = JsonUtils.Deserialize<V2rayConfig>(result.Data!.ToString())!; var cfg = JsonUtils.Deserialize<V2rayConfig>(result.Data!.ToString())!;
var dns = JsonUtils.Deserialize<Dns4Ray>(JsonUtils.Serialize(cfg.dns))!; var dns = JsonUtils.Deserialize<Dns4Ray>(JsonUtils.Serialize(cfg.dns))!;
var dnsServers = dns.servers var dnsServers = dns.servers
@@ -463,14 +456,14 @@ public class CoreConfigV2rayServiceTests
var hasExpectedIPs = dnsServers.Any(s => var hasExpectedIPs = dnsServers.Any(s =>
s.expectedIPs?.Contains("192.168.0.0/16") == true s.expectedIPs?.Contains("192.168.0.0/16") == true
|| s.expectedIPs?.Contains("geoip:cn") == true); || s.expectedIPs?.Contains("geoip:cn") == true);
hasExpectedIPs.Should().BeFalse(); await hasExpectedIPs.Should().BeFalse();
} }
[Theory] [Test]
[InlineData("geosite:cn")] [Arguments("geosite:cn")]
[InlineData("geosite:geolocation-cn")] [Arguments("geosite:geolocation-cn")]
[InlineData("geosite:tld-cn")] [Arguments("geosite:tld-cn")]
public void GenerateClientConfigContent_DirectExpectedIPs_RegionVariant_ShouldApplyExpectedIPs(string domainTag) public async Task GenerateClientConfigContent_DirectExpectedIPs_RegionVariant_ShouldApplyExpectedIPs(string domainTag)
{ {
var config = CoreConfigTestFactory.CreateConfigWithDirectExpectedIPs(ECoreType.Xray, "192.168.0.0/16,geoip:cn"); var config = CoreConfigTestFactory.CreateConfigWithDirectExpectedIPs(ECoreType.Xray, "192.168.0.0/16,geoip:cn");
CoreConfigTestFactory.BindAppManagerConfig(config); CoreConfigTestFactory.BindAppManagerConfig(config);
@@ -496,7 +489,7 @@ public class CoreConfigV2rayServiceTests
var result = new CoreConfigV2rayService(context).GenerateClientConfigContent(); var result = new CoreConfigV2rayService(context).GenerateClientConfigContent();
result.Success.Should().BeTrue(); await result.Success.Should().BeTrue();
var cfg = JsonUtils.Deserialize<V2rayConfig>(result.Data!.ToString())!; var cfg = JsonUtils.Deserialize<V2rayConfig>(result.Data!.ToString())!;
var dns = JsonUtils.Deserialize<Dns4Ray>(JsonUtils.Serialize(cfg.dns))!; var dns = JsonUtils.Deserialize<Dns4Ray>(JsonUtils.Serialize(cfg.dns))!;
var dnsServers = dns.servers var dnsServers = dns.servers
@@ -510,11 +503,11 @@ public class CoreConfigV2rayServiceTests
&& s.domains?.Contains(domainTag) == true && s.domains?.Contains(domainTag) == true
&& s.expectedIPs?.Contains("192.168.0.0/16") == true && s.expectedIPs?.Contains("192.168.0.0/16") == true
&& s.expectedIPs?.Contains("geoip:cn") == true); && s.expectedIPs?.Contains("geoip:cn") == true);
hasExpectedServer.Should().BeTrue(); await hasExpectedServer.Should().BeTrue();
} }
[Fact] [Test]
public void GenerateClientConfigContent_Hosts_ShouldPopulateDnsHosts() public async Task GenerateClientConfigContent_Hosts_ShouldPopulateDnsHosts()
{ {
var config = CoreConfigTestFactory.CreateConfig(ECoreType.Xray); var config = CoreConfigTestFactory.CreateConfig(ECoreType.Xray);
config.SimpleDNSItem.Hosts = "resolver.example 1.1.1.1"; config.SimpleDNSItem.Hosts = "resolver.example 1.1.1.1";
@@ -525,17 +518,17 @@ public class CoreConfigV2rayServiceTests
var result = new CoreConfigV2rayService(context).GenerateClientConfigContent(); var result = new CoreConfigV2rayService(context).GenerateClientConfigContent();
result.Success.Should().BeTrue(); await result.Success.Should().BeTrue();
var cfg = JsonUtils.Deserialize<V2rayConfig>(result.Data!.ToString())!; var cfg = JsonUtils.Deserialize<V2rayConfig>(result.Data!.ToString())!;
var dns = JsonUtils.Deserialize<Dns4Ray>(JsonUtils.Serialize(cfg.dns))!; var dns = JsonUtils.Deserialize<Dns4Ray>(JsonUtils.Serialize(cfg.dns))!;
dns.hosts.Should().NotBeNull(); await dns.hosts.Should().NotBeNull();
dns.hosts!.Should().ContainKey("resolver.example"); await dns.hosts!.Should().ContainKey("resolver.example");
JsonUtils.Serialize(dns.hosts!["resolver.example"]).Should().Contain("1.1.1.1"); await JsonUtils.Serialize(dns.hosts!["resolver.example"]).Should().Contain("1.1.1.1");
} }
[Fact] [Test]
public void GenerateClientConfigContent_RawDnsEnabled_ShouldUseCustomDnsConfig() public async Task GenerateClientConfigContent_RawDnsEnabled_ShouldUseCustomDnsConfig()
{ {
var config = CoreConfigTestFactory.CreateConfig(ECoreType.Xray); var config = CoreConfigTestFactory.CreateConfig(ECoreType.Xray);
CoreConfigTestFactory.BindAppManagerConfig(config); CoreConfigTestFactory.BindAppManagerConfig(config);
@@ -556,22 +549,114 @@ public class CoreConfigV2rayServiceTests
var result = new CoreConfigV2rayService(context).GenerateClientConfigContent(); var result = new CoreConfigV2rayService(context).GenerateClientConfigContent();
result.Success.Should().BeTrue(); await result.Success.Should().BeTrue();
var cfg = JsonUtils.Deserialize<V2rayConfig>(result.Data!.ToString())!; var cfg = JsonUtils.Deserialize<V2rayConfig>(result.Data!.ToString())!;
var dns = JsonUtils.Deserialize<Dns4Ray>(JsonUtils.Serialize(cfg.dns))!; var dns = JsonUtils.Deserialize<Dns4Ray>(JsonUtils.Serialize(cfg.dns))!;
JsonUtils.Serialize(dns.servers).Should().Contain("8.8.8.8"); await JsonUtils.Serialize(dns.servers).Should().Contain("8.8.8.8");
dns.hosts.Should().NotBeNull(); await dns.hosts.Should().NotBeNull();
dns.hosts!.Should().ContainKey("raw.example"); await dns.hosts!.Should().ContainKey("raw.example");
JsonUtils.Serialize(dns.hosts!["raw.example"]).Should().Contain("1.1.1.1"); await JsonUtils.Serialize(dns.hosts!["raw.example"]).Should().Contain("1.1.1.1");
var directOutbound = cfg.outbounds.FirstOrDefault(o => o.tag == Global.DirectTag && o.protocol == "freedom"); var directOutbound = cfg.outbounds.FirstOrDefault(o => o.tag == Global.DirectTag && o.protocol == "freedom");
directOutbound.Should().NotBeNull(); await directOutbound.Should().NotBeNull();
directOutbound!.streamSettings.sockopt!.domainStrategy.Should().Be("UseIPv4"); await directOutbound!.streamSettings.sockopt!.domainStrategy.Should().BeEqualTo("UseIPv4");
} }
[Fact] [Test]
public void GenerateClientConfigContent_TunRouteExcludeAddress() [Arguments(false)]
[Arguments(true)]
public async Task GenerateClientConfigContent_Tun_ShouldRouteIPv6IntoTunnel(bool enableIPv6Address)
{
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);
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");
await tunInbound.settings.autoSystemRoutingTable.Should().Contain("::/0");
// EnableIPv6Address governs the interface address only, never the routing table.
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()
{
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);
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(x => x.Contains(':'));
}
[Test]
public async Task GenerateClientConfigContent_TunRouteExcludeAddress()
{ {
var config = CoreConfigTestFactory.CreateConfigWithTunRouteExcludeAddress(ECoreType.Xray); var config = CoreConfigTestFactory.CreateConfigWithTunRouteExcludeAddress(ECoreType.Xray);
CoreConfigTestFactory.BindAppManagerConfig(config); CoreConfigTestFactory.BindAppManagerConfig(config);
@@ -581,20 +666,20 @@ public class CoreConfigV2rayServiceTests
var result = new CoreConfigV2rayService(context).GenerateClientConfigContent(); var result = new CoreConfigV2rayService(context).GenerateClientConfigContent();
result.Success.Should().BeTrue(); await result.Success.Should().BeTrue();
var cfg = JsonUtils.Deserialize<V2rayConfig>(result.Data!.ToString())!; var cfg = JsonUtils.Deserialize<V2rayConfig>(result.Data!.ToString())!;
var tunInbound = cfg.inbounds.FirstOrDefault(i => i.protocol == "tun"); var tunInbound = cfg.inbounds.FirstOrDefault(i => i.protocol == "tun");
tunInbound.Should().NotBeNull(); await tunInbound.Should().NotBeNull();
tunInbound!.settings.autoSystemRoutingTable.Should().NotContain("0.0.0.0/0"); await tunInbound!.settings.autoSystemRoutingTable.Should().NotContain("0.0.0.0/0");
tunInbound!.settings.autoSystemRoutingTable.Should().Contain("10.0.0.0/32"); await tunInbound!.settings.autoSystemRoutingTable.Should().Contain("10.0.0.0/32");
tunInbound!.settings.autoSystemRoutingTable.Should().Contain("10.0.0.2/31"); await tunInbound!.settings.autoSystemRoutingTable.Should().Contain("10.0.0.2/31");
} }
[Fact] [Test]
public void GenerateClientConfigContent_CustomOutbound_ShouldReplaceWithUserCustomOutboundJson() public async Task GenerateClientConfigContent_CustomOutbound_ShouldReplaceWithUserCustomOutboundJson()
{ {
var config = CoreConfigTestFactory.CreateConfig(ECoreType.Xray); var config = CoreConfigTestFactory.CreateConfig(ECoreType.Xray);
CoreConfigTestFactory.BindAppManagerConfig(config); CoreConfigTestFactory.BindAppManagerConfig(config);
@@ -621,14 +706,14 @@ public class CoreConfigV2rayServiceTests
var result = new CoreConfigV2rayService(context).GenerateClientConfigContent(); var result = new CoreConfigV2rayService(context).GenerateClientConfigContent();
result.Success.Should().BeTrue($"ret msg: {result.Msg}"); await result.Success.Should().BeTrue().Because($"ret msg: {result.Msg}");
result.Data.Should().NotBeNull(); await result.Data.Should().NotBeNull();
var cfg = JsonUtils.Deserialize<V2rayConfig>(result.Data!.ToString()); var cfg = JsonUtils.Deserialize<V2rayConfig>(result.Data!.ToString());
cfg.Should().NotBeNull(); await cfg.Should().NotBeNull();
var proxyOutbound = cfg!.outbounds.FirstOrDefault(o => o.tag == Global.ProxyTag); var proxyOutbound = cfg!.outbounds.FirstOrDefault(o => o.tag == Global.ProxyTag);
proxyOutbound.Should().NotBeNull(); await proxyOutbound.Should().NotBeNull();
proxyOutbound!.protocol.Should().Be("shadowsocks"); await proxyOutbound!.protocol.Should().BeEqualTo("shadowsocks");
proxyOutbound.settings.servers.Should().NotBeNull(); await proxyOutbound.settings.servers.Should().NotBeNull();
} }
} }
+443 -54
View File
@@ -1,103 +1,354 @@
using AwesomeAssertions;
using ServiceLib.Enums;
using ServiceLib.Handler.Fmt;
using ServiceLib.Models;
using Xunit;
namespace ServiceLib.Tests.Fmt; namespace ServiceLib.Tests.Fmt;
public class FmtHandlerTests public class FmtHandlerTests
{ {
[Fact] /// <summary>
public void GetShareUriAndResolveConfig_Vmess_ShouldRoundTripBasicFields() /// 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()
{ {
var source = CreateVmessProfile(); var source = CreateVmessProfile();
var resolved = ExportThenImport(source); var resolved = await ExportThenImport(source);
resolved.ConfigType.Should().Be(EConfigType.VMess); await resolved.ConfigType.Should().BeEqualTo(EConfigType.VMess);
resolved.Remarks.Should().Be(source.Remarks); await resolved.Remarks.Should().BeEqualTo(source.Remarks);
resolved.Address.Should().Be(source.Address); await resolved.Address.Should().BeEqualTo(source.Address);
resolved.Port.Should().Be(source.Port); await resolved.Port.Should().BeEqualTo(source.Port);
resolved.Password.Should().Be(source.Password); await resolved.Password.Should().BeEqualTo(source.Password);
resolved.GetProtocolExtra().AlterId.Should().Be(source.GetProtocolExtra().AlterId); await resolved.GetProtocolExtra().AlterId.Should().BeEqualTo(source.GetProtocolExtra().AlterId);
} }
[Fact] [Test]
public void GetShareUriAndResolveConfig_Vless_ShouldRoundTripBasicFields() public async Task GetShareUriAndResolveConfig_Vless_ShouldRoundTripBasicFields()
{ {
var source = CreateVlessProfile(); var source = CreateVlessProfile();
var resolved = ExportThenImport(source); var resolved = await ExportThenImport(source);
resolved.ConfigType.Should().Be(EConfigType.VLESS); await resolved.ConfigType.Should().BeEqualTo(EConfigType.VLESS);
resolved.Remarks.Should().Be(source.Remarks); await resolved.Remarks.Should().BeEqualTo(source.Remarks);
resolved.Address.Should().Be(source.Address); await resolved.Address.Should().BeEqualTo(source.Address);
resolved.Port.Should().Be(source.Port); await resolved.Port.Should().BeEqualTo(source.Port);
resolved.Password.Should().Be(source.Password); await resolved.Password.Should().BeEqualTo(source.Password);
resolved.GetProtocolExtra().VlessEncryption.Should().Be(Global.None); await resolved.GetProtocolExtra().VlessEncryption.Should().BeEqualTo(Global.None);
} }
[Fact] [Test]
public void GetShareUriAndResolveConfig_Shadowsocks_ShouldRoundTripBasicFields() public async Task GetShareUriAndResolveConfig_Shadowsocks_ShouldRoundTripBasicFields()
{ {
var source = CreateShadowsocksProfile(); var source = CreateShadowsocksProfile();
var resolved = ExportThenImport(source); var resolved = await ExportThenImport(source);
resolved.ConfigType.Should().Be(EConfigType.Shadowsocks); await resolved.ConfigType.Should().BeEqualTo(EConfigType.Shadowsocks);
resolved.Remarks.Should().Be(source.Remarks); await resolved.Remarks.Should().BeEqualTo(source.Remarks);
resolved.Address.Should().Be(source.Address); await resolved.Address.Should().BeEqualTo(source.Address);
resolved.Port.Should().Be(source.Port); await resolved.Port.Should().BeEqualTo(source.Port);
resolved.Password.Should().Be(source.Password); await resolved.Password.Should().BeEqualTo(source.Password);
resolved.GetProtocolExtra().SsMethod.Should().Be(source.GetProtocolExtra().SsMethod); await resolved.GetProtocolExtra().SsMethod.Should().BeEqualTo(source.GetProtocolExtra().SsMethod);
} }
[Fact] [Test]
public void GetShareUriAndResolveConfig_Socks_ShouldRoundTripBasicFields() public async Task GetShareUriAndResolveConfig_Socks_ShouldRoundTripBasicFields()
{ {
var source = CreateSocksProfile(); var source = CreateSocksProfile();
var resolved = ExportThenImport(source); var resolved = await ExportThenImport(source);
resolved.ConfigType.Should().Be(EConfigType.SOCKS); await resolved.ConfigType.Should().BeEqualTo(EConfigType.SOCKS);
resolved.Remarks.Should().Be(source.Remarks); await resolved.Remarks.Should().BeEqualTo(source.Remarks);
resolved.Address.Should().Be(source.Address); await resolved.Address.Should().BeEqualTo(source.Address);
resolved.Port.Should().Be(source.Port); await resolved.Port.Should().BeEqualTo(source.Port);
resolved.Username.Should().Be(source.Username); await resolved.Username.Should().BeEqualTo(source.Username);
resolved.Password.Should().Be(source.Password); await resolved.Password.Should().BeEqualTo(source.Password);
} }
[Fact] [Test]
public void ResolveConfig_UnsupportedProtocol_ShouldReturnNull() 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()
{ {
var resolved = FmtHandler.ResolveConfig("not-a-share-uri", out var msg); var resolved = FmtHandler.ResolveConfig("not-a-share-uri", out var msg);
resolved.Should().BeNull(); await resolved.Should().BeNull();
msg.Should().NotBeNullOrWhiteSpace(); await msg.Should().NotBeNull();
await msg.Should().NotBeEmpty();
} }
[Fact] [Test]
public void GetShareUri_UnsupportedConfigType_ShouldReturnNull() public async Task GetShareUri_UnsupportedConfigType_ShouldReturnNull()
{ {
var item = new ProfileItem { ConfigType = EConfigType.PolicyGroup, Remarks = "group", }; var item = new ProfileItem { ConfigType = EConfigType.PolicyGroup, Remarks = "group", };
var uri = FmtHandler.GetShareUri(item); var uri = FmtHandler.GetShareUri(item);
uri.Should().BeNull(); await uri.Should().BeNull();
} }
private static ProfileItem ExportThenImport(ProfileItem source) 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); var uri = FmtHandler.GetShareUri(source);
uri.Should().NotBeNullOrWhiteSpace(); await uri.Should().NotBeNull();
uri!.StartsWith(Global.ProtocolShares[source.ConfigType], StringComparison.OrdinalIgnoreCase).Should()
.BeTrue(); 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(expectedPrefix, StringComparison.OrdinalIgnoreCase).Should().BeTrue();
var resolved = FmtHandler.ResolveConfig(uri, out var msg); var resolved = FmtHandler.ResolveConfig(uri, out var msg);
resolved.Should().NotBeNull($"uri: {uri}, msg: {msg}"); await resolved.Should().NotBeNull().Because($"uri: {uri}, msg: {msg}");
return resolved!; return resolved!;
} }
@@ -170,4 +421,142 @@ public class FmtHandlerTests
Password = "pass", 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;
}
} }
+37 -42
View File
@@ -1,32 +1,27 @@
using AwesomeAssertions;
using ServiceLib.Handler.Fmt;
using ServiceLib.Models.Dto;
using Xunit;
namespace ServiceLib.Tests.Fmt; namespace ServiceLib.Tests.Fmt;
public class HyRealmTests public class HyRealmTests
{ {
[Fact] [Test]
public void TryParse_ShouldParseValidRealm() public async Task TryParse_ShouldParseValidRealm()
{ {
var str = "realm://public@realm.hy2.io/57f9be7c-2810-4f5b-8cb9-260bc84d6c90?stun=example.stun:3478&stun=example2.stun:3478"; var str = "realm://public@realm.hy2.io/57f9be7c-2810-4f5b-8cb9-260bc84d6c90?stun=example.stun:3478&stun=example2.stun:3478";
var result = HyRealm.TryParse(str, out var realm); var result = HyRealm.TryParse(str, out var realm);
result.Should().BeTrue(); await result.Should().BeTrue();
realm.Should().NotBeNull(); await realm.Should().NotBeNull();
realm.IsHttp.Should().BeFalse(); await realm.IsHttp.Should().BeFalse();
realm.Token.Should().Be("public"); await realm.Token.Should().BeEqualTo("public");
realm.RendezvousHost.Should().Be("realm.hy2.io"); await realm.RendezvousHost.Should().BeEqualTo("realm.hy2.io");
realm.RendezvousPort.Should().Be(443); await realm.RendezvousPort.Should().BeEqualTo(443);
realm.RealmName.Should().Be("57f9be7c-2810-4f5b-8cb9-260bc84d6c90"); await realm.RealmName.Should().BeEqualTo("57f9be7c-2810-4f5b-8cb9-260bc84d6c90");
realm.StunList.Should().HaveCount(2); await realm.StunList.Should().HaveCount(2);
realm.StunList.Should().Contain("example.stun:3478"); await realm.StunList.Should().Contain("example.stun:3478");
realm.StunList.Should().Contain("example2.stun:3478"); await realm.StunList.Should().Contain("example2.stun:3478");
} }
[Fact] [Test]
public void ToUri_ShouldGenerateValidUri() public async Task ToUri_ShouldGenerateValidUri()
{ {
var realm = new HyRealm( var realm = new HyRealm(
IsHttp: false, IsHttp: false,
@@ -37,32 +32,32 @@ public class HyRealmTests
StunList: ["example.stun:3478", "example2.stun:3478"] StunList: ["example.stun:3478", "example2.stun:3478"]
); );
var uri = realm.ToUri(); var uri = realm.ToUri();
uri.Should().Contain("realm://public@realm.hy2.io"); await uri.Should().Contain("realm://public@realm.hy2.io");
uri.Should().Contain("/57f9be7c-2810-4f5b-8cb9-260bc84d6c90"); await uri.Should().Contain("/57f9be7c-2810-4f5b-8cb9-260bc84d6c90");
uri.Should().Contain("stun=example.stun:3478"); await uri.Should().Contain("stun=example.stun:3478");
uri.Should().Contain("stun=example2.stun:3478"); await uri.Should().Contain("stun=example2.stun:3478");
} }
[Fact] [Test]
public void GetShareUriAndResolveConfig_Hy2Realm_ShouldRoundTripBasicFields() public async Task GetShareUriAndResolveConfig_Hy2Realm_ShouldRoundTripBasicFields()
{ {
var str = "hysteria2+realm://mytoken@rendezvous.example.com/my-cabin-1f3a8c2e9b?auth=your_password&insecure=1&pinSHA256=deadbeef#remark"; var str = "hysteria2+realm://mytoken@rendezvous.example.com/my-cabin-1f3a8c2e9b?auth=your_password&insecure=1&pinSHA256=deadbeef#remark";
var resolved = Hysteria2Fmt.ResolveRealm(str, out var msg); var resolved = Hysteria2Fmt.ResolveRealm(str, out var msg);
resolved.Should().NotBeNull(); await resolved.Should().NotBeNull();
resolved.Password.Should().Be("your_password"); await resolved.Password.Should().BeEqualTo("your_password");
var result = HyRealm.TryParse(resolved.GetProtocolExtra().Hy2RealmUrl, out var realm); var result = HyRealm.TryParse(resolved.GetProtocolExtra().Hy2RealmUrl, out var realm);
result.Should().BeTrue(); await result.Should().BeTrue();
realm.Should().NotBeNull(); await realm.Should().NotBeNull();
realm.Token.Should().Be("mytoken"); await realm.Token.Should().BeEqualTo("mytoken");
// To uri // To uri
var uri = Hysteria2Fmt.ToUri(resolved); var uri = Hysteria2Fmt.ToUri(resolved);
uri.Should().Contain("hysteria2+realm://mytoken@rendezvous.example.com"); await uri.Should().Contain("hysteria2+realm://mytoken@rendezvous.example.com");
uri.Should().EndWith("#remark"); await uri.Should().EndWith("#remark");
} }
[Fact] [Test]
public void ToServerUrl_ShouldIncludeSchemeForSingbox() public async Task ToServerUrl_ShouldIncludeSchemeForSingbox()
{ {
var realm = new HyRealm( var realm = new HyRealm(
IsHttp: false, IsHttp: false,
@@ -73,19 +68,19 @@ public class HyRealmTests
StunList: ["turn.cloudflare.com:3478"] StunList: ["turn.cloudflare.com:3478"]
); );
realm.ToServerUrl().Should().Be("https://realm.hy2.io:443"); await realm.ToServerUrl().Should().BeEqualTo("https://realm.hy2.io:443");
} }
[Fact] [Test]
public void ResolveRealm_Issue9635_ShouldProduceHttpsServerUrl() public async Task ResolveRealm_Issue9635_ShouldProduceHttpsServerUrl()
{ {
var str = "hysteria2+realm://public@realm.hy2.io/my-realm-id?auth=uuid&stun=turn.cloudflare.com%3A3478&sni=cloudflare.com&pinSHA256=xxx#Realm-Test"; var str = "hysteria2+realm://public@realm.hy2.io/my-realm-id?auth=uuid&stun=turn.cloudflare.com%3A3478&sni=cloudflare.com&pinSHA256=xxx#Realm-Test";
var resolved = Hysteria2Fmt.ResolveRealm(str, out _); var resolved = Hysteria2Fmt.ResolveRealm(str, out _);
resolved.Should().NotBeNull(); await resolved.Should().NotBeNull();
HyRealm.TryParse(resolved!.GetProtocolExtra().Hy2RealmUrl, out var realm).Should().BeTrue(); await HyRealm.TryParse(resolved!.GetProtocolExtra().Hy2RealmUrl, out var realm).Should().BeTrue();
realm!.ToServerUrl().Should().StartWith("https://"); await realm!.ToServerUrl().Should().StartWith("https://");
realm.ToServerUrl().Should().Contain("realm.hy2.io"); await realm.ToServerUrl().Should().Contain("realm.hy2.io");
realm.StunList.Should().Contain("turn.cloudflare.com:3478"); await realm.StunList.Should().Contain("turn.cloudflare.com:3478");
} }
} }
@@ -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();
}
}
+9 -12
View File
@@ -1,15 +1,11 @@
using AwesomeAssertions;
using ServiceLib.Enums;
using ServiceLib.Handler.Fmt;
using ServiceLib.Tests.CoreConfig; using ServiceLib.Tests.CoreConfig;
using Xunit;
namespace ServiceLib.Tests.Fmt; namespace ServiceLib.Tests.Fmt;
public class InnerFmtTests public class InnerFmtTests
{ {
[Fact] [Test]
public void ToUriAndResolve_ShouldRoundTripPolicyGroupReferences() public async Task ToUriAndResolve_ShouldRoundTripPolicyGroupReferences()
{ {
var childA = CoreConfigTestFactory.CreateSocksNode(ECoreType.Xray, "child-a", "child-a"); var childA = CoreConfigTestFactory.CreateSocksNode(ECoreType.Xray, "child-a", "child-a");
var childB = CoreConfigTestFactory.CreateVmessNode(ECoreType.Xray, "child-b", "child-b"); var childB = CoreConfigTestFactory.CreateVmessNode(ECoreType.Xray, "child-b", "child-b");
@@ -19,19 +15,20 @@ public class InnerFmtTests
var uri = InnerFmt.ToUri([group, childA, childB]); var uri = InnerFmt.ToUri([group, childA, childB]);
uri.Should().NotBeNullOrWhiteSpace(); await uri.Should().NotBeNull();
await uri.Should().NotBeEmpty();
var resolved = InnerFmt.Resolve(uri!, "sub-123"); var resolved = InnerFmt.Resolve(uri!, "sub-123");
resolved.Should().NotBeNull(); await resolved.Should().NotBeNull();
resolved.Should().HaveCount(3); await resolved.Should().HaveCount(3);
var resolvedGroup = resolved!.Single(x => x.Remarks == group.Remarks); var resolvedGroup = resolved!.Single(x => x.Remarks == group.Remarks);
var resolvedChildA = resolved.Single(x => x.Remarks == childA.Remarks); var resolvedChildA = resolved.Single(x => x.Remarks == childA.Remarks);
var resolvedChildB = resolved.Single(x => x.Remarks == childB.Remarks); var resolvedChildB = resolved.Single(x => x.Remarks == childB.Remarks);
resolvedGroup.ConfigType.Should().Be(EConfigType.PolicyGroup); await resolvedGroup.ConfigType.Should().BeEqualTo(EConfigType.PolicyGroup);
resolvedGroup.GetProtocolExtra().SubChildItems.Should().Be("sub-123"); await resolvedGroup.GetProtocolExtra().SubChildItems.Should().BeEqualTo("sub-123");
resolvedGroup.GetProtocolExtra().ChildItems.Should().Be($"{resolvedChildA.IndexId},{resolvedChildB.IndexId}"); await resolvedGroup.GetProtocolExtra().ChildItems.Should().BeEqualTo($"{resolvedChildA.IndexId},{resolvedChildB.IndexId}");
} }
} }
@@ -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");
}
}
@@ -1,13 +1,9 @@
using AwesomeAssertions;
using ServiceLib.Handler.Fmt;
using Xunit;
namespace ServiceLib.Tests.Fmt; namespace ServiceLib.Tests.Fmt;
public class WireguardFmtTests public class WireguardFmtTests
{ {
[Fact] [Test]
public void ResolveConfig_ShouldParsePeersAndIgnoreInlineComments() public async Task ResolveConfig_ShouldParsePeersAndIgnoreInlineComments()
{ {
const string config = const string config =
""" """
@@ -15,6 +11,7 @@ public class WireguardFmtTests
PrivateKey = interface-private-key PrivateKey = interface-private-key
Address = 10.0.0.2/32, fd00::2/128 ; inline comment Address = 10.0.0.2/32, fd00::2/128 ; inline comment
MTU = 1420 MTU = 1420
DNS = 2001::db8::53, 1.2.3.4, 2001:db8::54
[Peer] [Peer]
PublicKey = peer-public-key PublicKey = peer-public-key
@@ -29,19 +26,20 @@ public class WireguardFmtTests
var resolved = WireguardFmt.ResolveConfig(config); var resolved = WireguardFmt.ResolveConfig(config);
resolved.Should().NotBeNull(); await resolved.Should().NotBeNull();
resolved.Should().HaveCount(2); await resolved.Should().HaveCount(2);
var first = resolved![0]; var first = resolved![0];
first.Address.Should().Be("2001:db8::1"); await first.Address.Should().BeEqualTo("2001:db8::1");
first.Port.Should().Be(51820); await first.Port.Should().BeEqualTo(51820);
first.Password.Should().Be("interface-private-key"); await first.Password.Should().BeEqualTo("interface-private-key");
first.GetProtocolExtra().WgReserved.Should().Be("1, 2, 3"); await first.GetProtocolExtra().WgReserved.Should().BeEqualTo("1, 2, 3");
first.GetProtocolExtra().WgInterfaceAddress.Should().Be("10.0.0.2/32, fd00::2/128"); await first.GetProtocolExtra().WgInterfaceAddress.Should().BeEqualTo("10.0.0.2/32, fd00::2/128");
first.GetProtocolExtra().WgMtu.Should().Be(1420); 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]; var second = resolved[1];
second.Address.Should().Be("example.com"); await second.Address.Should().BeEqualTo("example.com");
second.Port.Should().Be(12345); await second.Port.Should().BeEqualTo(12345);
} }
} }
@@ -1,44 +1,39 @@
using AwesomeAssertions;
using ServiceLib.Enums;
using ServiceLib.Manager;
using Xunit;
namespace ServiceLib.Tests.Manager; namespace ServiceLib.Tests.Manager;
public class CoreManagerTests public class CoreManagerTests
{ {
[Theory] [Test]
[InlineData(ECoreType.sing_box)] [Arguments(ECoreType.sing_box)]
[InlineData(ECoreType.mihomo)] [Arguments(ECoreType.mihomo)]
[InlineData(ECoreType.Xray)] [Arguments(ECoreType.Xray)]
public void ShouldRunAsSudo_TunLaunchOnNonWindows_RequiresElevation(ECoreType coreType) public async Task ShouldRunAsSudo_TunLaunchOnNonWindows_RequiresElevation(ECoreType coreType)
{ {
CoreManager.ShouldRunAsSudo(isTunLaunch: true, coreType, isNonWindows: true).Should().BeTrue(); await CoreManager.ShouldRunAsSudo(isTunLaunch: true, coreType, isNonWindows: true).Should().BeTrue();
} }
[Fact] [Test]
public void ShouldRunAsSudo_NonTunLaunch_ShouldNotElevate() public async Task ShouldRunAsSudo_NonTunLaunch_ShouldNotElevate()
{ {
// Regression guard for the macOS TUN failure: the elevation decision must follow // Regression guard for the macOS TUN failure: the elevation decision must follow
// the context snapshot that generated the config. A launch whose snapshot has TUN // the context snapshot that generated the config. A launch whose snapshot has TUN
// disabled must never elevate, and a launch whose snapshot has TUN enabled must // disabled must never elevate, and a launch whose snapshot has TUN enabled must
// elevate regardless of later changes to the live config. // elevate regardless of later changes to the live config.
CoreManager.ShouldRunAsSudo(isTunLaunch: false, ECoreType.sing_box, isNonWindows: true).Should().BeFalse(); await CoreManager.ShouldRunAsSudo(isTunLaunch: false, ECoreType.sing_box, isNonWindows: true).Should().BeFalse();
CoreManager.ShouldRunAsSudo(isTunLaunch: false, ECoreType.Xray, isNonWindows: true).Should().BeFalse(); await CoreManager.ShouldRunAsSudo(isTunLaunch: false, ECoreType.Xray, isNonWindows: true).Should().BeFalse();
} }
[Fact] [Test]
public void ShouldRunAsSudo_OnWindows_ShouldNotElevate() public async Task ShouldRunAsSudo_OnWindows_ShouldNotElevate()
{ {
CoreManager.ShouldRunAsSudo(isTunLaunch: true, ECoreType.sing_box, isNonWindows: false).Should().BeFalse(); await CoreManager.ShouldRunAsSudo(isTunLaunch: true, ECoreType.sing_box, isNonWindows: false).Should().BeFalse();
} }
[Theory] [Test]
[InlineData(ECoreType.v2fly)] [Arguments(ECoreType.v2fly)]
[InlineData(ECoreType.hysteria)] [Arguments(ECoreType.hysteria)]
[InlineData(null)] [Arguments(null)]
public void ShouldRunAsSudo_UnsupportedCoreType_ShouldNotElevate(ECoreType? coreType) public async Task ShouldRunAsSudo_UnsupportedCoreType_ShouldNotElevate(ECoreType? coreType)
{ {
CoreManager.ShouldRunAsSudo(isTunLaunch: true, coreType, isNonWindows: true).Should().BeFalse(); await CoreManager.ShouldRunAsSudo(isTunLaunch: true, coreType, isNonWindows: true).Should().BeFalse();
} }
} }
+17 -17
View File
@@ -1,23 +1,23 @@
<Project Sdk="Microsoft.NET.Sdk"> <Project Sdk="Microsoft.NET.Sdk">
<PropertyGroup> <PropertyGroup>
<OutputType>Exe</OutputType> <OutputType>Exe</OutputType>
<IsPackable>false</IsPackable> <IsPackable>false</IsPackable>
<IsTestProject>true</IsTestProject> <ImplicitUsings>enable</ImplicitUsings>
</PropertyGroup> </PropertyGroup>
<ItemGroup> <ItemGroup>
<PackageReference Include="AwesomeAssertions" /> <PackageReference Include="TUnit" />
<PackageReference Include="Microsoft.NET.Test.Sdk" /> <PackageReference Include="TUnit.Assertions.Should" />
<PackageReference Include="xunit.runner.visualstudio"> </ItemGroup>
<PrivateAssets>all</PrivateAssets>
<IncludeAssets>runtime; build; native; contentfiles; analyzers; buildtransitive</IncludeAssets>
</PackageReference>
<PackageReference Include="xunit.v3" />
</ItemGroup>
<ItemGroup> <ItemGroup>
<ProjectReference Include="..\ServiceLib\ServiceLib.csproj" /> <ProjectReference Include="..\ServiceLib\ServiceLib.csproj" />
</ItemGroup> </ItemGroup>
<ItemGroup>
<Using Include="TUnit.Assertions.Should" />
<Using Include="TUnit.Assertions.Should.Extensions" />
</ItemGroup>
</Project> </Project>
@@ -2,7 +2,14 @@ namespace ServiceLib.Base;
public class BulkObservableCollection<T> : ObservableCollection<T> 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) protected override void OnCollectionChanged(NotifyCollectionChangedEventArgs e)
{ {
@@ -63,4 +70,27 @@ public class BulkObservableCollection<T> : ObservableCollection<T>
return true; 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));
}
}
} }
+27
View File
@@ -1,4 +1,5 @@
using System.Diagnostics.CodeAnalysis; using System.Diagnostics.CodeAnalysis;
using System.Runtime.CompilerServices;
namespace ServiceLib.Common; namespace ServiceLib.Common;
@@ -134,4 +135,30 @@ public static class Extension
.Replace("\r", replacement) .Replace("\r", replacement)
.Replace("\n", replacement); .Replace("\n", replacement);
} }
public static async Task<TOutput> HandleSafe<TInput, TOutput>(
this Interaction<TInput, TOutput> interaction,
TInput input,
TOutput defaultValue = default!,
[CallerMemberName] string memberName = "",
[CallerFilePath] string filePath = "",
[CallerLineNumber] int lineNumber = 0)
{
try
{
return await interaction.Handle(input);
}
catch (UnhandledInteractionException<TInput, TOutput> ex)
{
var title = $"Unhandled interaction exception in {memberName} at {filePath}:{lineNumber}";
Logging.SaveLog(title, ex);
return defaultValue;
}
catch (Exception ex)
{
var title = $"Exception occurred while handling interaction in {memberName} at {filePath}:{lineNumber}, input: {input}";
Logging.SaveLog(title, ex);
return defaultValue;
}
}
} }
+39 -4
View File
@@ -1,4 +1,3 @@
using System.Collections.Specialized;
using System.Security.Principal; using System.Security.Principal;
using CliWrap; using CliWrap;
using CliWrap.Buffered; using CliWrap.Buffered;
@@ -201,7 +200,9 @@ public class Utils
var parts = query[1..].Split('&', StringSplitOptions.RemoveEmptyEntries); var parts = query[1..].Split('&', StringSplitOptions.RemoveEmptyEntries);
foreach (var part in parts) 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) if (keyValue.Length != 2)
{ {
continue; continue;
@@ -753,11 +754,11 @@ public class Utils
return false; return false;
} }
public static int GetFreePort(int defaultPort = 0) public static int GetFreePort(int defaultPort)
{ {
try try
{ {
if (!(defaultPort == 0 || Utils.PortInUse(defaultPort))) if (!PortInUse(defaultPort))
{ {
return defaultPort; return defaultPort;
} }
@@ -814,6 +815,40 @@ public class Utils
.Any(ni => ni.Name.Equals(inInterfaceName, StringComparison.OrdinalIgnoreCase)); .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 #endregion Speed Test
#region Miscellaneous #region Miscellaneous
-1
View File
@@ -5,5 +5,4 @@ public enum ERuleMode
Rule = 0, Rule = 0,
Global = 1, Global = 1,
Direct = 2, Direct = 2,
Unchanged = 3
} }
+1
View File
@@ -5,6 +5,7 @@ public sealed class EventChannel<T>
private readonly Signal<T> _signal = new(); private readonly Signal<T> _signal = new();
private readonly Lock _gate = new(); private readonly Lock _gate = new();
private readonly IObservable<T> _observable; private readonly IObservable<T> _observable;
public EventChannel() public EventChannel()
{ {
_observable = _signal.Synchronize(_gate); _observable = _signal.Synchronize(_gate);
+10 -5
View File
@@ -97,11 +97,16 @@ public class Global
public const string StringFalse = "false"; public const string StringFalse = "false";
public const int SqliteMaxBatchSize = 10000; public const int SqliteMaxBatchSize = 10000;
public const string SingboxDirectDNSTag = "direct_dns"; public const string SingboxDirectDNSTagPrefix = "direct-dns-";
public const string SingboxRemoteDNSTag = "remote_dns"; public const string SingboxRemoteDNSTagPrefix = "remote-dns-";
public const string SingboxLocalDNSTag = "local_local"; public const string SingboxDirectDNSTag = "direct-dns-1";
public const string SingboxHostsDNSTag = "hosts_dns"; public const string SingboxRemoteDNSTag = "remote-dns-1";
public const string SingboxFakeDNSTag = "fake_dns"; 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 int Hysteria2DefaultHopInt = 30;
public const string PolicyGroupExcludeKeywords = @"剩余|过期|到期|重置|[Rr]emaining|[Ee]xpir|[Rr]eset"; public const string PolicyGroupExcludeKeywords = @"剩余|过期|到期|重置|[Rr]emaining|[Ee]xpir|[Rr]eset";
+1 -2
View File
@@ -8,6 +8,7 @@ global using System.Net.NetworkInformation;
global using System.Net.Sockets; global using System.Net.Sockets;
global using ReactiveUI.Primitives; global using ReactiveUI.Primitives;
global using ReactiveUI.Primitives.Concurrency; global using ReactiveUI.Primitives.Concurrency;
global using ReactiveUI.Primitives.Extensions;
global using ReactiveUI.Primitives.Disposables; global using ReactiveUI.Primitives.Disposables;
global using ReactiveUI.Primitives.Signals; global using ReactiveUI.Primitives.Signals;
global using System.Reflection; global using System.Reflection;
@@ -41,5 +42,3 @@ global using ServiceLib.Services;
global using ServiceLib.Services.CoreConfig; global using ServiceLib.Services.CoreConfig;
global using ServiceLib.Services.Statistics; global using ServiceLib.Services.Statistics;
global using SQLite; global using SQLite;
@@ -1,5 +1,3 @@
using System.ComponentModel.DataAnnotations;
namespace ServiceLib.Handler.Builder; namespace ServiceLib.Handler.Builder;
public record CoreConfigContextBuilderResult(CoreConfigContext Context, NodeValidatorResult ValidatorResult) public record CoreConfigContextBuilderResult(CoreConfigContext Context, NodeValidatorResult ValidatorResult)
@@ -51,6 +49,7 @@ public class CoreConfigContextBuilder
RoutingItem = await ConfigHandler.GetDefaultRouting(config), RoutingItem = await ConfigHandler.GetDefaultRouting(config),
IsWindows = Utils.IsWindows(), IsWindows = Utils.IsWindows(),
IsMacOS = Utils.IsMacOS(), IsMacOS = Utils.IsMacOS(),
HasGlobalIPv6Address = Utils.HasGlobalIPv6Address(),
ProtectCoreTypeList = config.TunModeItem.EnableTun ? [ECoreType.Xray, ECoreType.sing_box] : [] ProtectCoreTypeList = config.TunModeItem.EnableTun ? [ECoreType.Xray, ECoreType.sing_box] : []
}; };
var validatorResult = NodeValidatorResult.Empty(); var validatorResult = NodeValidatorResult.Empty();
@@ -205,7 +204,7 @@ public class CoreConfigContextBuilder
Context = preSocksResult.Context with Context = preSocksResult.Context with
{ {
ProtectDomainList = ProtectDomainList =
[.. nodeContext.ProtectDomainList ?? [], .. preSocksResult.Context.ProtectDomainList ?? []], [.. nodeContext.ProtectDomainList, .. preSocksResult.Context.ProtectDomainList],
ProtectCoreTypeList = protectCoreTypeList, ProtectCoreTypeList = protectCoreTypeList,
}, },
}; };
@@ -386,7 +385,7 @@ public class CoreConfigContextBuilder
{ {
var echQuerySni = node.Sni; var echQuerySni = node.Sni;
if (node.StreamSecurity == Global.StreamSecurity if (node.StreamSecurity == Global.StreamSecurity
&& node.EchConfigList?.Contains("://") == true) && node.EchConfigList.Contains("://"))
{ {
var idx = node.EchConfigList.IndexOf('+'); var idx = node.EchConfigList.IndexOf('+');
echQuerySni = idx > 0 ? node.EchConfigList[..idx] : node.Sni; echQuerySni = idx > 0 ? node.EchConfigList[..idx] : node.Sni;
@@ -115,10 +115,10 @@ public class NodeValidator
if (item.GetNetwork() is nameof(ETransport.ws) if (item.GetNetwork() is nameof(ETransport.ws)
&& item.EchConfigList.IsNullOrEmpty() && item.EchConfigList.IsNullOrEmpty()
&& item.GetAlpn()?.FirstOrDefault() == "h3") && item.GetAlpn()?.FirstOrDefault() is "h3" or "h2")
{ {
v.Warning( v.Warning(
"WebSocket but ALPN is set to h3, the core may ignore the ALPN setting or cause unexpected issues."); $"WebSocket but ALPN is set to {item.Alpn}, the core may ignore the ALPN setting or cause unexpected issues.");
} }
// TLS & Security // TLS & Security
@@ -131,14 +131,6 @@ public class NodeValidator
isCertProvided = false; isCertProvided = false;
} }
// Check for deprecated allowInsecure property when TLS is enabled
if (item.GetAllowInsecure()
&& item.Cert.IsNullOrEmpty()
&& item.CertSha.IsNullOrEmpty())
{
v.Warning(ResUI.MsgAllowInsecureDeprecated);
}
if ((coreType == ECoreType.Xray if ((coreType == ECoreType.Xray
&& item.GetAllowInsecure() && item.GetAllowInsecure()
&& !isCertProvided && !isCertProvided
@@ -147,6 +139,10 @@ public class NodeValidator
&& item.GetAllowInsecure() && item.GetAllowInsecure()
&& !isCertProvided)) && !isCertProvided))
{ {
if (coreType == ECoreType.Xray)
{
v.Warning(ResUI.MsgAllowInsecureDeprecated);
}
v.Warning(ResUI.MsgInsecureConfiguration); v.Warning(ResUI.MsgInsecureConfiguration);
} }
} }
+37 -24
View File
@@ -115,6 +115,7 @@ public static class ConfigHandler
config.ConstItem ??= new ConstItem(); config.ConstItem ??= new ConstItem();
config.SimpleDNSItem ??= InitBuiltinSimpleDNS(); config.SimpleDNSItem ??= InitBuiltinSimpleDNS();
config.SimpleDNSItem.BlockAAAAQuery ??= false;
config.SimpleDNSItem.FakeIPRange ??= Global.FakeIPRanges.FirstOrDefault(); config.SimpleDNSItem.FakeIPRange ??= Global.FakeIPRanges.FirstOrDefault();
config.SimpleDNSItem.GlobalFakeIp ??= true; config.SimpleDNSItem.GlobalFakeIp ??= true;
config.SimpleDNSItem.BootstrapDNS ??= Global.DomainPureIPDNSAddress.FirstOrDefault(); config.SimpleDNSItem.BootstrapDNS ??= Global.DomainPureIPDNSAddress.FirstOrDefault();
@@ -586,7 +587,6 @@ public static class ConfigHandler
return 0; return 0;
} }
public static async Task<int> AddCustomOutboundServer(Config config, ProfileItem profileItem, bool blDelete, bool toFile = true) public static async Task<int> AddCustomOutboundServer(Config config, ProfileItem profileItem, bool blDelete, bool toFile = true)
{ {
var fileName = profileItem.Address; var fileName = profileItem.Address;
@@ -922,6 +922,7 @@ public static class ConfigHandler
WgInterfaceAddress = profileItem.GetProtocolExtra().WgInterfaceAddress?.TrimEx(), WgInterfaceAddress = profileItem.GetProtocolExtra().WgInterfaceAddress?.TrimEx(),
WgReserved = wgReserved, WgReserved = wgReserved,
WgMtu = profileItem.GetProtocolExtra().WgMtu is null or <= 0 ? Global.TunMtus.First() : profileItem.GetProtocolExtra().WgMtu, WgMtu = profileItem.GetProtocolExtra().WgMtu is null or <= 0 ? Global.TunMtus.First() : profileItem.GetProtocolExtra().WgMtu,
WgDns = profileItem.GetProtocolExtra().WgDns?.TrimEx(),
}); });
if (profileItem.Password.IsNullOrEmpty()) if (profileItem.Password.IsNullOrEmpty())
@@ -1739,21 +1740,27 @@ public static class ConfigHandler
SubItem? subItem) SubItem? subItem)
{ {
var subRemarks = subItem?.Remarks; var subRemarks = subItem?.Remarks;
// Safe Mode: Only allow full configuration if it's not from a subscription // Prioritize using complete custom parsing, followed by custom outbound parsing.
var lstProfiles = V2rayFmt.ResolveToCustomOutbound(strData, subRemarks); var lstProfiles = V2rayFmt.ResolveToCustom(strData, subRemarks);
if (lstProfiles.Count == 0)
{
lstProfiles = SingboxFmt.ResolveToCustom(strData, subRemarks);
}
if (lstProfiles.Count == 0)
{
lstProfiles = V2rayFmt.ResolveToCustomOutbound(strData, subRemarks);
}
if (lstProfiles.Count == 0) if (lstProfiles.Count == 0)
{ {
lstProfiles = SingboxFmt.ResolveToCustomOutbound(strData, subRemarks); 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)
{
var count = await AddCustomOutboundServers(config, lstProfiles, subid, isSub); return count;
if (count > 0) }
{
return count;
} }
if (HtmlPageFmt.IsHtmlPage(strData)) if (HtmlPageFmt.IsHtmlPage(strData))
@@ -1786,21 +1793,16 @@ public static class ConfigHandler
var subRemarks = subItem.Remarks; var subRemarks = subItem.Remarks;
var customCoreType = subItem.CustomCoreType!.Value; var customCoreType = subItem.CustomCoreType!.Value;
List<ProfileItem>? lstProfiles = customCoreType switch var lstProfiles = customCoreType switch
{ {
ECoreType.Xray => V2rayFmt.ResolveToCustom(strData, subRemarks), ECoreType.Xray => V2rayFmt.ResolveToCustom(strData, subRemarks),
ECoreType.sing_box => SingboxFmt.ResolveToCustom(strData, subRemarks), ECoreType.sing_box => SingboxFmt.ResolveToCustom(strData, subRemarks),
_ => null _ => null,
}; };
if (lstProfiles is not null) if (lstProfiles?.Count > 0)
{ {
if (lstProfiles.Count == 0) var count = await AddBatchCustomServers(config, lstProfiles, subid, isSub);
{
return -1;
}
var count = await AddCustomOutboundServers(config, lstProfiles, subid, isSub);
if (count > 0) if (count > 0)
{ {
return count; return count;
@@ -1810,7 +1812,7 @@ public static class ConfigHandler
return await SaveCustomRawFileServer(config, strData, subid, isSub, subItem, customCoreType); return await SaveCustomRawFileServer(config, strData, subid, isSub, subItem, customCoreType);
} }
private static async Task<int> AddCustomOutboundServers( private static async Task<int> AddBatchCustomServers(
Config config, Config config,
List<ProfileItem> lstProfiles, List<ProfileItem> lstProfiles,
string subid, string subid,
@@ -1821,9 +1823,20 @@ public static class ConfigHandler
{ {
it.Subid = subid; it.Subid = subid;
it.IsSub = isSub; it.IsSub = isSub;
if (await AddCustomOutboundServer(config, it, true) == 0)
if (it.ConfigType == EConfigType.Custom)
{ {
count++; if (await AddCustomServer(config, it, true) == 0)
{
count++;
}
}
else
{
if (await AddCustomOutboundServer(config, it, true) == 0)
{
count++;
}
} }
} }
return count; return count;
@@ -2598,7 +2611,7 @@ public static class ConfigHandler
items = await AppManager.Instance.RoutingItems(); 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 //migrate
//TODO Temporary code to be removed later //TODO Temporary code to be removed later
@@ -5,14 +5,14 @@ public static class ConnectionHandler
private static readonly string _tag = "ConnectionHandler"; private static readonly string _tag = "ConnectionHandler";
/// <summary> /// <summary>
/// Runs ping and IP checks and returns a formatted result string. /// Runs ping and IP checks.
/// </summary> /// </summary>
public static async Task<string> RunAvailabilityCheck() public static async Task<AvailabilityCheckResult> RunAvailabilityCheck()
{ {
var time = await GetRealPingTimeInfo(); var time = await GetRealPingTimeInfo();
var ip = time > 0 ? await GetIPInfo() : Global.None; var ip = time > 0 ? await GetIPInfo() : Global.None;
return string.Format(ResUI.TestMeOutput, time, ip); return new AvailabilityCheckResult(time, ip);
} }
/// <summary> /// <summary>
+6 -3
View File
@@ -1,5 +1,3 @@
using System.Collections.Specialized;
namespace ServiceLib.Handler.Fmt; namespace ServiceLib.Handler.Fmt;
public class BaseFmt public class BaseFmt
@@ -344,8 +342,13 @@ public class BaseFmt
return query[key] ?? defaultValue; 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 = "") protected static string GetQueryDecoded(NameValueCollection query, string key, string defaultValue = "")
{ {
return Utils.UrlDecode(GetQueryValue(query, key, defaultValue)); return GetQueryValue(query, key, defaultValue);
} }
} }
+28 -4
View File
@@ -1,5 +1,3 @@
using System.Collections.Specialized;
namespace ServiceLib.Handler.Fmt; namespace ServiceLib.Handler.Fmt;
public class Hysteria2Fmt : BaseFmt public class Hysteria2Fmt : BaseFmt
@@ -19,7 +17,11 @@ public class Hysteria2Fmt : BaseFmt
} }
item.Address = url.IdnHost; 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.Remarks = url.GetComponents(UriComponents.Fragment, UriFormat.Unescaped);
item.Password = Utils.UrlDecode(url.UserInfo); item.Password = Utils.UrlDecode(url.UserInfo);
@@ -168,8 +170,21 @@ public class Hysteria2Fmt : BaseFmt
} }
if (item.CertSha.IsNullOrEmpty()) if (item.CertSha.IsNullOrEmpty())
{ {
item.CertSha = GetQueryDecoded(query, "pinSHA256"); var pinSHA256 = GetQueryDecoded(query, "pinSHA256");
item.CertSha = pinSHA256;
if (!pinSHA256.IsNullOrEmpty())
{
// NOTE:
// To accommodate Xray changes,
// some providers issue self-signed cert links with `insecure = false` and a certificate fingerprint,
// breaking interoperability between Xray, official Hysteria 2 client, and sing-box.
// Since this won't compromise the overall security model,
// `insecure = true` is automatically set when a fingerprint is detected,
// and the value is restored when generating configurations.
item.AllowInsecure = Global.StringTrue;
}
} }
item.EchConfigList = GetQueryDecoded(query, "ech");
item.SetProtocolExtra(item.GetProtocolExtra() with item.SetProtocolExtra(item.GetProtocolExtra() with
{ {
Ports = GetQueryDecoded(query, "mport"), Ports = GetQueryDecoded(query, "mport"),
@@ -212,6 +227,15 @@ public class Hysteria2Fmt : BaseFmt
var sha = item.CertSha; var sha = item.CertSha;
dicQuery.Add("pinSHA256", Utils.UrlEncode(sha)); 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));
}
var protocolExtraItem = item.GetProtocolExtra(); var protocolExtraItem = item.GetProtocolExtra();
var isGecko = !protocolExtraItem.GeckoMinPacketSize.IsNullOrEmpty() || !protocolExtraItem.GeckoMaxPacketSize.IsNullOrEmpty(); var isGecko = !protocolExtraItem.GeckoMinPacketSize.IsNullOrEmpty() || !protocolExtraItem.GeckoMaxPacketSize.IsNullOrEmpty();
if (!protocolExtraItem.SalamanderPass.IsNullOrEmpty()) if (!protocolExtraItem.SalamanderPass.IsNullOrEmpty())
+2 -1
View File
@@ -34,9 +34,9 @@ public class SingboxFmt : BaseFmt
if (!isOutbound) if (!isOutbound)
{ {
var fullProfile = ResolveFull(jsonObject, subRemarks); var fullProfile = ResolveFull(jsonObject, subRemarks);
profileList.Add(fullProfile);
if (fullProfile is not null) if (fullProfile is not null)
{ {
profileList.Add(fullProfile);
return profileList; return profileList;
} }
} }
@@ -76,6 +76,7 @@ public class SingboxFmt : BaseFmt
var fileName = WriteAllText(JsonUtils.Serialize(jsonObject)); var fileName = WriteAllText(JsonUtils.Serialize(jsonObject));
var profileItem = new ProfileItem var profileItem = new ProfileItem
{ {
ConfigType = EConfigType.Custom,
CoreType = ECoreType.sing_box, CoreType = ECoreType.sing_box,
Address = fileName, Address = fileName,
Remarks = subRemarks ?? "singbox_custom", Remarks = subRemarks ?? "singbox_custom",
+2 -1
View File
@@ -34,9 +34,9 @@ public class V2rayFmt : BaseFmt
if (!isOutbound) if (!isOutbound)
{ {
var fullProfile = ResolveFull(jsonObject, subRemarks); var fullProfile = ResolveFull(jsonObject, subRemarks);
profileList.Add(fullProfile);
if (fullProfile is not null) if (fullProfile is not null)
{ {
profileList.Add(fullProfile);
return profileList; return profileList;
} }
} }
@@ -77,6 +77,7 @@ public class V2rayFmt : BaseFmt
var profileItem = new ProfileItem var profileItem = new ProfileItem
{ {
ConfigType = EConfigType.Custom,
CoreType = ECoreType.Xray, CoreType = ECoreType.Xray,
Address = fileName, Address = fileName,
Remarks = jsonObject["remarks"]?.ToString() ?? subRemarks ?? "v2ray_custom", Remarks = jsonObject["remarks"]?.ToString() ?? subRemarks ?? "v2ray_custom",
@@ -31,6 +31,7 @@ public class WireguardFmt : BaseFmt
WgReserved = GetQueryDecoded(query, "reserved"), WgReserved = GetQueryDecoded(query, "reserved"),
WgInterfaceAddress = GetQueryDecoded(query, "address"), WgInterfaceAddress = GetQueryDecoded(query, "address"),
WgMtu = int.TryParse(GetQueryDecoded(query, "mtu"), out var mtuVal) ? mtuVal : null, WgMtu = int.TryParse(GetQueryDecoded(query, "mtu"), out var mtuVal) ? mtuVal : null,
WgDns = GetQueryDecoded(query, "dns"),
}); });
return item; return item;
@@ -71,6 +72,10 @@ public class WireguardFmt : BaseFmt
{ {
dicQuery.Add("mtu", protoExtra.WgMtu.ToString()); 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); 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 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 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 index = 0;
var resultList = new List<ProfileItem>(); var resultList = new List<ProfileItem>();
@@ -156,6 +162,7 @@ public class WireguardFmt : BaseFmt
WgInterfaceAddress = wgInterfaceAddress, WgInterfaceAddress = wgInterfaceAddress,
WgReserved = (peerDic.TryGetValue("Reserved", out var reserved) ? reserved : string.Empty).NullIfEmpty(), WgReserved = (peerDic.TryGetValue("Reserved", out var reserved) ? reserved : string.Empty).NullIfEmpty(),
WgMtu = wgMtu > 0 ? wgMtu : null, WgMtu = wgMtu > 0 ? wgMtu : null,
WgDns = wgDns,
}; };
var item = new ProfileItem var item = new ProfileItem
@@ -82,7 +82,7 @@ public static class SubscriptionHandler
private static DownloadService CreateDownloadHandler(string hashCode, Func<bool, string, Task> updateFunc) private static DownloadService CreateDownloadHandler(string hashCode, Func<bool, string, Task> updateFunc)
{ {
var downloadHandle = new DownloadService(); var downloadHandle = new DownloadService { AcceptHeader = "*/*" };
downloadHandle.Error += (sender2, args) => downloadHandle.Error += (sender2, args) =>
{ {
updateFunc?.Invoke(false, $"{hashCode}{args.GetException().Message}"); updateFunc?.Invoke(false, $"{hashCode}{args.GetException().Message}");
+117 -41
View File
@@ -55,8 +55,6 @@ public class DownloaderHelper
await using var stream = await downloader.DownloadFileTaskAsync(address: url, cts.Token); await using var stream = await downloader.DownloadFileTaskAsync(address: url, cts.Token);
using StreamReader reader = new(stream); using StreamReader reader = new(stream);
downloadOpt = null;
return await reader.ReadToEndAsync(cts.Token); return await reader.ReadToEndAsync(cts.Token);
} }
@@ -128,72 +126,150 @@ public class DownloaderHelper
using var cts = new CancellationTokenSource(); using var cts = new CancellationTokenSource();
cts.CancelAfter(TimeSpan.FromSeconds(timeout)); cts.CancelAfter(TimeSpan.FromSeconds(timeout));
await using var stream = await downloader.DownloadFileTaskAsync(address: url, cts.Token); await using var stream = await downloader.DownloadFileTaskAsync(address: url, cts.Token);
downloadOpt = null;
} }
public async Task DownloadFileAsync(IWebProxy? webProxy, string url, string fileName, IProgress<double> progress, int timeout) public async Task DownloadFileAsync(IWebProxy? webProxy, FileDownloadRequest request, Action<FileDownloadState> onProgress, TimeSpan connectTimeout, CancellationToken cancellationToken = default)
{ {
if (url.IsNullOrEmpty()) ArgumentNullException.ThrowIfNull(request);
if (request.FilePath.IsNullOrEmpty())
{ {
throw new ArgumentNullException(nameof(url)); throw new ArgumentNullException(nameof(request.FilePath));
} }
if (fileName.IsNullOrEmpty()) if (File.Exists(request.FilePath))
{ {
throw new ArgumentNullException(nameof(fileName)); File.Delete(request.FilePath);
}
if (File.Exists(fileName))
{
File.Delete(fileName);
} }
var connectTimeout = Math.Clamp(timeout / 5, 2, 5); var state = new FileDownloadState
{
Request = request,
};
var requestConfiguration = new RequestConfiguration() var requestConfiguration = new RequestConfiguration()
{ {
ConnectTimeout = connectTimeout * 1000, ConnectTimeout = (int)connectTimeout.TotalMilliseconds,
Proxy = webProxy Proxy = webProxy,
}; };
var downloadOpt = new DownloadConfiguration() var downloadOpt = new DownloadConfiguration()
{ {
BlockTimeout = timeout * 1000, ChunkCount = 100,
MaxTryAgainOnFailure = 2, MinimumChunkSize = 8 * 1024 * 1024, // 8 MB
MinimumSizeOfChunking = 8 * 1024 * 1024, // 8 MB
ParallelDownload = true,
ParallelCount = 4,
RequestConfiguration = requestConfiguration, RequestConfiguration = requestConfiguration,
CustomHttpMessageHandlerFactory = () => GetSocketsHttpHandler(requestConfiguration), CustomHttpMessageHandlerFactory = () => GetSocketsHttpHandler(requestConfiguration),
}; };
var progressPercentage = 0;
var hasValue = false;
await using var downloader = new Downloader.DownloadService(downloadOpt); await using var downloader = new Downloader.DownloadService(downloadOpt);
downloader.DownloadStarted += (sender, value) => progress?.Report(0); downloader.DownloadStarted += (sender, value) =>
{
state = state with
{
TotalBytes = value.TotalBytesToReceive,
};
onProgress.Invoke(state);
};
downloader.DownloadProgressChanged += (sender, value) => downloader.DownloadProgressChanged += (sender, value) =>
{ {
hasValue = true; state = state with
var percent = (int)value.ProgressPercentage;// Convert.ToInt32((totalRead * 1d) / (total * 1d) * 100);
if (progressPercentage != percent && percent % 10 == 0)
{ {
progressPercentage = percent; DownloadedBytes = value.ReceivedBytesSize,
progress.Report(percent); TotalBytes = value.TotalBytesToReceive,
} SpeedBytesPerSecond = value.BytesPerSecondSpeed,
};
onProgress.Invoke(state);
}; };
downloader.DownloadFileCompleted += (sender, value) => downloader.DownloadFileCompleted += (sender, value) =>
{ {
if (progress != null) state = state with
{ {
if (hasValue && value.Error == null) Completed = true,
{ Error = value.Error,
progress.Report(101); };
} onProgress.Invoke(state);
else if (value.Error != null)
{
throw value.Error;
}
}
}; };
using var cts = new CancellationTokenSource(); await downloader.DownloadFileTaskAsync(request.FileUrl, request.FilePath, cancellationToken);
await downloader.DownloadFileTaskAsync(url, fileName, cts.Token); }
downloadOpt = null; public async Task DownloadSmallFilesAsync(IWebProxy? webProxy, List<FileDownloadRequest> requests, Action<ReadOnlyMemory<FileDownloadState>> onProgress, TimeSpan connectTimeout, CancellationToken cancellationToken = default)
{
if (requests is not { Count: > 0 })
{
throw new ArgumentNullException(nameof(requests));
}
var states = new FileDownloadState[requests.Count];
for (var i = 0; i < requests.Count; i++)
{
states[i] = new FileDownloadState
{
Request = requests[i],
};
}
var readOnlyStates = new ReadOnlyMemory<FileDownloadState>(states);
var requestConfiguration = new RequestConfiguration()
{
ConnectTimeout = (int)connectTimeout.TotalMilliseconds,
Proxy = webProxy,
KeepAlive = true,
};
using var socketsHttpHandler = GetSocketsHttpHandler(requestConfiguration);
var parallelOptions = new ParallelOptions
{
MaxDegreeOfParallelism = 4,
//CancellationToken = cancellationToken,
};
await Parallel.ForEachAsync(Enumerable.Range(0, requests.Count), parallelOptions, async (index, parallelCancellationToken) =>
{
var request = requests[index];
var downloadOpt = new DownloadConfiguration()
{
RequestConfiguration = requestConfiguration,
// ReSharper disable once AccessToDisposedClosure
CustomHttpMessageHandlerFactory = () => socketsHttpHandler,
};
await using var downloader = new Downloader.DownloadService(downloadOpt);
downloader.DownloadStarted += (sender, value) =>
{
states[index] = states[index] with
{
DownloadedBytes = 0,
TotalBytes = value.TotalBytesToReceive,
SpeedBytesPerSecond = 0,
Completed = false,
};
onProgress.Invoke(readOnlyStates);
};
downloader.DownloadProgressChanged += (sender, value) =>
{
states[index] = states[index] with
{
DownloadedBytes = value.ReceivedBytesSize,
TotalBytes = value.TotalBytesToReceive,
SpeedBytesPerSecond = value.BytesPerSecondSpeed,
Completed = false,
};
onProgress.Invoke(readOnlyStates);
};
downloader.DownloadFileCompleted += (sender, value) =>
{
var newState = states[index] with { Completed = true };
if (value.Error != null)
{
newState = newState with { Error = value.Error };
}
states[index] = newState;
onProgress.Invoke(readOnlyStates);
};
await downloader.DownloadFileTaskAsync(request.FileUrl, request.FilePath, parallelCancellationToken);
});
} }
// https://github.com/bezzad/Downloader/blob/a75a6e431acd6cbba6293f7afdcf676544a09174/src/Downloader/SocketClient.cs#L45 // https://github.com/bezzad/Downloader/blob/a75a6e431acd6cbba6293f7afdcf676544a09174/src/Downloader/SocketClient.cs#L45
@@ -213,7 +289,7 @@ public class DownloaderHelper
PooledConnectionIdleTimeout = config.KeepAliveTimeout, PooledConnectionIdleTimeout = config.KeepAliveTimeout,
PooledConnectionLifetime = Timeout.InfiniteTimeSpan, PooledConnectionLifetime = Timeout.InfiniteTimeSpan,
EnableMultipleHttp2Connections = true, EnableMultipleHttp2Connections = true,
ConnectTimeout = TimeSpan.FromMilliseconds(config.ConnectTimeout) ConnectTimeout = TimeSpan.FromMilliseconds(config.ConnectTimeout),
}; };
// Set up the SslClientAuthenticationOptions for custom certificate validation // Set up the SslClientAuthenticationOptions for custom certificate validation
@@ -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(() => private static readonly Lazy<X509Certificate2Collection> _chromeRootCerts = new(() =>
{ {
var pemText = EmbedUtils.GetEmbedText(Global.ChromeRootCertFileName); var pemText = EmbedUtils.GetEmbedText(Global.ChromeRootCertFileName);
+117 -103
View File
@@ -1,113 +1,104 @@
using static ServiceLib.Models.Dto.ClashProxies;
namespace ServiceLib.Manager; namespace ServiceLib.Manager;
public sealed class ClashApiManager public sealed class ClashApiManager
{ {
private const string _tag = "ClashApiManager";
private static readonly Lazy<ClashApiManager> instance = new(() => new()); private static readonly Lazy<ClashApiManager> instance = new(() => new());
public static ClashApiManager Instance => instance.Value; public static ClashApiManager Instance => instance.Value;
private static readonly string _tag = "ClashApiHandler"; private static string ApiUrl => $"{Global.HttpProtocol}{Global.Loopback}:{AppManager.Instance.StatePort2}";
private Dictionary<string, ProxiesItem>? _proxies;
public Dictionary<string, object> ProfileContent { get; set; }
public async Task<Tuple<ClashProxies, ClashProviders>?> GetClashProxiesAsync() public async Task<ClashItem?> GetProxies()
{ {
for (var i = 0; i < 3; i++) for (var i = 0; i < 3; i++)
{ {
var url = $"{GetApiUrl()}/proxies"; var url = $"{ApiUrl}/proxies";
var result = await HttpClientHelper.Instance.TryGetAsync(url); var resultTask = HttpClientHelper.Instance.TryGetAsync(url);
var clashProxies = JsonUtils.Deserialize<ClashProxies>(result);
var url2 = $"{GetApiUrl()}/providers/proxies"; var url2 = $"{ApiUrl}/providers/proxies";
var result2 = await HttpClientHelper.Instance.TryGetAsync(url2); 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); var clashProviders = JsonUtils.Deserialize<ClashProviders>(result2);
if (clashProxies != null || clashProviders != null) if (clashProxies is null && clashProviders is null)
{ {
_proxies = clashProxies?.proxies; await Task.Delay(2000);
return new Tuple<ClashProxies, ClashProviders>(clashProxies, clashProviders); 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; 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) return await TestProviderProxyDelay(name, providerName);
{ }
if (_proxies == null) return await TestProxyDelay(name);
{
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, "");
});
} }
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 try
{ {
var fileContent = ProfileContent; var url = $"{ApiUrl}/proxies/{Utils.UrlEncode(groupName)}";
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 headers = new Dictionary<string, string>(); var headers = new Dictionary<string, string>();
headers.Add("name", nameNode); headers.Add("name", nodeName);
await HttpClientHelper.Instance.PutAsync(url, headers); await HttpClientHelper.Instance.PutAsync(url, headers);
} }
catch (Exception ex) 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); await HttpClientHelper.Instance.PatchAsync(urlBase, headers);
} }
public async Task ClashConfigReload(string filePath) public async Task<List<string>> GetClashModes()
{ {
await ClashConnectionClose(""); var jsonNode = await GetConfig();
try if ((jsonNode?["mode-list"] ?? jsonNode?["modes"]) is not JsonArray { Count: > 0 } modesArray)
{ {
var url = $"{GetApiUrl()}/configs?force=true"; return [];
var headers = new Dictionary<string, string>();
headers.Add("path", filePath);
await HttpClientHelper.Instance.PutAsync(url, headers);
} }
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 try
{ {
var url = $"{GetApiUrl()}/connections"; var url = $"{ApiUrl}/connections";
var result = await HttpClientHelper.Instance.TryGetAsync(url); var result = await HttpClientHelper.Instance.TryGetAsync(url);
var clashConnections = JsonUtils.Deserialize<ClashConnections>(result); var clashConnections = JsonUtils.Deserialize<ClashConnections>(result);
@@ -162,11 +181,11 @@ public sealed class ClashApiManager
return null; return null;
} }
public async Task ClashConnectionClose(string id) public async Task CloseConnection(string id)
{ {
try try
{ {
var url = $"{GetApiUrl()}/connections/{id}"; var url = $"{ApiUrl}/connections/{id}";
await HttpClientHelper.Instance.DeleteAsync(url); await HttpClientHelper.Instance.DeleteAsync(url);
} }
catch (Exception ex) catch (Exception ex)
@@ -174,9 +193,4 @@ public sealed class ClashApiManager
Logging.SaveLog(_tag, ex); 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", DownloadUrlLinuxLoong64 = urlSingbox + "/download/{0}/sing-box-{1}-linux-loong64.tar.gz",
DownloadUrlOSX64 = urlSingbox + "/download/{0}/sing-box-{1}-darwin-amd64.tar.gz", DownloadUrlOSX64 = urlSingbox + "/download/{0}/sing-box-{1}-darwin-amd64.tar.gz",
DownloadUrlOSXArm64 = urlSingbox + "/download/{0}/sing-box-{1}-darwin-arm64.tar.gz", DownloadUrlOSXArm64 = urlSingbox + "/download/{0}/sing-box-{1}-darwin-arm64.tar.gz",
LockedMaxVersion = new SemanticVersion(1, 14, int.MaxValue),
Match = "sing-box", Match = "sing-box",
VersionArg = "version", VersionArg = "version",
}, },
+8 -4
View File
@@ -12,7 +12,11 @@ public class TaskManager
_config = config; _config = config;
_updateFunc = updateFunc; _updateFunc = updateFunc;
Task.Run(ScheduledTasks); _ = Task.Factory.StartNew(
ScheduledTasks,
CancellationToken.None,
TaskCreationOptions.LongRunning,
TaskScheduler.Default);
} }
private async Task ScheduledTasks() private async Task ScheduledTasks()
@@ -57,8 +61,8 @@ public class TaskManager
//Logging.SaveLog("Execute delete expired files"); //Logging.SaveLog("Execute delete expired files");
FileUtils.DeleteExpiredFiles(Utils.GetBinConfigPath(), DateTime.Now.AddHours(-1), "Test"); FileUtils.DeleteExpiredFiles(Utils.GetBinConfigPath(), DateTime.Now.AddHours(-1), "Test");
FileUtils.DeleteExpiredFiles(Utils.GetLogPath(), DateTime.Now.AddMonths(-1)); FileUtils.DeleteExpiredFiles(Utils.GetLogPath(), DateTime.Now.AddDays(-7));
FileUtils.DeleteExpiredFiles(Utils.GetTempPath(), DateTime.Now.AddMonths(-1)); FileUtils.DeleteExpiredFiles(Utils.GetTempPath(), DateTime.Now.AddDays(-7));
try try
{ {
@@ -136,7 +140,7 @@ public class TaskManager
var updateService = new UpdateService(_config, async (success, msg) => await Task.CompletedTask); 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) foreach (var msg in msgs)
{ {
await _updateFunc?.Invoke(false, msg); await _updateFunc?.Invoke(false, msg);
@@ -209,7 +209,6 @@ public class HysteriaItem
[Serializable] [Serializable]
public class ClashUIItem public class ClashUIItem
{ {
public ERuleMode RuleMode { get; set; }
public bool EnableIPv6 { get; set; } public bool EnableIPv6 { get; set; }
public bool EnableMixinContent { get; set; } public bool EnableMixinContent { get; set; }
public int ProxiesSorting { get; set; } public int ProxiesSorting { get; set; }
@@ -244,6 +243,7 @@ public class WebDavItem
public class CheckUpdateItem public class CheckUpdateItem
{ {
public bool CheckPreReleaseUpdate { get; set; } public bool CheckPreReleaseUpdate { get; set; }
public bool UpdateViaProxy { get; set; } = true;
public List<string>? SelectedCoreTypes { get; set; } public List<string>? SelectedCoreTypes { get; set; }
} }
@@ -279,6 +279,7 @@ public class SimpleDNSItem
public bool? GlobalFakeIp { get; set; } public bool? GlobalFakeIp { get; set; }
public string? FakeIPRange { get; set; } public string? FakeIPRange { get; set; }
public bool? BlockBindingQuery { get; set; } public bool? BlockBindingQuery { get; set; }
public bool? BlockAAAAQuery { get; set; }
public string? DirectDNS { get; set; } public string? DirectDNS { get; set; }
public string? RemoteDNS { get; set; } public string? RemoteDNS { get; set; }
public string? BootstrapDNS { get; set; } public string? BootstrapDNS { get; set; }
@@ -25,6 +25,10 @@ public record CoreConfigContext
public bool IsWindows { get; init; } public bool IsWindows { get; init; }
public bool IsMacOS { 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 // Generation Context
public Dictionary<object, string> CustomOutboundMap { get; init; } = new(); public Dictionary<object, string> CustomOutboundMap { get; init; } = new();
} }
@@ -16,6 +16,7 @@ public class CoreInfo
public string? DownloadUrlLinuxLoong64 { get; set; } public string? DownloadUrlLinuxLoong64 { get; set; }
public string? DownloadUrlOSX64 { get; set; } public string? DownloadUrlOSX64 { get; set; }
public string? DownloadUrlOSXArm64 { get; set; } public string? DownloadUrlOSXArm64 { get; set; }
public SemanticVersion? LockedMaxVersion { get; set; }
public string? Match { get; set; } public string? Match { get; set; }
public string? VersionArg { get; set; } public string? VersionArg { get; set; }
public bool AbsolutePath { get; set; } public bool AbsolutePath { get; set; }
@@ -8,6 +8,7 @@ public class SingboxConfig
public List<Outbound4Sbox> outbounds { get; set; } public List<Outbound4Sbox> outbounds { get; set; }
public List<Endpoints4Sbox>? endpoints { get; set; } public List<Endpoints4Sbox>? endpoints { get; set; }
public Route4Sbox route { get; set; } public Route4Sbox route { get; set; }
public List<HttpClient4Sbox>? http_clients { get; set; }
public Experimental4Sbox? experimental { get; set; } public Experimental4Sbox? experimental { get; set; }
} }
@@ -24,6 +25,7 @@ public class Dns4Sbox
public List<Server4Sbox> servers { get; set; } public List<Server4Sbox> servers { get; set; }
public List<Rule4Sbox> rules { get; set; } public List<Rule4Sbox> rules { get; set; }
public string? final { get; set; } public string? final { get; set; }
public bool? optimistic { get; set; }
public string? strategy { get; set; } public string? strategy { get; set; }
public bool? disable_cache { get; set; } public bool? disable_cache { get; set; }
public bool? disable_expire { 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_match_source { get; set; }
public bool? rule_set_ip_cidr_accept_empty { get; set; } public bool? rule_set_ip_cidr_accept_empty { get; set; }
public bool? tls_record_fragment { 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] [Serializable]
@@ -108,7 +115,6 @@ public class Inbound4Sbox
public int? mtu { get; set; } public int? mtu { get; set; }
public bool? auto_route { get; set; } public bool? auto_route { get; set; }
public bool? strict_route { get; set; } public bool? strict_route { get; set; }
public bool? endpoint_independent_nat { get; set; }
public string? stack { get; set; } public string? stack { get; set; }
public List<User4Sbox> users { get; set; } public List<User4Sbox> users { get; set; }
public List<string>? route_exclude_address { get; set; } public List<string>? route_exclude_address { get; set; }
@@ -318,10 +324,12 @@ public class Ruleset4Sbox
public string? format { get; set; } public string? format { get; set; }
public string? path { get; set; } public string? path { get; set; }
public string? url { 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 string? update_interval { get; set; }
} }
public class HttpClient4Sbox : BaseServer4Sbox;
public abstract class DialFields4Sbox public abstract class DialFields4Sbox
{ {
public string? detour { get; set; } public string? detour { get; set; }
@@ -172,6 +172,8 @@ public class Outboundsettings4Ray
public int? workers { get; set; } public int? workers { get; set; }
public int? version { get; set; } public int? version { get; set; }
public List<string>? remoteDNS { get; set; }
} }
public class WireguardPeer4Ray public class WireguardPeer4Ray
@@ -241,6 +243,7 @@ public class Dns4Ray
public List<object> servers { get; set; } public List<object> servers { get; set; }
public bool? serveStale { get; set; } public bool? serveStale { get; set; }
public bool? enableParallelQuery { get; set; } public bool? enableParallelQuery { get; set; }
public string? queryStrategy { get; set; }
public string? tag { get; set; } public string? tag { get; set; }
} }
@@ -0,0 +1,6 @@
namespace ServiceLib.Models.Dto;
public sealed record AvailabilityCheckResult(int Time, string Ip)
{
public string? GetValidIp() => Ip == Global.None ? null : Ip;
}
+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] [Serializable]
public partial class ClashProxyModel : ReactiveObject 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; } public string? Now { get; set; }
@@ -0,0 +1,22 @@
namespace ServiceLib.Models.Dto;
public record FileDownloadState
{
public required FileDownloadRequest Request { get; init; }
public long DownloadedBytes { get; init; } = 0;
public long TotalBytes { get; init; } = 0;
public double SpeedBytesPerSecond { get; init; } = 0;
public bool Completed { get; init; } = false;
public Exception? Error { get; init; }
public bool IsFailed => Error != null;
}
public record FileDownloadRequest
{
public required string FileUrl { get; init; }
public required string FilePath { get; init; }
public string? DisplayFileName { get; init; }
public string FileName => DisplayFileName ?? Path.GetFileName(FilePath);
}
+171 -103
View File
@@ -1,18 +1,20 @@
namespace ServiceLib.Models.Dto; namespace ServiceLib.Models.Dto;
public class SemanticVersion public class SemanticVersion : IEquatable<SemanticVersion>, IComparable
{ {
private readonly string? build;
private readonly int major; private readonly int major;
private readonly int minor; private readonly int minor;
private readonly int patch; private readonly int patch;
private readonly string version; private readonly string? prerelease;
private readonly string raw;
public SemanticVersion(int major, int minor, int patch) public SemanticVersion(int major, int minor, int patch)
{ {
this.major = major; this.major = major;
this.minor = minor; this.minor = minor;
this.patch = patch; this.patch = patch;
version = $"{major}.{minor}.{patch}"; raw = $"{major}.{minor}.{patch}";
} }
public SemanticVersion(string? version) public SemanticVersion(string? version)
@@ -24,26 +26,41 @@ public class SemanticVersion
major = 0; major = 0;
minor = 0; minor = 0;
patch = 0; patch = 0;
raw = $"{major}.{minor}.{patch}";
return; return;
} }
this.version = version.RemovePrefix('v'); raw = version;
var parts = this.version.Split('.'); var span = version.StartsWith("v", StringComparison.OrdinalIgnoreCase)
if (parts.Length == 2) ? 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()); case 2:
minor = int.Parse(parts.Last()); major = int.Parse(parts.First());
patch = 0; minor = int.Parse(parts.Last());
} patch = 0;
else if (parts.Length is 3 or 4) break;
{
major = int.Parse(parts[0]); case 3 or 4:
minor = int.Parse(parts[1]); major = int.Parse(parts[0]);
patch = int.Parse(parts[2]); minor = int.Parse(parts[1]);
} patch = int.Parse(parts[2]);
else break;
{
throw new ArgumentException("Invalid version string"); default:
throw new ArgumentException("Invalid version string");
} }
} }
catch catch
@@ -51,135 +68,186 @@ public class SemanticVersion
major = 0; major = 0;
minor = 0; minor = 0;
patch = 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) public override bool Equals(object? obj)
{ {
if (obj is SemanticVersion other) return obj is SemanticVersion other && Equals(other);
{
return major == other.major && minor == other.minor && patch == other.patch;
}
else
{
return false;
}
} }
public override int GetHashCode() 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() 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) public static bool operator <(SemanticVersion left, SemanticVersion right)
{ return v1.Equals(v2); } {
return left.CompareTo(right) < 0;
}
public static bool operator !=(SemanticVersion v1, SemanticVersion v2) public static bool operator >(SemanticVersion left, SemanticVersion right)
{ return !v1.Equals(v2); } {
return left.CompareTo(right) > 0;
}
public static bool operator >=(SemanticVersion v1, SemanticVersion v2) public static bool operator <=(SemanticVersion left, SemanticVersion right)
{ return v1.GreaterEquals(v2); } {
return left.CompareTo(right) <= 0;
}
public static bool operator <=(SemanticVersion v1, SemanticVersion v2) public static bool operator >=(SemanticVersion left, SemanticVersion right)
{ return v1.LessEquals(v2); } {
return left.CompareTo(right) >= 0;
}
#region Private #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 1;
{ }
return false;
} var leftSegment = leftSpan[leftEnum.Current];
else if (patch > other.patch) var rightSegment = rightSpan[rightEnum.Current];
{
return true; var segCmp = CompareSegment(leftSegment, rightSegment);
} if (segCmp != 0)
else {
{ return segCmp;
return true;
}
} }
} }
} }
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; (true, true) => leftNum.CompareTo(rightNum),
}
else if (major > other.major) (false, true) => 1,
{ (true, false) => -1,
return false;
} (false, false) => left.SequenceCompareTo(right),
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;
}
}
}
} }
#endregion Private #endregion Private
@@ -27,6 +27,7 @@ public record ProtocolExtraItem
public string? WgInterfaceAddress { get; init; } public string? WgInterfaceAddress { get; init; }
public string? WgReserved { get; init; } public string? WgReserved { get; init; }
public int? WgMtu { get; init; } public int? WgMtu { get; init; }
public string? WgDns { get; init; }
// hysteria2 // hysteria2
public string? SalamanderPass { get; init; } public string? SalamanderPass { get; init; }
+94 -13
View File
@@ -178,7 +178,7 @@ namespace ServiceLib.Resx {
} }
/// <summary> /// <summary>
/// 查找类似 Failed to import custom configuration Configuration 的本地化字符串。 /// 查找类似 Failed to import configuration 的本地化字符串。
/// </summary> /// </summary>
public static string FailedImportedCustomServer { public static string FailedImportedCustomServer {
get { get {
@@ -268,7 +268,7 @@ namespace ServiceLib.Resx {
} }
/// <summary> /// <summary>
/// 查找类似 Please browse to import Configuration configuration 的本地化字符串。 /// 查找类似 Please browse to import configuration 的本地化字符串。
/// </summary> /// </summary>
public static string FillServerAddressCustom { public static string FillServerAddressCustom {
get { get {
@@ -987,6 +987,15 @@ namespace ServiceLib.Resx {
} }
} }
/// <summary>
/// 查找类似 Latency Test 的本地化字符串。
/// </summary>
public static string menuDelaytest {
get {
return ResourceManager.GetString("menuDelaytest", resourceCulture);
}
}
/// <summary> /// <summary>
/// 查找类似 DNS Settings 的本地化字符串。 /// 查找类似 DNS Settings 的本地化字符串。
/// </summary> /// </summary>
@@ -1401,15 +1410,6 @@ namespace ServiceLib.Resx {
} }
} }
/// <summary>
/// 查找类似 Latency Test 的本地化字符串。
/// </summary>
public static string menuProxiesDelaytest {
get {
return ResourceManager.GetString("menuProxiesDelaytest", resourceCulture);
}
}
/// <summary> /// <summary>
/// 查找类似 Part Node Latency Test 的本地化字符串。 /// 查找类似 Part Node Latency Test 的本地化字符串。
/// </summary> /// </summary>
@@ -2653,7 +2653,7 @@ namespace ServiceLib.Resx {
} }
/// <summary> /// <summary>
/// 查找类似 Custom configuration Configuration imported successfully 的本地化字符串。 /// 查找类似 Custom configuration imported successfully 的本地化字符串。
/// </summary> /// </summary>
public static string SuccessfullyImportedCustomServer { public static string SuccessfullyImportedCustomServer {
get { get {
@@ -2769,6 +2769,24 @@ namespace ServiceLib.Resx {
} }
} }
/// <summary>
/// 查找类似 Block AAAA Queries 的本地化字符串。
/// </summary>
public static string TbBlockAAAAQueries {
get {
return ResourceManager.GetString("TbBlockAAAAQueries", resourceCulture);
}
}
/// <summary>
/// 查找类似 Block IPv6 queries when enabled 的本地化字符串。
/// </summary>
public static string TbBlockAAAAQueriesTips {
get {
return ResourceManager.GetString("TbBlockAAAAQueriesTips", resourceCulture);
}
}
/// <summary> /// <summary>
/// 查找类似 Block SVCB and HTTPS Queries 的本地化字符串。 /// 查找类似 Block SVCB and HTTPS Queries 的本地化字符串。
/// </summary> /// </summary>
@@ -2779,7 +2797,7 @@ namespace ServiceLib.Resx {
} }
/// <summary> /// <summary>
/// 查找类似 Block ECH and HTTP/3 availability checks when enabled 的本地化字符串。 /// 查找类似 Block ECH and HTTP/3 availability checks when enabled. Always enabled in Xray 的本地化字符串。
/// </summary> /// </summary>
public static string TbBlockSVCBHTTPSQueriesTips { public static string TbBlockSVCBHTTPSQueriesTips {
get { get {
@@ -2997,6 +3015,15 @@ namespace ServiceLib.Resx {
} }
} }
/// <summary>
/// 查找类似 DNS 的本地化字符串。
/// </summary>
public static string TbDNS {
get {
return ResourceManager.GetString("TbDNS", resourceCulture);
}
}
/// <summary> /// <summary>
/// 查找类似 DNS Hosts: (&quot;domain1 ip1 ip2&quot; per line) 的本地化字符串。 /// 查找类似 DNS Hosts: (&quot;domain1 ip1 ip2&quot; per line) 的本地化字符串。
/// </summary> /// </summary>
@@ -4212,6 +4239,15 @@ namespace ServiceLib.Resx {
} }
} }
/// <summary>
/// 查找类似 Update via proxy 的本地化字符串。
/// </summary>
public static string TbSettingsEnableUpdateViaProxy {
get {
return ResourceManager.GetString("TbSettingsEnableUpdateViaProxy", resourceCulture);
}
}
/// <summary> /// <summary>
/// 查找类似 Enable sorting Configurations by drag-n-drop (requires restart) 的本地化字符串。 /// 查找类似 Enable sorting Configurations by drag-n-drop (requires restart) 的本地化字符串。
/// </summary> /// </summary>
@@ -4509,6 +4545,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> /// <summary>
/// 查找类似 sing-box Mux Protocol 的本地化字符串。 /// 查找类似 sing-box Mux Protocol 的本地化字符串。
/// </summary> /// </summary>
@@ -5031,6 +5103,15 @@ namespace ServiceLib.Resx {
} }
} }
/// <summary>
/// 查找类似 Xray Only 的本地化字符串。
/// </summary>
public static string TbXrayOnly {
get {
return ResourceManager.GetString("TbXrayOnly", resourceCulture);
}
}
/// <summary> /// <summary>
/// 查找类似 The delay: {0} ms, {1} 的本地化字符串。 /// 查找类似 The delay: {0} ms, {1} 的本地化字符串。
/// </summary> /// </summary>
+5 -2
View File
@@ -693,6 +693,9 @@
<data name="TbSettingsEnableCheckPreReleaseUpdate" xml:space="preserve"> <data name="TbSettingsEnableCheckPreReleaseUpdate" xml:space="preserve">
<value>به روز رسانی های پیش از انتشار را بررسی شود</value> <value>به روز رسانی های پیش از انتشار را بررسی شود</value>
</data> </data>
<data name="TbSettingsEnableUpdateViaProxy" xml:space="preserve">
<value>به‌روزرسانی از طریق پروکسی</value>
</data>
<data name="TbSettingsException" xml:space="preserve"> <data name="TbSettingsException" xml:space="preserve">
<value>استثنا</value> <value>استثنا</value>
</data> </data>
@@ -1215,7 +1218,7 @@
<data name="menuModeRule" xml:space="preserve"> <data name="menuModeRule" xml:space="preserve">
<value>قانون</value> <value>قانون</value>
</data> </data>
<data name="menuProxiesDelaytest" xml:space="preserve"> <data name="menuDelaytest" xml:space="preserve">
<value>تست تأخیر</value> <value>تست تأخیر</value>
</data> </data>
<data name="menuProxiesDelaytestPart" xml:space="preserve"> <data name="menuProxiesDelaytestPart" xml:space="preserve">
@@ -1456,7 +1459,7 @@
<value>Custom DNS Enabled, This Page's Settings Invalid</value> <value>Custom DNS Enabled, This Page's Settings Invalid</value>
</data> </data>
<data name="TbBlockSVCBHTTPSQueriesTips" xml:space="preserve"> <data name="TbBlockSVCBHTTPSQueriesTips" xml:space="preserve">
<value>Block ECH and HTTP/3 availability checks when enabled</value> <value>Block ECH and HTTP/3 availability checks when enabled. Always enabled in Xray</value>
</data> </data>
<data name="FillCorrectConfigTemplateText" xml:space="preserve"> <data name="FillCorrectConfigTemplateText" xml:space="preserve">
<value>Please fill in the correct config template</value> <value>Please fill in the correct config template</value>
+2 -2
View File
@@ -1212,7 +1212,7 @@
<data name="menuModeRule" xml:space="preserve"> <data name="menuModeRule" xml:space="preserve">
<value>Règle</value> <value>Règle</value>
</data> </data>
<data name="menuProxiesDelaytest" xml:space="preserve"> <data name="menuDelaytest" xml:space="preserve">
<value>Test de latence</value> <value>Test de latence</value>
</data> </data>
<data name="menuProxiesDelaytestPart" xml:space="preserve"> <data name="menuProxiesDelaytestPart" xml:space="preserve">
@@ -1453,7 +1453,7 @@
<value>DNS personnalisé activé ; la configuration de cette page sera ignorée</value> <value>DNS personnalisé activé ; la configuration de cette page sera ignorée</value>
</data> </data>
<data name="TbBlockSVCBHTTPSQueriesTips" xml:space="preserve"> <data name="TbBlockSVCBHTTPSQueriesTips" xml:space="preserve">
<value>Une fois activé, bloque les requêtes ECH et de disponibilité HTTP/3</value> <value>Block ECH and HTTP/3 availability checks when enabled. Always enabled in Xray</value>
</data> </data>
<data name="FillCorrectConfigTemplateText" xml:space="preserve"> <data name="FillCorrectConfigTemplateText" xml:space="preserve">
<value>Veuillez saisir un modèle de configuration valide</value> <value>Veuillez saisir un modèle de configuration valide</value>
+2 -2
View File
@@ -1215,7 +1215,7 @@
<data name="menuModeRule" xml:space="preserve"> <data name="menuModeRule" xml:space="preserve">
<value>Szabály</value> <value>Szabály</value>
</data> </data>
<data name="menuProxiesDelaytest" xml:space="preserve"> <data name="menuDelaytest" xml:space="preserve">
<value>Késleltetés teszt</value> <value>Késleltetés teszt</value>
</data> </data>
<data name="menuProxiesDelaytestPart" xml:space="preserve"> <data name="menuProxiesDelaytestPart" xml:space="preserve">
@@ -1456,7 +1456,7 @@
<value>Custom DNS Enabled, This Page's Settings Invalid</value> <value>Custom DNS Enabled, This Page's Settings Invalid</value>
</data> </data>
<data name="TbBlockSVCBHTTPSQueriesTips" xml:space="preserve"> <data name="TbBlockSVCBHTTPSQueriesTips" xml:space="preserve">
<value>Block ECH and HTTP/3 availability checks when enabled</value> <value>Block ECH and HTTP/3 availability checks when enabled. Always enabled in Xray</value>
</data> </data>
<data name="FillCorrectConfigTemplateText" xml:space="preserve"> <data name="FillCorrectConfigTemplateText" xml:space="preserve">
<value>Please fill in the correct config template</value> <value>Please fill in the correct config template</value>
+2 -2
View File
@@ -1215,7 +1215,7 @@
<data name="menuModeRule" xml:space="preserve"> <data name="menuModeRule" xml:space="preserve">
<value>Aturan</value> <value>Aturan</value>
</data> </data>
<data name="menuProxiesDelaytest" xml:space="preserve"> <data name="menuDelaytest" xml:space="preserve">
<value>Uji latensi</value> <value>Uji latensi</value>
</data> </data>
<data name="menuProxiesDelaytestPart" xml:space="preserve"> <data name="menuProxiesDelaytestPart" xml:space="preserve">
@@ -1456,7 +1456,7 @@
<value>DNS kustom diaktifkan, pengaturan halaman ini tidak berlaku</value> <value>DNS kustom diaktifkan, pengaturan halaman ini tidak berlaku</value>
</data> </data>
<data name="TbBlockSVCBHTTPSQueriesTips" xml:space="preserve"> <data name="TbBlockSVCBHTTPSQueriesTips" xml:space="preserve">
<value>Blokir pemeriksaan ketersediaan ECH dan HTTP/3 saat diaktifkan</value> <value>Block ECH and HTTP/3 availability checks when enabled. Always enabled in Xray</value>
</data> </data>
<data name="FillCorrectConfigTemplateText" xml:space="preserve"> <data name="FillCorrectConfigTemplateText" xml:space="preserve">
<value>Isi template konfigurasi yang benar</value> <value>Isi template konfigurasi yang benar</value>
+32 -5
View File
@@ -142,7 +142,7 @@
<value>Failed to get the default configuration</value> <value>Failed to get the default configuration</value>
</data> </data>
<data name="FailedImportedCustomServer" xml:space="preserve"> <data name="FailedImportedCustomServer" xml:space="preserve">
<value>Failed to import custom configuration Configuration</value> <value>Failed to import configuration</value>
</data> </data>
<data name="FailedReadConfiguration" xml:space="preserve"> <data name="FailedReadConfiguration" xml:space="preserve">
<value>Failed to read configuration file</value> <value>Failed to read configuration file</value>
@@ -283,7 +283,7 @@
<value>Configuration successful. {0}</value> <value>Configuration successful. {0}</value>
</data> </data>
<data name="SuccessfullyImportedCustomServer" xml:space="preserve"> <data name="SuccessfullyImportedCustomServer" xml:space="preserve">
<value>Custom configuration Configuration imported successfully</value> <value>Custom configuration imported successfully</value>
</data> </data>
<data name="SuccessfullyImportedServerViaClipboard" xml:space="preserve"> <data name="SuccessfullyImportedServerViaClipboard" xml:space="preserve">
<value>{0} Configurations have been imported from clipboard</value> <value>{0} Configurations have been imported from clipboard</value>
@@ -385,7 +385,7 @@
<value>All</value> <value>All</value>
</data> </data>
<data name="FillServerAddressCustom" xml:space="preserve"> <data name="FillServerAddressCustom" xml:space="preserve">
<value>Please browse to import Configuration configuration</value> <value>Please browse to import configuration</value>
</data> </data>
<data name="Speedtesting" xml:space="preserve"> <data name="Speedtesting" xml:space="preserve">
<value>Testing...</value> <value>Testing...</value>
@@ -693,6 +693,9 @@
<data name="TbSettingsEnableCheckPreReleaseUpdate" xml:space="preserve"> <data name="TbSettingsEnableCheckPreReleaseUpdate" xml:space="preserve">
<value>Check for pre-release</value> <value>Check for pre-release</value>
</data> </data>
<data name="TbSettingsEnableUpdateViaProxy" xml:space="preserve">
<value>Update via proxy</value>
</data>
<data name="TbSettingsException" xml:space="preserve"> <data name="TbSettingsException" xml:space="preserve">
<value>Exception</value> <value>Exception</value>
</data> </data>
@@ -1020,6 +1023,15 @@
<data name="TbSettingsMux4SboxProtocol" xml:space="preserve"> <data name="TbSettingsMux4SboxProtocol" xml:space="preserve">
<value>sing-box Mux Protocol</value> <value>sing-box Mux Protocol</value>
</data> </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"> <data name="TbRoutingRuleProcess" xml:space="preserve">
<value>Process (Linux/Windows)</value> <value>Process (Linux/Windows)</value>
</data> </data>
@@ -1221,7 +1233,7 @@
<data name="menuModeRule" xml:space="preserve"> <data name="menuModeRule" xml:space="preserve">
<value>Rule</value> <value>Rule</value>
</data> </data>
<data name="menuProxiesDelaytest" xml:space="preserve"> <data name="menuDelaytest" xml:space="preserve">
<value>Latency Test</value> <value>Latency Test</value>
</data> </data>
<data name="menuProxiesDelaytestPart" xml:space="preserve"> <data name="menuProxiesDelaytestPart" xml:space="preserve">
@@ -1462,7 +1474,7 @@
<value>Custom DNS Enabled, This Page's Settings Invalid</value> <value>Custom DNS Enabled, This Page's Settings Invalid</value>
</data> </data>
<data name="TbBlockSVCBHTTPSQueriesTips" xml:space="preserve"> <data name="TbBlockSVCBHTTPSQueriesTips" xml:space="preserve">
<value>Block ECH and HTTP/3 availability checks when enabled</value> <value>Block ECH and HTTP/3 availability checks when enabled. Always enabled in Xray</value>
</data> </data>
<data name="FillCorrectConfigTemplateText" xml:space="preserve"> <data name="FillCorrectConfigTemplateText" xml:space="preserve">
<value>Please fill in the correct config template</value> <value>Please fill in the correct config template</value>
@@ -1860,4 +1872,19 @@ The "Get Certificate" action may fail if a self-signed certificate is used or if
<data name="LvCustomCoreType" xml:space="preserve"> <data name="LvCustomCoreType" xml:space="preserve">
<value>Custom config core</value> <value>Custom config core</value>
</data> </data>
<data name="TbXrayOnly" xml:space="preserve">
<value>Xray Only</value>
</data>
<data name="TbBlockAAAAQueries" xml:space="preserve">
<value>Block AAAA Queries</value>
</data>
<data name="TbBlockAAAAQueriesTips" xml:space="preserve">
<value>Block IPv6 queries when enabled</value>
</data>
<data name="TbDNS" xml:space="preserve">
<value>DNS</value>
</data>
<data name="TbSettingsMux4Ray" xml:space="preserve">
<value>Xray Mux setting</value>
</data>
</root> </root>
+73 -34
View File
@@ -268,7 +268,7 @@
<value>Сначала выберите сервер</value> <value>Сначала выберите сервер</value>
</data> </data>
<data name="RemoveDuplicateServerResult" xml:space="preserve"> <data name="RemoveDuplicateServerResult" xml:space="preserve">
<value>Дедупликация конфигураций завершена. Было: {0}, Стало: {1}.</value> <value>Дедупликация конфигураций завершена. Было: {0}, Стало: {1}</value>
</data> </data>
<data name="RemoveServer" xml:space="preserve"> <data name="RemoveServer" xml:space="preserve">
<value>Вы уверены, что хотите удалить сервер?</value> <value>Вы уверены, что хотите удалить сервер?</value>
@@ -1048,7 +1048,7 @@
<value>HTTP-заголовки</value> <value>HTTP-заголовки</value>
</data> </data>
<data name="TipHttpOutboundHeaders" xml:space="preserve"> <data name="TipHttpOutboundHeaders" xml:space="preserve">
<value>Пользовательские заголовки исходящего HTTP-запроса. Введите JSON-объект, значения которого — строка или массив строк.</value> <value>Пользовательские заголовки исходящего HTTP-запроса. Введите JSON-объект, значения которого — строка или массив строк</value>
</data> </data>
<data name="LvPrevProfile" xml:space="preserve"> <data name="LvPrevProfile" xml:space="preserve">
<value>Псевдоним предыдущего прокси</value> <value>Псевдоним предыдущего прокси</value>
@@ -1120,28 +1120,28 @@
<value>Длина фрагмента</value> <value>Длина фрагмента</value>
</data> </data>
<data name="TbSettingsFragmentLengthTip" xml:space="preserve"> <data name="TbSettingsFragmentLengthTip" xml:space="preserve">
<value>Диапазон размера фрагмента в байтах (например, 50-100). Первое значение не больше второго. Пусто = по умолчанию.</value> <value>Диапазон размера фрагмента в байтах (например, 50-100). Первое значение не больше второго. Пусто = по умолчанию</value>
</data> </data>
<data name="TbSettingsFragmentInterval" xml:space="preserve"> <data name="TbSettingsFragmentInterval" xml:space="preserve">
<value>Интервал фрагментации</value> <value>Интервал фрагментации</value>
</data> </data>
<data name="TbSettingsFragmentIntervalTip" xml:space="preserve"> <data name="TbSettingsFragmentIntervalTip" xml:space="preserve">
<value>Задержка между фрагментами в мс (например, 10-20). Диапазон 1-100. Первое значение не больше второго. Пусто = по умолчанию.</value> <value>Задержка между фрагментами в мс (например, 10-20). Диапазон 1-100. Первое значение не больше второго. Пусто = по умолчанию</value>
</data> </data>
<data name="TbSettingsFragmentMaxSplit" xml:space="preserve"> <data name="TbSettingsFragmentMaxSplit" xml:space="preserve">
<value>Макс. разделение</value> <value>Макс. разделение</value>
</data> </data>
<data name="TbSettingsFragmentMaxSplitTip" xml:space="preserve"> <data name="TbSettingsFragmentMaxSplitTip" xml:space="preserve">
<value>Максимальное число разделений (0 = без лимита, 0-10000). Только для Xray-core. Пусто = по умолчанию.</value> <value>Максимальное число разделений (0 = без лимита, 0-10000). Только для Xray-core. Пусто = по умолчанию</value>
</data> </data>
<data name="TbSettingsFragmentFallbackDelay" xml:space="preserve"> <data name="TbSettingsFragmentFallbackDelay" xml:space="preserve">
<value>Резервная задержка</value> <value>Резервная задержка</value>
</data> </data>
<data name="TbSettingsFragmentFallbackDelayTip" xml:space="preserve"> <data name="TbSettingsFragmentFallbackDelayTip" xml:space="preserve">
<value>Резервная задержка для TLS-фрагмента. Только для sing-box. Пусто = по умолчанию.</value> <value>Резервная задержка для TLS-фрагмента. Только для sing-box. Пусто = по умолчанию</value>
</data> </data>
<data name="FillFragmentParameterError" xml:space="preserve"> <data name="FillFragmentParameterError" xml:space="preserve">
<value>Неверный формат диапазона. Используйте 'from-to' (например, 50-100).</value> <value>Неверный формат диапазона. Используйте 'from-to' (например, 50-100)</value>
</data> </data>
<data name="TbSettingsEnableCacheFile4Sbox" xml:space="preserve"> <data name="TbSettingsEnableCacheFile4Sbox" xml:space="preserve">
<value>Включить файл кэша для sing-box (файлы наборов правил)</value> <value>Включить файл кэша для sing-box (файлы наборов правил)</value>
@@ -1221,7 +1221,7 @@
<data name="menuModeRule" xml:space="preserve"> <data name="menuModeRule" xml:space="preserve">
<value>Правила</value> <value>Правила</value>
</data> </data>
<data name="menuProxiesDelaytest" xml:space="preserve"> <data name="menuDelaytest" xml:space="preserve">
<value>Тест задержки</value> <value>Тест задержки</value>
</data> </data>
<data name="menuProxiesDelaytestPart" xml:space="preserve"> <data name="menuProxiesDelaytestPart" xml:space="preserve">
@@ -1351,7 +1351,7 @@
<value>Пароль sudo системы</value> <value>Пароль sudo системы</value>
</data> </data>
<data name="TbSettingsLinuxSudoPasswordTip" xml:space="preserve"> <data name="TbSettingsLinuxSudoPasswordTip" xml:space="preserve">
<value>Пароль sudo будет проверен в терминале. Если из-за ошибки проверки приложение начнёт работать некорректно, перезапустите его. Пароль не сохраняется — его нужно вводить после каждого перезапуска.</value> <value>Пароль sudo будет проверен в терминале. Если из-за ошибки проверки приложение начнёт работать некорректно, перезапустите его. Пароль не сохраняется — его нужно вводить после каждого перезапуска</value>
</data> </data>
<data name="TransportHeaderType5" xml:space="preserve"> <data name="TransportHeaderType5" xml:space="preserve">
<value>XHTTP-режим</value> <value>XHTTP-режим</value>
@@ -1411,7 +1411,7 @@
<value>Можно указать псевдоним из конфигурации, убедитесь, что он существует и уникален</value> <value>Можно указать псевдоним из конфигурации, убедитесь, что он существует и уникален</value>
</data> </data>
<data name="SudoIncorrectPasswordTip" xml:space="preserve"> <data name="SudoIncorrectPasswordTip" xml:space="preserve">
<value>Неверный пароль, попробуйте ещё раз.</value> <value>Неверный пароль, попробуйте ещё раз</value>
</data> </data>
<data name="TbMldsa65Verify" xml:space="preserve"> <data name="TbMldsa65Verify" xml:space="preserve">
<value>Mldsa65Verify</value> <value>Mldsa65Verify</value>
@@ -1462,7 +1462,7 @@
<value>Включён пользовательский DNS — настройки на этой странице не применяются</value> <value>Включён пользовательский DNS — настройки на этой странице не применяются</value>
</data> </data>
<data name="TbBlockSVCBHTTPSQueriesTips" xml:space="preserve"> <data name="TbBlockSVCBHTTPSQueriesTips" xml:space="preserve">
<value>При включении блокирует проверки доступности ECH и HTTP/3</value> <value>При включении блокирует запросы доступности ECH и HTTP/3. В Xray включено всегда</value>
</data> </data>
<data name="FillCorrectConfigTemplateText" xml:space="preserve"> <data name="FillCorrectConfigTemplateText" xml:space="preserve">
<value>Пожалуйста, заполните корректный шаблон конфигурации</value> <value>Пожалуйста, заполните корректный шаблон конфигурации</value>
@@ -1492,7 +1492,7 @@
<value>Добавляет только конфигурацию Outbound и Endpoint. Нажмите, чтобы открыть документ</value> <value>Добавляет только конфигурацию Outbound и Endpoint. Нажмите, чтобы открыть документ</value>
</data> </data>
<data name="TbFullConfigTemplateDesc" xml:space="preserve"> <data name="TbFullConfigTemplateDesc" xml:space="preserve">
<value>Эта функция предназначена для продвинутых пользователей и особых случаев. После включения игнорируются базовые настройки ядра, DNS и маршрутизации. Вы должны самостоятельно корректно задать порт системного прокси, учёт трафика и другие связанные параметры — всё настраивается вручную.</value> <value>Эта функция предназначена для продвинутых пользователей и особых случаев. После включения игнорируются базовые настройки ядра, DNS и маршрутизации. Вы должны самостоятельно корректно задать порт системного прокси, учёт трафика и другие связанные параметры — всё настраивается вручную</value>
</data> </data>
<data name="MsgStartParsingSubscription" xml:space="preserve"> <data name="MsgStartParsingSubscription" xml:space="preserve">
<value>Начинается разбор и обработка содержимого подписки</value> <value>Начинается разбор и обработка содержимого подписки</value>
@@ -1501,7 +1501,7 @@
<value>Выбрать профиль</value> <value>Выбрать профиль</value>
</data> </data>
<data name="TbFakeIPTips" xml:space="preserve"> <data name="TbFakeIPTips" xml:space="preserve">
<value>Applies globally by default, and built-in FakeIP filtering is only built into sing-box.</value> <value>Применяется глобально по умолчанию; встроенная фильтрация FakeIP есть только в sing-box</value>
</data> </data>
<data name="PleaseAddAtLeastOneServer" xml:space="preserve"> <data name="PleaseAddAtLeastOneServer" xml:space="preserve">
<value>Добавьте хотя бы одну конфигурацию</value> <value>Добавьте хотя бы одну конфигурацию</value>
@@ -1588,7 +1588,7 @@
<value>Привязанный сертификат (заполните любое из полей) <value>Привязанный сертификат (заполните любое из полей)
При указании сертификат будет привязан, а «Пропустить проверку сертификата» отключится. При указании сертификат будет привязан, а «Пропустить проверку сертификата» отключится.
Получение сертификата может завершиться неудачей при использовании самоподписанного сертификата или при наличии ненадёжного / вредоносного ЦС в системе.</value> Получение сертификата может завершиться неудачей при использовании самоподписанного сертификата или при наличии ненадёжного / вредоносного ЦС в системе</value>
</data> </data>
<data name="TbFetchCert" xml:space="preserve"> <data name="TbFetchCert" xml:space="preserve">
<value>Получить сертификат</value> <value>Получить сертификат</value>
@@ -1642,10 +1642,10 @@
<value>По умолчанию используется только при разрешении имён в процессе маршрутизации; убедитесь, что удалённый сервер может достичь этого DNS</value> <value>По умолчанию используется только при разрешении имён в процессе маршрутизации; убедитесь, что удалённый сервер может достичь этого DNS</value>
</data> </data>
<data name="TbDirectResolveStrategyTips" xml:space="preserve"> <data name="TbDirectResolveStrategyTips" xml:space="preserve">
<value>Если не задано или «AsIs», используется системный DNS; иначе — встроенный DNS-модуль.</value> <value>Если не задано или «AsIs», используется системный DNS; иначе — встроенный DNS-модуль</value>
</data> </data>
<data name="TbRemoteResolveStrategyTips" xml:space="preserve"> <data name="TbRemoteResolveStrategyTips" xml:space="preserve">
<value>Если не задано или «AsIs», разрешение DNS выполняется DNS удалённого сервера; иначе — встроенный DNS-модуль.</value> <value>Если не задано или «AsIs», разрешение DNS выполняется DNS удалённого сервера; иначе — встроенный DNS-модуль</value>
</data> </data>
<data name="TbHopInt7" xml:space="preserve"> <data name="TbHopInt7" xml:space="preserve">
<value>Интервал смены портов (Port Hopping)</value> <value>Интервал смены портов (Port Hopping)</value>
@@ -1660,37 +1660,37 @@
<value>Правило маршрутизации {0}, исходящий узел {1}, предупреждение: {2}</value> <value>Правило маршрутизации {0}, исходящий узел {1}, предупреждение: {2}</value>
</data> </data>
<data name="MsgRoutingRuleOutboundNodeError" xml:space="preserve"> <data name="MsgRoutingRuleOutboundNodeError" xml:space="preserve">
<value>Правило маршрутизации {0}, исходящий узел {1}, ошибка: {2}. Используется только прокси-узел.</value> <value>Правило маршрутизации {0}, исходящий узел {1}, ошибка: {2}. Используется только прокси-узел</value>
</data> </data>
<data name="MsgGroupCycleDependency" xml:space="preserve"> <data name="MsgGroupCycleDependency" xml:space="preserve">
<value>Группа {0} имеет циклическую зависимость на дочерний узел {1}. Узел пропущен.</value> <value>Группа {0} имеет циклическую зависимость на дочерний узел {1}. Узел пропущен</value>
</data> </data>
<data name="MsgGroupChildNodeWarning" xml:space="preserve"> <data name="MsgGroupChildNodeWarning" xml:space="preserve">
<value>Группа {0}: предупреждение дочернего узла {1}: {2}</value> <value>Группа {0}: предупреждение дочернего узла {1}: {2}</value>
</data> </data>
<data name="MsgGroupChildNodeError" xml:space="preserve"> <data name="MsgGroupChildNodeError" xml:space="preserve">
<value>Группа {0}: ошибка дочернего узла {1}: {2}. Узел пропущен.</value> <value>Группа {0}: ошибка дочернего узла {1}: {2}. Узел пропущен</value>
</data> </data>
<data name="MsgGroupChildGroupNodeWarning" xml:space="preserve"> <data name="MsgGroupChildGroupNodeWarning" xml:space="preserve">
<value>Группа {0}: предупреждение дочернего узла группы {1}: {2}</value> <value>Группа {0}: предупреждение дочернего узла группы {1}: {2}</value>
</data> </data>
<data name="MsgGroupChildGroupNodeError" xml:space="preserve"> <data name="MsgGroupChildGroupNodeError" xml:space="preserve">
<value>Группа {0}: ошибка дочернего узла группы {1}: {2}. Узел пропущен.</value> <value>Группа {0}: ошибка дочернего узла группы {1}: {2}. Узел пропущен</value>
</data> </data>
<data name="MsgGroupNoValidChildNode" xml:space="preserve"> <data name="MsgGroupNoValidChildNode" xml:space="preserve">
<value>У группы {0} нет допустимых дочерних узлов.</value> <value>У группы {0} нет допустимых дочерних узлов</value>
</data> </data>
<data name="MsgRoutingRuleEmptyOutboundTag" xml:space="preserve"> <data name="MsgRoutingRuleEmptyOutboundTag" xml:space="preserve">
<value>У правила маршрутизации {0} пустой исходящий тег. Используется только прокси-узел.</value> <value>У правила маршрутизации {0} пустой исходящий тег. Используется только прокси-узел</value>
</data> </data>
<data name="MsgRoutingRuleOutboundNodeNotFound" xml:space="preserve"> <data name="MsgRoutingRuleOutboundNodeNotFound" xml:space="preserve">
<value>Правило маршрутизации {0}, исходящий узел {1} не найден. Используется только прокси-узел.</value> <value>Правило маршрутизации {0}, исходящий узел {1} не найден. Используется только прокси-узел</value>
</data> </data>
<data name="MsgSubscriptionPrevProfileNotFound" xml:space="preserve"> <data name="MsgSubscriptionPrevProfileNotFound" xml:space="preserve">
<value>Предыдущий прокси подписки {0} не найден. Пропущено.</value> <value>Предыдущий прокси подписки {0} не найден. Пропущено</value>
</data> </data>
<data name="MsgSubscriptionNextProfileNotFound" xml:space="preserve"> <data name="MsgSubscriptionNextProfileNotFound" xml:space="preserve">
<value>Следующий прокси подписки {0} не найден. Пропущено.</value> <value>Следующий прокси подписки {0} не найден. Пропущено</value>
</data> </data>
<data name="menuGenGroupServer" xml:space="preserve"> <data name="menuGenGroupServer" xml:space="preserve">
<value>Сгенерировать группу политик</value> <value>Сгенерировать группу политик</value>
@@ -1756,7 +1756,7 @@
<value>Привязать интерфейс</value> <value>Привязать интерфейс</value>
</data> </data>
<data name="TbSettingsBindInterfaceTip" xml:space="preserve"> <data name="TbSettingsBindInterfaceTip" xml:space="preserve">
<value>Для среды с несколькими сетевыми интерфейсами укажите имя интерфейса для исходящих подключений. На Linux/macOS работает только при включённом TUN.</value> <value>Для среды с несколькими сетевыми интерфейсами укажите имя интерфейса для исходящих подключений. На Linux/macOS работает только при включённом TUN</value>
</data> </data>
<data name="TbPreSharedKey" xml:space="preserve"> <data name="TbPreSharedKey" xml:space="preserve">
<value>Общий ключ (PSK)</value> <value>Общий ключ (PSK)</value>
@@ -1780,13 +1780,13 @@
<value>Доступно обновление</value> <value>Доступно обновление</value>
</data> </data>
<data name="MsgAllowInsecureDeprecated" xml:space="preserve"> <data name="MsgAllowInsecureDeprecated" xml:space="preserve">
<value>Предупреждение: 1 августа 2026 г. Xray отключит пропуск проверки сертификата (allowInsecure). Как можно скорее перейдите на привязанный отпечаток сертификата (pinnedPeerCertSha256). После этой даты allowInsecure использовать будет нельзя.</value> <value>Предупреждение: 1 августа 2026 г. Xray отключит пропуск проверки сертификата (allowInsecure). Как можно скорее перейдите на привязанный отпечаток сертификата (pinnedPeerCertSha256). После этой даты allowInsecure использовать будет нельзя</value>
</data> </data>
<data name="TbRouteExcludeAddress" xml:space="preserve"> <data name="TbRouteExcludeAddress" xml:space="preserve">
<value>Адреса, исключаемые из маршрутизации</value> <value>Адреса, исключаемые из маршрутизации</value>
</data> </data>
<data name="TbRouteExcludeAddressTip" xml:space="preserve"> <data name="TbRouteExcludeAddressTip" xml:space="preserve">
<value>Разделяйте запятыми (,).</value> <value>Разделяйте запятыми (,)</value>
</data> </data>
<data name="MsgTunRouteExcludeInvalidAddress" xml:space="preserve"> <data name="MsgTunRouteExcludeInvalidAddress" xml:space="preserve">
<value>Недопустимый адрес в списке исключений маршрутизации TUN: {0}</value> <value>Недопустимый адрес в списке исключений маршрутизации TUN: {0}</value>
@@ -1798,22 +1798,22 @@
<value>Включить финальную фрагментацию (Final Fragment)</value> <value>Включить финальную фрагментацию (Final Fragment)</value>
</data> </data>
<data name="TbEnableFinalFragmentTip" xml:space="preserve"> <data name="TbEnableFinalFragmentTip" xml:space="preserve">
<value>Разбивать конец пакетов на более мелкие фрагменты при отправке. Это может влиять на пропускную способность и задержку.</value> <value>Разбивать конец пакетов на более мелкие фрагменты при отправке. Это может влиять на пропускную способность и задержку</value>
</data> </data>
<data name="TbEnabletDnsViaProxy" xml:space="preserve"> <data name="TbEnabletDnsViaProxy" xml:space="preserve">
<value>DNS через Bridge</value> <value>DNS через Bridge</value>
</data> </data>
<data name="MsgInsecureConfiguration" xml:space="preserve"> <data name="MsgInsecureConfiguration" xml:space="preserve">
<value>Обнаружена небезопасная конфигурация: AllowInsecure включён, но сертификат не предоставлен. Это может привести к атаке «человек посередине» (MITM).</value> <value>Обнаружена небезопасная конфигурация: AllowInsecure включён, но сертификат не предоставлен. Это может привести к атаке «человек посередине» (MITM)</value>
</data> </data>
<data name="TbHy2RealmUrl" xml:space="preserve"> <data name="TbHy2RealmUrl" xml:space="preserve">
<value>Realm URL</value> <value>Realm URL</value>
</data> </data>
<data name="InvalidHy2RealmUrl" xml:space="preserve"> <data name="InvalidHy2RealmUrl" xml:space="preserve">
<value>Некорректный Realm URL.</value> <value>Некорректный Realm URL</value>
</data> </data>
<data name="InvalidHttpOutboundHeaders" xml:space="preserve"> <data name="InvalidHttpOutboundHeaders" xml:space="preserve">
<value>Введите корректный JSON заголовков HTTP-запроса.</value> <value>Введите корректный JSON заголовков HTTP-запроса</value>
</data> </data>
<data name="TbHy2RealmUrlTip" xml:space="preserve"> <data name="TbHy2RealmUrlTip" xml:space="preserve">
<value>Формат: realm://&lt;token&gt;@&lt;rendezvous-host&gt;[:port]/&lt;realm-name&gt;?stun=&lt;stun-host&gt;[:port]</value> <value>Формат: realm://&lt;token&gt;@&lt;rendezvous-host&gt;[:port]/&lt;realm-name&gt;?stun=&lt;stun-host&gt;[:port]</value>
@@ -1822,12 +1822,51 @@
<value>Размер пакета Gecko (мин/макс)</value> <value>Размер пакета Gecko (мин/макс)</value>
</data> </data>
<data name="TbLegacyProtectTip" xml:space="preserve"> <data name="TbLegacyProtectTip" xml:space="preserve">
<value>Если включено, используется sing-box TUN; иначе — xray TUN.</value> <value>Если включено, используется sing-box TUN; иначе — xray TUN</value>
</data> </data>
<data name="TbRootCertificateProvider" xml:space="preserve"> <data name="TbRootCertificateProvider" xml:space="preserve">
<value>Поставщик корневых сертификатов</value> <value>Поставщик корневых сертификатов</value>
</data> </data>
<data name="TbRootCertificateProviderTip" xml:space="preserve"> <data name="TbRootCertificateProviderTip" xml:space="preserve">
<value>Применяется только к загрузкам и сетевым запросам графического интерфейса v2rayN. Не влияет на проверку сертификатов ядром.</value> <value>Применяется только к загрузкам и сетевым запросам графического интерфейса v2rayN. Не влияет на проверку сертификатов ядром</value>
</data>
<data name="TbProxyDialResolveStrategy" xml:space="preserve">
<value>Стратегия разрешения DNS при подключении к прокси</value>
</data>
<data name="TbProxyDialResolveStrategyTip" xml:space="preserve">
<value>Не рекомендуется; может вызвать петли маршрутизации</value>
</data>
<data name="TbEnableHappyEyeballs" xml:space="preserve">
<value>Включить Happy Eyeballs</value>
</data>
<data name="TbEnableHappyEyeballsTip" xml:space="preserve">
<value>Требуется стратегия UseIP. При включении одновременно устанавливаются соединения по IPv4 и IPv6 и автоматически выбирается более быстрый доступный путь</value>
</data>
<data name="TbIpv6Address" xml:space="preserve">
<value>Адрес IPv6</value>
</data>
<data name="TbIpv4Address" xml:space="preserve">
<value>Адрес IPv4</value>
</data>
<data name="menuAddCustomOutboundServer" xml:space="preserve">
<value>Добавить пользовательский outbound</value>
</data>
<data name="MsgCustomOutboundFileNotFound" xml:space="preserve">
<value>Файл пользовательского outbound {0} не найден: {1}</value>
</data>
<data name="TbCustomOutboundTip" xml:space="preserve">
<value>Для xray/sing-box поддерживается только один outbound/endpoint</value>
</data>
<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> </data>
</root> </root>
+20 -2
View File
@@ -693,6 +693,9 @@
<data name="TbSettingsEnableCheckPreReleaseUpdate" xml:space="preserve"> <data name="TbSettingsEnableCheckPreReleaseUpdate" xml:space="preserve">
<value>检查 Pre-Release</value> <value>检查 Pre-Release</value>
</data> </data>
<data name="TbSettingsEnableUpdateViaProxy" xml:space="preserve">
<value>通过代理更新</value>
</data>
<data name="TbSettingsException" xml:space="preserve"> <data name="TbSettingsException" xml:space="preserve">
<value>例外</value> <value>例外</value>
</data> </data>
@@ -1218,7 +1221,7 @@
<data name="menuModeRule" xml:space="preserve"> <data name="menuModeRule" xml:space="preserve">
<value>规则</value> <value>规则</value>
</data> </data>
<data name="menuProxiesDelaytest" xml:space="preserve"> <data name="menuDelaytest" xml:space="preserve">
<value>延迟测试</value> <value>延迟测试</value>
</data> </data>
<data name="menuProxiesDelaytestPart" xml:space="preserve"> <data name="menuProxiesDelaytestPart" xml:space="preserve">
@@ -1459,7 +1462,7 @@
<value>自定义 DNS 已启用,此页面配置将无效</value> <value>自定义 DNS 已启用,此页面配置将无效</value>
</data> </data>
<data name="TbBlockSVCBHTTPSQueriesTips" xml:space="preserve"> <data name="TbBlockSVCBHTTPSQueriesTips" xml:space="preserve">
<value>开启后将阻止 ECH 和 HTTP/3 可用性查询</value> <value>开启后将阻止 ECH 和 HTTP/3 可用性查询。Xray 固定开启</value>
</data> </data>
<data name="FillCorrectConfigTemplateText" xml:space="preserve"> <data name="FillCorrectConfigTemplateText" xml:space="preserve">
<value>请填写正确的配置模板</value> <value>请填写正确的配置模板</value>
@@ -1861,4 +1864,19 @@
<data name="LvCustomCoreType" xml:space="preserve"> <data name="LvCustomCoreType" xml:space="preserve">
<value>自定义配置核心</value> <value>自定义配置核心</value>
</data> </data>
<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>
</root> </root>
+37 -22
View File
@@ -693,6 +693,9 @@
<data name="TbSettingsEnableCheckPreReleaseUpdate" xml:space="preserve"> <data name="TbSettingsEnableCheckPreReleaseUpdate" xml:space="preserve">
<value>檢查 Pre-Release</value> <value>檢查 Pre-Release</value>
</data> </data>
<data name="TbSettingsEnableUpdateViaProxy" xml:space="preserve">
<value>透過代理更新</value>
</data>
<data name="TbSettingsException" xml:space="preserve"> <data name="TbSettingsException" xml:space="preserve">
<value>例外</value> <value>例外</value>
</data> </data>
@@ -1218,7 +1221,7 @@
<data name="menuModeRule" xml:space="preserve"> <data name="menuModeRule" xml:space="preserve">
<value>規則</value> <value>規則</value>
</data> </data>
<data name="menuProxiesDelaytest" xml:space="preserve"> <data name="menuDelaytest" xml:space="preserve">
<value>延遲測試</value> <value>延遲測試</value>
</data> </data>
<data name="menuProxiesDelaytestPart" xml:space="preserve"> <data name="menuProxiesDelaytestPart" xml:space="preserve">
@@ -1417,7 +1420,7 @@
<value>新增 [Anytls] 節點</value> <value>新增 [Anytls] 節點</value>
</data> </data>
<data name="TbRemoteDNS" xml:space="preserve"> <data name="TbRemoteDNS" xml:space="preserve">
<value>遠程 DNS</value> <value>遠端 DNS</value>
</data> </data>
<data name="TbDomesticDNS" xml:space="preserve"> <data name="TbDomesticDNS" xml:space="preserve">
<value>直連 DNS</value> <value>直連 DNS</value>
@@ -1429,16 +1432,16 @@
<value>代理目標解析策略</value> <value>代理目標解析策略</value>
</data> </data>
<data name="TbAddCommonDNSHosts" xml:space="preserve"> <data name="TbAddCommonDNSHosts" xml:space="preserve">
<value>新增常用 DNS Hosts</value> <value>新增常用 DNS 主機對應</value>
</data> </data>
<data name="TbFakeIP" xml:space="preserve"> <data name="TbFakeIP" xml:space="preserve">
<value>FakeIP</value> <value>FakeIP</value>
</data> </data>
<data name="TbBlockSVCBHTTPSQueries" xml:space="preserve"> <data name="TbBlockSVCBHTTPSQueries" xml:space="preserve">
<value>阻止 SVCB 和 HTTPS 查詢</value> <value>封鎖 SVCB 與 HTTPS 查詢</value>
</data> </data>
<data name="TbDNSHostsConfig" xml:space="preserve"> <data name="TbDNSHostsConfig" xml:space="preserve">
<value>DNS Hosts:(“網域名稱1 ip1 ip2” 一行一個)</value> <value>DNS 主機對應:(每行一組「網域名稱1 ip1 ip2」)</value>
</data> </data>
<data name="ThBasicDNSSettings" xml:space="preserve"> <data name="ThBasicDNSSettings" xml:space="preserve">
<value>DNS 基礎設定</value> <value>DNS 基礎設定</value>
@@ -1447,49 +1450,49 @@
<value>DNS 進階設定</value> <value>DNS 進階設定</value>
</data> </data>
<data name="TbValidateDirectExpectedIPs" xml:space="preserve"> <data name="TbValidateDirectExpectedIPs" xml:space="preserve">
<value>校驗相應地區域名 IP</value> <value>驗證區域網域名稱的 IP</value>
</data> </data>
<data name="TbValidateDirectExpectedIPsDesc" xml:space="preserve"> <data name="TbValidateDirectExpectedIPsDesc" xml:space="preserve">
<value>配置後,會對相應地區域名(如 geosite:cn - geoip:cn)的返回 IP 進行校驗,僅返回期望 IP</value> <value>設定後,系統會驗證區域網域名稱(例如 geosite:cn - geoip:cn)所傳回的 IP,並只傳回預期的 IP。</value>
</data> </data>
<data name="TbCustomDNSEnable" xml:space="preserve"> <data name="TbCustomDNSEnable" xml:space="preserve">
<value>啟用自訂 DNS</value> <value>啟用自訂 DNS</value>
</data> </data>
<data name="TbCustomDNSEnabledPageInvalid" xml:space="preserve"> <data name="TbCustomDNSEnabledPageInvalid" xml:space="preserve">
<value>自訂 DNS 已啟用,此頁面配置將無效</value> <value>已啟用自訂 DNS,此頁面的設定將不會生效</value>
</data> </data>
<data name="TbBlockSVCBHTTPSQueriesTips" xml:space="preserve"> <data name="TbBlockSVCBHTTPSQueriesTips" xml:space="preserve">
<value>開啟後將阻止 ECH 和 HTTP/3 可用性查詢</value> <value>啟用後將封鎖 ECH 與 HTTP/3 可用性查詢。Xray 固定啟用</value>
</data> </data>
<data name="FillCorrectConfigTemplateText" xml:space="preserve"> <data name="FillCorrectConfigTemplateText" xml:space="preserve">
<value>請填寫正確的配置範本</value> <value>請填寫正確的設定範本</value>
</data> </data>
<data name="menuFullConfigTemplate" xml:space="preserve"> <data name="menuFullConfigTemplate" xml:space="preserve">
<value>完整配置範本設定</value> <value>完整設定範本</value>
</data> </data>
<data name="TbFullConfigTemplateEnable" xml:space="preserve"> <data name="TbFullConfigTemplateEnable" xml:space="preserve">
<value>啟用完整配置範本</value> <value>啟用完整設定範本</value>
</data> </data>
<data name="TbRayFullConfigTemplate" xml:space="preserve"> <data name="TbRayFullConfigTemplate" xml:space="preserve">
<value>v2ray 完整配置範本</value> <value>v2ray 完整設定範本</value>
</data> </data>
<data name="TbRayFullConfigTemplateDesc" xml:space="preserve"> <data name="TbRayFullConfigTemplateDesc" xml:space="preserve">
<value>僅添加出站配置,routing.balancers 和 routing.rules.outboundTag,點擊查看文檔</value> <value>僅新增出站設定、routing.balancers 與 routing.rules.outboundTag。點選以查看說明文件</value>
</data> </data>
<data name="TbAddProxyProtocolOutboundOnly" xml:space="preserve"> <data name="TbAddProxyProtocolOutboundOnly" xml:space="preserve">
<value>不添加非代理協定出站</value> <value>不新增非代理協定出站</value>
</data> </data>
<data name="TbSetUpstreamProxyDetour" xml:space="preserve"> <data name="TbSetUpstreamProxyDetour" xml:space="preserve">
<value>設定上游代理 tag</value> <value>設定上游代理標籤</value>
</data> </data>
<data name="TbSBFullConfigTemplate" xml:space="preserve"> <data name="TbSBFullConfigTemplate" xml:space="preserve">
<value>sing-box 完整配置範本</value> <value>sing-box 完整設定範本</value>
</data> </data>
<data name="TbSBFullConfigTemplateDesc" xml:space="preserve"> <data name="TbSBFullConfigTemplateDesc" xml:space="preserve">
<value>僅添加出站和端點配置,點擊查看文檔</value> <value>僅新增出站與端點設定。點選以查看說明文件</value>
</data> </data>
<data name="TbFullConfigTemplateDesc" xml:space="preserve"> <data name="TbFullConfigTemplateDesc" xml:space="preserve">
<value>此功能供高級用戶和有特殊需求的用戶使用。 啟用此功能後,將忽略 Core 的基礎設定,DNS 設定 ,路由設定。你需要保證系統代理的埠和流量統計等功能的配置正確,一切都由你來設定。</value> <value>此功能適合進階使用者與有特殊需求的使用者。啟用後,Core 基礎設定、DNS 設定與路由設定將被忽略。請確認系統代理連接埠、流量統計及其他相關設定皆正確;所有項目都必須由您自行設定。</value>
</data> </data>
<data name="MsgStartParsingSubscription" xml:space="preserve"> <data name="MsgStartParsingSubscription" xml:space="preserve">
<value>開始解析和處理訂閱內容</value> <value>開始解析和處理訂閱內容</value>
@@ -1498,10 +1501,10 @@
<value>選擇節點</value> <value>選擇節點</value>
</data> </data>
<data name="TbFakeIPTips" xml:space="preserve"> <data name="TbFakeIPTips" xml:space="preserve">
<value>默認全局生效,僅在 sing-box 中內置 FakeIP 過濾。</value> <value>預設會套用至全域;內建 FakeIP 過濾僅支援 sing-box。</value>
</data> </data>
<data name="PleaseAddAtLeastOneServer" xml:space="preserve"> <data name="PleaseAddAtLeastOneServer" xml:space="preserve">
<value>請至少添加一個節點</value> <value>請至少新增一個設定檔</value>
</data> </data>
<data name="TbConfigTypePolicyGroup" xml:space="preserve"> <data name="TbConfigTypePolicyGroup" xml:space="preserve">
<value>策略組</value> <value>策略組</value>
@@ -1627,7 +1630,7 @@
<value>提供過期快取(Serve Stale)</value> <value>提供過期快取(Serve Stale)</value>
</data> </data>
<data name="TbParallelQuery" xml:space="preserve"> <data name="TbParallelQuery" xml:space="preserve">
<value>并行查詢</value> <value>並行查詢</value>
</data> </data>
<data name="TbDomesticDNSTips" xml:space="preserve"> <data name="TbDomesticDNSTips" xml:space="preserve">
<value>預設僅在路由期間進行解析時調用</value> <value>預設僅在路由期間進行解析時調用</value>
@@ -1849,4 +1852,16 @@
<data name="TbIpv4Address" xml:space="preserve"> <data name="TbIpv4Address" xml:space="preserve">
<value>Ipv4 位址</value> <value>Ipv4 位址</value>
</data> </data>
<data name="menuAddCustomOutboundServer" xml:space="preserve">
<value>新增自訂出站</value>
</data>
<data name="MsgCustomOutboundFileNotFound" xml:space="preserve">
<value>自訂出站 {0} 的檔案未找到:{1}</value>
</data>
<data name="TbCustomOutboundTip" xml:space="preserve">
<value>僅支援 xray/sing-box 的單一 outbound/endpoint</value>
</data>
<data name="LvCustomCoreType" xml:space="preserve">
<value>自訂設定核心</value>
</data>
</root> </root>
+2 -4
View File
@@ -17,15 +17,13 @@
"rule_set": [ "rule_set": [
"geosite-google" "geosite-google"
], ],
"server": "remote", "server": "remote"
"strategy": "prefer_ipv4"
}, },
{ {
"rule_set": [ "rule_set": [
"geosite-cn" "geosite-cn"
], ],
"server": "local", "server": "local"
"strategy": "prefer_ipv4"
} }
], ],
"final": "remote", "final": "remote",
+2 -4
View File
@@ -17,15 +17,13 @@
"rule_set": [ "rule_set": [
"geosite-google" "geosite-google"
], ],
"server": "remote", "server": "remote"
"strategy": "prefer_ipv4"
}, },
{ {
"rule_set": [ "rule_set": [
"geosite-cn" "geosite-cn"
], ],
"server": "local", "server": "local"
"strategy": "prefer_ipv4"
} }
], ],
"final": "remote", "final": "remote",
@@ -1,3 +1,6 @@
using YamlDotNet.Core;
using YamlDotNet.RepresentationModel;
namespace ServiceLib.Services.CoreConfig; namespace ServiceLib.Services.CoreConfig;
/// <summary> /// <summary>
@@ -91,17 +94,7 @@ public class CoreConfigClashService(Config config, bool isTunEnabled)
fileContent["ipv6"] = config.ClashUIItem.EnableIPv6; fileContent["ipv6"] = config.ClashUIItem.EnableIPv6;
//mode //mode
if (!fileContent.ContainsKey("mode")) fileContent.TryAdd("mode", nameof(ERuleMode.Rule));
{
fileContent["mode"] = nameof(ERuleMode.Rule).ToLower();
}
else
{
if (config.ClashUIItem.RuleMode != ERuleMode.Unchanged)
{
fileContent["mode"] = config.ClashUIItem.RuleMode.ToString().ToLower();
}
}
//enable tun mode //enable tun mode
if (isTunEnabled) if (isTunEnabled)
@@ -127,7 +120,27 @@ public class CoreConfigClashService(Config config, bool isTunEnabled)
Logging.SaveLog($"{_tag}-Mixin", ex); Logging.SaveLog($"{_tag}-Mixin", ex);
} }
// Mihomo parses plain values such as 815458e4 as floats, so quote REALITY short IDs.
var originalRealityShortIds = new List<(Dictionary<object, object> RealityOptions, string ShortId)>();
if (fileContent.GetValueOrDefault("proxies") is List<object> proxies)
{
foreach (var proxy in proxies.OfType<Dictionary<object, object>>())
{
if (proxy.GetValueOrDefault("reality-opts") is Dictionary<object, object> realityOptions
&& realityOptions.GetValueOrDefault("short-id") is string shortId
&& !shortId.StartsWith(tagYamlStr2, StringComparison.Ordinal))
{
originalRealityShortIds.Add((realityOptions, shortId));
realityOptions["short-id"] = new YamlScalarNode(shortId) { Style = ScalarStyle.DoubleQuoted };
}
}
}
var txtFileNew = YamlUtils.ToYaml(fileContent).Replace(tagYamlStr2, tagYamlStr3); var txtFileNew = YamlUtils.ToYaml(fileContent).Replace(tagYamlStr2, tagYamlStr3);
foreach (var (realityOptions, shortId) in originalRealityShortIds)
{
realityOptions["short-id"] = shortId;
}
await File.WriteAllTextAsync(fileName, txtFileNew); await File.WriteAllTextAsync(fileName, txtFileNew);
//check again //check again
if (!File.Exists(fileName)) if (!File.Exists(fileName))
@@ -136,8 +149,6 @@ public class CoreConfigClashService(Config config, bool isTunEnabled)
return ret; return ret;
} }
ClashApiManager.Instance.ProfileContent = fileContent;
ret.Msg = string.Format(ResUI.SuccessfulConfiguration, $"{node.GetSummary()}"); ret.Msg = string.Format(ResUI.SuccessfulConfiguration, $"{node.GetSummary()}");
ret.Success = true; ret.Success = true;
return ret; return ret;
@@ -125,7 +125,8 @@ public partial class CoreConfigSingboxService
fullConfigTemplateNode["outbounds"] = customOutboundsNode; fullConfigTemplateNode["outbounds"] = customOutboundsNode;
// Process endpoints // Process endpoints
if (fullConfigTemplateNode["endpoints"] is JsonArray { Count: > 0 } coreConfigEndpointsNode) var coreConfigEndpointsNode = coreConfigNode?["endpoints"] as JsonArray ?? [];
if (coreConfigEndpointsNode is { Count: > 0 })
{ {
var customEndpointsNode = fullConfigTemplateNode["endpoints"] as JsonArray ?? []; var customEndpointsNode = fullConfigTemplateNode["endpoints"] as JsonArray ?? [];
foreach (var endpoint in coreConfigEndpointsNode) foreach (var endpoint in coreConfigEndpointsNode)
@@ -17,36 +17,11 @@ public partial class CoreConfigSingboxService
GenDnsRules(); GenDnsRules();
_coreConfig.dns ??= new Dns4Sbox(); _coreConfig.dns ??= new Dns4Sbox();
_coreConfig.dns.independent_cache = true; _coreConfig.dns.optimistic = context.SimpleDnsItem.ServeStale == true ? true : null;
// final dns // final dns
var routing = context.RoutingItem; var useDirectDns = UseDirectDns();
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;
}
}
_coreConfig.dns.final = useDirectDns ? Global.SingboxDirectDNSTag : Global.SingboxRemoteDNSTag; _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) catch (Exception ex)
{ {
@@ -59,14 +34,24 @@ public partial class CoreConfigSingboxService
var simpleDnsItem = context.SimpleDnsItem; var simpleDnsItem = context.SimpleDnsItem;
var finalDns = GenBootstrapDns(); var finalDns = GenBootstrapDns();
var directDns = ParseDnsAddress(simpleDnsItem.DirectDNS ?? Global.DomainDirectDNSAddress.First()); var directDnsList = ParseDnsAddresses(simpleDnsItem.DirectDNS ?? Global.DomainDirectDNSAddress.First());
directDns.tag = Global.SingboxDirectDNSTag; for (var i = 0; i < directDnsList.Count; i++)
directDns.domain_resolver = Global.SingboxLocalDNSTag; {
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()); var remoteDnsList = ParseDnsAddresses(simpleDnsItem.RemoteDNS ?? Global.DomainRemoteDNSAddress.First());
remoteDns.tag = Global.SingboxRemoteDNSTag; for (var i = 0; i < remoteDnsList.Count; i++)
remoteDns.detour = Global.ProxyTag; {
remoteDns.domain_resolver = Global.SingboxLocalDNSTag; 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 var hostsDns = new Server4Sbox
{ {
@@ -118,26 +103,28 @@ public partial class CoreConfigSingboxService
{ {
finalDns.domain_resolver = Global.SingboxHostsDNSTag; finalDns.domain_resolver = Global.SingboxHostsDNSTag;
} }
if (remoteDns.server == host.Key) foreach (var directDns in directDnsList.Where(directDns => directDns.server == host.Key))
{
remoteDns.domain_resolver = Global.SingboxHostsDNSTag;
}
if (directDns.server == host.Key)
{ {
directDns.domain_resolver = Global.SingboxHostsDNSTag; 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 ??= new Dns4Sbox();
_coreConfig.dns.servers ??= []; _coreConfig.dns.servers ??= [];
_coreConfig.dns.servers.Add(remoteDns); _coreConfig.dns.servers.AddRange(remoteDnsList);
_coreConfig.dns.servers.Add(directDns); _coreConfig.dns.servers.AddRange(directDnsList);
_coreConfig.dns.servers.Add(hostsDns); _coreConfig.dns.servers.Add(hostsDns);
// fake ip // fake ip
if (simpleDnsItem.FakeIP == true) 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 var fakeip = new Server4Sbox
{ {
tag = Global.SingboxFakeDNSTag, tag = Global.SingboxFakeDNSTag,
@@ -164,33 +151,33 @@ public partial class CoreConfigSingboxService
_coreConfig.dns ??= new Dns4Sbox(); _coreConfig.dns ??= new Dns4Sbox();
_coreConfig.dns.rules ??= []; _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) if (context.ProtectDomainList.Count > 0)
{ {
_coreConfig.dns.rules.Add(new() _coreConfig.dns.rules.Add(new()
{ {
server = Global.SingboxDirectDNSTag, server = Global.SingboxDirectDNSTag,
strategy = Utils.DomainStrategy4Sbox(simpleDnsItem.Strategy4ProxyDial),
domain = context.ProtectDomainList.ToList(), domain = context.ProtectDomainList.ToList(),
}); });
} }
_coreConfig.dns.rules.AddRange(new[] _coreConfig.dns.rules.AddRange([
{
new Rule4Sbox new Rule4Sbox
{ {
server = Global.SingboxRemoteDNSTag, server = Global.SingboxRemoteDNSTag,
strategy = Utils.DomainStrategy4Sbox(simpleDnsItem.Strategy4Proxy), clash_mode = nameof(ERuleMode.Global),
clash_mode = nameof(ERuleMode.Global)
}, },
new Rule4Sbox new Rule4Sbox
{ {
server = Global.SingboxDirectDNSTag, 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)) foreach (var kvp in Utils.ParseHostsToDictionary(simpleDnsItem.Hosts))
{ {
@@ -199,7 +186,7 @@ public partial class CoreConfigSingboxService
{ {
continue; continue;
} }
var rule = new Rule4Sbox() var rule = new Rule4Sbox
{ {
query_type = [1, 5, 28], // A, CNAME and AAAA query_type = [1, 5, 28], // A, CNAME and AAAA
action = "predefined", action = "predefined",
@@ -262,13 +249,14 @@ public partial class CoreConfigSingboxService
{ {
query_type = [64, 65], query_type = [64, 65],
action = "predefined", action = "predefined",
rcode = "NOERROR" rcode = "NOERROR",
}); });
} }
if (simpleDnsItem.FakeIP == true && simpleDnsItem.GlobalFakeIp != false) 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; fakeipFilterRule.invert = true;
var rule4Fake = new Rule4Sbox var rule4Fake = new Rule4Sbox
{ {
@@ -282,31 +270,45 @@ public partial class CoreConfigSingboxService
{ {
query_type = [1, 28], // A and AAAA query_type = [1, 28], // A and AAAA
}, },
fakeipFilterRule fakeipFilterRule,
] ],
}; };
_coreConfig.dns.rules.Add(rule4Fake); _coreConfig.dns.rules.Add(rule4Fake);
} }
if (simpleDnsItem.BlockAAAAQuery == true)
{
_coreConfig.dns.rules.Add(new()
{
query_type = [28],
action = "predefined",
rcode = "NOERROR",
});
}
var routing = context.RoutingItem; var routing = context.RoutingItem;
if (routing == null) if (routing == null)
{ {
return; 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 rules = JsonUtils.Deserialize<List<RulesItem>>(routing.RuleSet) ?? [];
var expectedIPCidr = new List<string>(); var expectedIPCidr = new HashSet<string>();
var expectedIPsRegions = new List<string>(); var expectedIPsRegions = new HashSet<string>();
var regionName = string.Empty; var regionName = string.Empty;
if (!string.IsNullOrEmpty(simpleDnsItem?.DirectExpectedIPs)) if (!string.IsNullOrEmpty(simpleDnsItem?.DirectExpectedIPs))
{ {
var ipItems = simpleDnsItem.DirectExpectedIPs var ipItems = (Utils.String2List(simpleDnsItem.DirectExpectedIPs) ?? [])
.Split(new[] { ',', ';' }, StringSplitOptions.RemoveEmptyEntries)
.Select(s => s.Trim()) .Select(s => s.Trim())
.Where(s => !string.IsNullOrEmpty(s)) .Where(s => !string.IsNullOrEmpty(s))
.ToList(); .ToHashSet();
foreach (var ip in ipItems) foreach (var ip in ipItems)
{ {
@@ -349,35 +351,60 @@ public partial class CoreConfigSingboxService
if (item.OutboundTag == Global.DirectTag) if (item.OutboundTag == Global.DirectTag)
{ {
rule.server = Global.SingboxDirectDNSTag; Rule4Sbox? rule4ExpectedIPs = null;
rule.strategy = Utils.DomainStrategy4Sbox(simpleDnsItem.Strategy4Freedom);
if (expectedIPsRegions.Count > 0 && rule.geosite?.Count > 0 && !regionName.IsNullOrEmpty()) if (expectedIPsRegions.Count > 0 && rule.geosite?.Count > 0 && !regionName.IsNullOrEmpty())
{ {
var regionGeosite = rule.geosite.Where(g => g.EndsWith($"-{regionName}", StringComparison.OrdinalIgnoreCase) var regionGeosite = rule.geosite.Where(g =>
|| g.EndsWith($"@{regionName}", StringComparison.OrdinalIgnoreCase) g.EndsWith($"-{regionName}", StringComparison.OrdinalIgnoreCase)
|| g == regionName).ToList(); || g.EndsWith($"@{regionName}", StringComparison.OrdinalIgnoreCase)
|| g == regionName).ToList();
if (regionGeosite.Count > 0) if (regionGeosite.Count > 0)
{ {
rule.geosite.RemoveAll(regionGeosite.Contains); rule.geosite.RemoveAll(regionGeosite.Contains);
var rule4ExpectedIPs = JsonUtils.DeepCopy(rule); rule4ExpectedIPs = JsonUtils.DeepCopy(rule);
rule4ExpectedIPs.geosite = regionGeosite; 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) if (expectedIPsRegions.Count > 0)
{ {
rule4ExpectedIPs.geoip = expectedIPsRegions; expectedRule.geoip = expectedIPsRegions.ToList();
} }
if (expectedIPCidr.Count > 0) 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) else if (item.OutboundTag == Global.BlockTag)
{ {
rule.action = "predefined"; rule.action = "predefined";
rule.rcode = "NXDOMAIN"; rule.rcode = "NXDOMAIN";
_coreConfig.dns.rules.Add(rule);
} }
else else
{ {
@@ -385,15 +412,104 @@ public partial class CoreConfigSingboxService
{ {
var rule4Fake = JsonUtils.DeepCopy(rule); var rule4Fake = JsonUtils.DeepCopy(rule);
rule4Fake.server = Global.SingboxFakeDNSTag; 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; rule4Fake.rewrite_ttl = 1;
_coreConfig.dns.rules.Add(rule4Fake); _coreConfig.dns.rules.Add(rule4Fake);
} }
rule.server = Global.SingboxRemoteDNSTag; AddRules(rule, item, remoteDnsList);
rule.strategy = Utils.DomainStrategy4Sbox(simpleDnsItem.Strategy4Proxy);
} }
}
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);
}
} }
} }
@@ -422,11 +538,15 @@ public partial class CoreConfigSingboxService
var strDNS = string.Empty; var strDNS = string.Empty;
if (context.IsTunEnabled) 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 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); var dns4Sbox = JsonUtils.Deserialize<Dns4Sbox>(strDNS);
@@ -454,15 +574,18 @@ public partial class CoreConfigSingboxService
dns4Sbox.rules.Insert(0, new() dns4Sbox.rules.Insert(0, new()
{ {
server = tag, server = tag,
clash_mode = nameof(ERuleMode.Direct) clash_mode = nameof(ERuleMode.Direct),
}); });
dns4Sbox.rules.Insert(0, new() dns4Sbox.rules.Insert(0, new()
{ {
server = dns4Sbox.servers.Where(t => t.detour == Global.ProxyTag).Select(t => t.tag).FirstOrDefault() ?? "remote", server = dns4Sbox.servers.Where(t => t.detour == Global.ProxyTag).Select(t => t.tag).FirstOrDefault() ??
clash_mode = nameof(ERuleMode.Global) "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); var localDnsServer = ParseDnsAddress(finalDnsAddress);
localDnsServer.tag = tag; localDnsServer.tag = tag;
@@ -490,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) 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)) if (string.IsNullOrEmpty(addressFirst))
{ {
return null; return null;
@@ -537,10 +672,33 @@ public partial class CoreConfigSingboxService
{ {
server.server_port = port; 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; server.path = path;
} }
return server; 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;
}
} }
@@ -21,26 +21,27 @@ public partial class CoreConfigSingboxService
}; };
_coreConfig.inbounds.Add(inbound); _coreConfig.inbounds.Add(inbound);
var inboundConf = _config.Inbound.First();
inbound.listen_port = listenPort; inbound.listen_port = listenPort;
if (_config.Inbound.First().SecondLocalPortEnabled) if (inboundConf.SecondLocalPortEnabled)
{ {
var inbound2 = BuildInbound(inbound, EInboundProtocol.socks2, true); var inbound2 = BuildInbound(inbound, EInboundProtocol.socks2, true);
_coreConfig.inbounds.Add(inbound2); _coreConfig.inbounds.Add(inbound2);
} }
if (_config.Inbound.First().AllowLANConn) if (inboundConf.AllowLANConn)
{ {
if (_config.Inbound.First().NewPort4LAN) if (inboundConf.NewPort4LAN)
{ {
var inbound3 = BuildInbound(inbound, EInboundProtocol.socks3, true); var inbound3 = BuildInbound(inbound, EInboundProtocol.socks3, true);
inbound3.listen = listen; inbound3.listen = listen;
_coreConfig.inbounds.Add(inbound3); _coreConfig.inbounds.Add(inbound3);
//auth //auth
if (_config.Inbound.First().User.IsNotEmpty() && _config.Inbound.First().Pass.IsNotEmpty()) if (inboundConf.User.IsNotEmpty() && inboundConf.Pass.IsNotEmpty())
{ {
inbound3.users = new() { new() { username = _config.Inbound.First().User, password = _config.Inbound.First().Pass } }; inbound3.users = new() { new() { username = inboundConf.User, password = inboundConf.Pass } };
} }
} }
else else
@@ -48,12 +48,18 @@ public partial class CoreConfigSingboxService
// packet straight back into the TUN, which hands it to the outbound again - // packet straight back into the TUN, which hands it to the outbound again -
// an infinite loop that pins a CPU core. Drop instead of rejecting so no // an infinite loop that pins a CPU core. Drop instead of rejecting so no
// ICMP unreachable is generated back towards the same addresses. // ICMP unreachable is generated back towards the same addresses.
//
// Match each address on its own, not the prefix it carries. On Linux sing-tun
// registers Inet4Address[0].Addr().Next() with systemd-resolved as a "~." DNS
// upstream, and every prefix offered here is a /30 or /126, so carrying the
// prefix through would cover that resolver address too and drop every system
// name lookup along with the loop.
var tunAddresses = _coreConfig.inbounds.FirstOrDefault(i => i.type == "tun")?.address; var tunAddresses = _coreConfig.inbounds.FirstOrDefault(i => i.type == "tun")?.address;
if (tunAddresses?.Count > 0) if (tunAddresses?.Count > 0)
{ {
_coreConfig.route.rules.Add(new() _coreConfig.route.rules.Add(new()
{ {
ip_cidr = [.. tunAddresses], ip_cidr = [.. tunAddresses.Select(ToSingleAddressPrefix)],
action = "reject", action = "reject",
method = "drop", method = "drop",
}); });
@@ -238,7 +244,7 @@ public partial class CoreConfigSingboxService
var resolveRule = new Rule4Sbox var resolveRule = new Rule4Sbox
{ {
action = "resolve", action = "resolve",
strategy = domainStrategy strategy = domainStrategy,
}; };
if (_config.RoutingBasicItem.DomainStrategy == Global.IPOnDemand) if (_config.RoutingBasicItem.DomainStrategy == Global.IPOnDemand)
{ {
@@ -284,6 +290,14 @@ public partial class CoreConfigSingboxService
} }
} }
private static string ToSingleAddressPrefix(string address)
{
var addr = address.Split('/').First();
return IPAddress.TryParse(addr, out var ip)
? $"{addr}/{(ip.AddressFamily == AddressFamily.InterNetworkV6 ? 128 : 32)}"
: address;
}
private List<string> BuildRoutingDirectExe() private List<string> BuildRoutingDirectExe()
{ {
var directExeSet = new HashSet<string>(StringComparer.OrdinalIgnoreCase); var directExeSet = new HashSet<string>(StringComparer.OrdinalIgnoreCase);
@@ -79,6 +79,7 @@ public partial class CoreConfigSingboxService
//Add ruleset srs //Add ruleset srs
_coreConfig.route.rule_set = []; _coreConfig.route.rule_set = [];
var containRemoteRuleset = false;
foreach (var item in new HashSet<string>(ruleSets)) foreach (var item in new HashSet<string>(ruleSets))
{ {
if (item.IsNullOrEmpty()) if (item.IsNullOrEmpty())
@@ -94,11 +95,13 @@ public partial class CoreConfigSingboxService
type = "local", type = "local",
format = "binary", format = "binary",
tag = item, tag = item,
path = pathSrs path = pathSrs,
}; };
} }
else else
{ {
containRemoteRuleset = true;
var srsUrl = string.IsNullOrEmpty(_config.ConstItem.SrsSourceUrl) var srsUrl = string.IsNullOrEmpty(_config.ConstItem.SrsSourceUrl)
? Global.SingboxRulesetUrl ? Global.SingboxRulesetUrl
: _config.ConstItem.SrsSourceUrl; : _config.ConstItem.SrsSourceUrl;
@@ -109,11 +112,21 @@ public partial class CoreConfigSingboxService
format = "binary", format = "binary",
tag = item, tag = item,
url = string.Format(srsUrl, item.StartsWith(geosite) ? geosite : geoip, 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); _coreConfig.route.rule_set.Add(customRuleset);
} }
if (containRemoteRuleset)
{
_coreConfig.http_clients ??= [];
_coreConfig.http_clients.Add(new()
{
tag = Global.SingboxSrsDownloadHttpClientTag,
detour = Global.ProxyTag,
});
}
} }
} }
@@ -82,6 +82,11 @@ public partial class CoreConfigV2rayService
dnsItem.serveStale = simpleDnsItem?.ServeStale is true ? true : null; dnsItem.serveStale = simpleDnsItem?.ServeStale is true ? true : null;
dnsItem.enableParallelQuery = simpleDnsItem?.ParallelQuery is true ? true : null; dnsItem.enableParallelQuery = simpleDnsItem?.ParallelQuery is true ? true : null;
if (simpleDnsItem.BlockAAAAQuery == true)
{
dnsItem.queryStrategy = "UseIPv4";
}
// DNS routing // DNS routing
var directDnsTags = dnsItem.servers var directDnsTags = dnsItem.servers
.Select(server => .Select(server =>
@@ -154,16 +159,16 @@ public partial class CoreConfigV2rayService
var directDNSAddress = ParseDnsAddresses(simpleDNSItem?.DirectDNS, Global.DomainDirectDNSAddress.First()); var directDNSAddress = ParseDnsAddresses(simpleDNSItem?.DirectDNS, Global.DomainDirectDNSAddress.First());
var remoteDNSAddress = ParseDnsAddresses(simpleDNSItem?.RemoteDNS, Global.DomainRemoteDNSAddress.First()); var remoteDNSAddress = ParseDnsAddresses(simpleDNSItem?.RemoteDNS, Global.DomainRemoteDNSAddress.First());
var directDomainList = new List<string>(); var directDomainList = new HashSet<string>();
var directGeositeList = new List<string>(); var directGeositeList = new HashSet<string>();
var proxyDomainList = new List<string>(); var proxyDomainList = new HashSet<string>();
var proxyGeositeList = new List<string>(); var proxyGeositeList = new HashSet<string>();
var expectedDomainList = new List<string>(); var expectedDomainList = new HashSet<string>();
var expectedIPs = new List<string>(); var expectedIPs = new HashSet<string>();
var regionName = string.Empty; var regionName = string.Empty;
var bootstrapDNSAddress = ParseDnsAddresses(simpleDNSItem?.BootstrapDNS, Global.DomainPureIPDNSAddress.First()); var bootstrapDNSAddress = ParseDnsAddresses(simpleDNSItem?.BootstrapDNS, Global.DomainPureIPDNSAddress.First());
var dnsServerDomains = new List<string>(); var dnsServerDomains = new HashSet<string>();
foreach (var dns in directDNSAddress) foreach (var dns in directDNSAddress)
{ {
@@ -189,15 +194,13 @@ public partial class CoreConfigV2rayService
dnsServerDomains.Add($"full:{domain}"); dnsServerDomains.Add($"full:{domain}");
} }
} }
dnsServerDomains = dnsServerDomains.Distinct().ToList();
if (!string.IsNullOrEmpty(simpleDNSItem?.DirectExpectedIPs)) if (!string.IsNullOrEmpty(simpleDNSItem?.DirectExpectedIPs))
{ {
expectedIPs = simpleDNSItem.DirectExpectedIPs expectedIPs = (Utils.String2List(simpleDNSItem.DirectExpectedIPs) ?? [])
.Split(new[] { ',', ';' }, StringSplitOptions.RemoveEmptyEntries)
.Select(s => s.Trim()) .Select(s => s.Trim())
.Where(s => !string.IsNullOrEmpty(s)) .Where(s => !string.IsNullOrEmpty(s))
.ToList(); .ToHashSet();
foreach (var region in from ip in expectedIPs foreach (var region in from ip in expectedIPs
where ip.StartsWith(Global.GeoIPPrefix, StringComparison.OrdinalIgnoreCase) where ip.StartsWith(Global.GeoIPPrefix, StringComparison.OrdinalIgnoreCase)
@@ -264,15 +267,19 @@ public partial class CoreConfigV2rayService
} }
} }
if (context.ProtectDomainList.Count > 0)
{
directDomainList.AddRange(context.ProtectDomainList);
}
dnsItem.servers ??= []; dnsItem.servers ??= [];
var directDnsTagIndex = 1; var directDnsTagIndex = 1;
if (dnsServerDomains.Count > 0)
{
AddDnsServers(bootstrapDNSAddress, dnsServerDomains);
}
if (context.ProtectDomainList.Count > 0)
{
AddDnsServers(directDNSAddress, context.ProtectDomainList, true);
}
if (simpleDNSItem.FakeIP == true) if (simpleDNSItem.FakeIP == true)
{ {
var fakeIPMatchDomain = new HashSet<string>(proxyDomainList); var fakeIPMatchDomain = new HashSet<string>(proxyDomainList);
@@ -286,7 +293,7 @@ public partial class CoreConfigV2rayService
if (fakeIPMatchDomain.Count > 0) if (fakeIPMatchDomain.Count > 0)
{ {
GenFakeDns(); GenFakeDns();
AddDnsServers(["fakedns"], fakeIPMatchDomain.ToList()); AddDnsServers(["fakedns"], fakeIPMatchDomain);
} }
} }
@@ -295,10 +302,6 @@ public partial class CoreConfigV2rayService
AddDnsServers(remoteDNSAddress, proxyGeositeList); AddDnsServers(remoteDNSAddress, proxyGeositeList);
AddDnsServers(directDNSAddress, directGeositeList, true); AddDnsServers(directDNSAddress, directGeositeList, true);
AddDnsServers(directDNSAddress, expectedDomainList, true, expectedIPs); AddDnsServers(directDNSAddress, expectedDomainList, true, expectedIPs);
if (dnsServerDomains.Count > 0)
{
AddDnsServers(bootstrapDNSAddress, dnsServerDomains);
}
var useDirectDns = false; var useDirectDns = false;
@@ -331,7 +334,7 @@ public partial class CoreConfigV2rayService
static List<string> ParseDnsAddresses(string? dnsInput, string defaultAddress) static List<string> ParseDnsAddresses(string? dnsInput, string defaultAddress)
{ {
var addresses = dnsInput?.Split(dnsInput.Contains(',') ? ',' : ';') var addresses = (Utils.String2List(dnsInput) ?? [])
.Select(addr => addr.Trim()) .Select(addr => addr.Trim())
.Where(addr => !string.IsNullOrEmpty(addr)) .Where(addr => !string.IsNullOrEmpty(addr))
.Select(addr => addr.StartsWith("dhcp", StringComparison.OrdinalIgnoreCase) ? "localhost" : addr) .Select(addr => addr.StartsWith("dhcp", StringComparison.OrdinalIgnoreCase) ? "localhost" : addr)
@@ -340,7 +343,7 @@ public partial class CoreConfigV2rayService
return addresses.Count > 0 ? addresses : new List<string> { defaultAddress }; 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 (domain, scheme, port, path) = Utils.ParseUrl(dnsAddress);
var domainFinal = dnsAddress; var domainFinal = dnsAddress;
@@ -360,13 +363,13 @@ public partial class CoreConfigV2rayService
address = domainFinal, address = domainFinal,
port = portFinal, port = portFinal,
skipFallback = true, skipFallback = true,
domains = domains.Count > 0 ? domains : null, domains = domains.Count > 0 ? domains.ToList() : null,
expectedIPs = expectedIPs?.Count > 0 ? expectedIPs : null expectedIPs = expectedIPs?.Count > 0 ? expectedIPs.ToList() : null
}; };
return dnsServer; 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) if (domains.Count <= 0)
{ {
@@ -9,37 +9,38 @@ public partial class CoreConfigV2rayService
var listen = "0.0.0.0"; var listen = "0.0.0.0";
var listenPort = AppManager.Instance.GetLocalPort(EInboundProtocol.socks); var listenPort = AppManager.Instance.GetLocalPort(EInboundProtocol.socks);
_coreConfig.inbounds = []; _coreConfig.inbounds = [];
var inbound = BuildInbound(_config.Inbound.First(), EInboundProtocol.socks, true); var inboundConf = _config.Inbound.First();
var inbound = BuildInbound(inboundConf, EInboundProtocol.socks, true);
var isUsingLocalMixedPort = _node.Address == Global.Loopback && _node.Port == listenPort; var isUsingLocalMixedPort = _node.Address == Global.Loopback && _node.Port == listenPort;
if (!context.IsTunEnabled || !isUsingLocalMixedPort) if (!context.IsTunEnabled || !isUsingLocalMixedPort)
{ {
_coreConfig.inbounds.Add(inbound); _coreConfig.inbounds.Add(inbound);
if (_config.Inbound.First().SecondLocalPortEnabled) if (inboundConf.SecondLocalPortEnabled)
{ {
var inbound2 = BuildInbound(_config.Inbound.First(), EInboundProtocol.socks2, true); var inbound2 = BuildInbound(inboundConf, EInboundProtocol.socks2, true);
_coreConfig.inbounds.Add(inbound2); _coreConfig.inbounds.Add(inbound2);
} }
if (_config.Inbound.First().AllowLANConn) if (inboundConf.AllowLANConn)
{ {
if (_config.Inbound.First().NewPort4LAN) if (inboundConf.NewPort4LAN)
{ {
var inbound3 = BuildInbound(_config.Inbound.First(), EInboundProtocol.socks3, true); var inbound3 = BuildInbound(inboundConf, EInboundProtocol.socks3, true);
inbound3.listen = listen; inbound3.listen = listen;
_coreConfig.inbounds.Add(inbound3); _coreConfig.inbounds.Add(inbound3);
// auth // auth
if (_config.Inbound.First().User.IsNotEmpty() && _config.Inbound.First().Pass.IsNotEmpty()) if (inboundConf.User.IsNotEmpty() && inboundConf.Pass.IsNotEmpty())
{ {
inbound3.settings.auth = "password"; inbound3.settings.auth = "password";
inbound3.settings.accounts = inbound3.settings.accounts =
[ [
new() new()
{ {
user = _config.Inbound.First().User, user = inboundConf.User,
pass = _config.Inbound.First().Pass, pass = inboundConf.Pass,
}, },
]; ];
@@ -66,12 +67,18 @@ public partial class CoreConfigV2rayService
var address = _config.TunModeItem.IPv4Address.NullIfEmpty() ?? Global.TunIPv4Address.First(); var address = _config.TunModeItem.IPv4Address.NullIfEmpty() ?? Global.TunIPv4Address.First();
tunInbound.settings.gateway = [address]; tunInbound.settings.gateway = [address];
tunInbound.settings.autoSystemRoutingTable = ["0.0.0.0/0"]; // 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.
// 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) if (_config.TunModeItem.EnableIPv6Address == true)
{ {
var address6 = _config.TunModeItem.IPv6Address.NullIfEmpty() ?? Global.TunIPv6Address.First(); var address6 = _config.TunModeItem.IPv6Address.NullIfEmpty() ?? Global.TunIPv6Address.First();
tunInbound.settings.gateway.Add(address6); tunInbound.settings.gateway.Add(address6);
tunInbound.settings.autoSystemRoutingTable.Add("::/0");
} }
var bindInterface = _config.CoreBasicItem.BindInterface?.TrimEx(); var bindInterface = _config.CoreBasicItem.BindInterface?.TrimEx();
@@ -80,6 +87,7 @@ public partial class CoreConfigV2rayService
tunInbound.settings.autoOutboundsInterface = bindInterface; tunInbound.settings.autoOutboundsInterface = bindInterface;
} }
tunInbound.sniffing = inbound.sniffing; tunInbound.sniffing = inbound.sniffing;
// tunInbound.sniffing.routeOnly = inbound.sniffing.routeOnly;
tunInbound.sniffing.routeOnly = true; tunInbound.sniffing.routeOnly = true;
if (_config.TunModeItem.RouteExcludeAddress is { Count: > 0 }) if (_config.TunModeItem.RouteExcludeAddress is { Count: > 0 })
@@ -91,7 +99,9 @@ public partial class CoreConfigV2rayService
.Where(x => x != null).ToList(); .Where(x => x != null).ToList();
var includeList = new List<IPNetwork2> { wholeInternet }; 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) foreach (var exclude in excludeList)
{ {
@@ -117,15 +127,8 @@ public partial class CoreConfigV2rayService
includeList = IPNetwork2.Supernet(includeList.ToArray()).ToList(); includeList = IPNetwork2.Supernet(includeList.ToArray()).ToList();
includeListV6 = IPNetwork2.Supernet(includeListV6.ToArray()).ToList(); includeListV6 = IPNetwork2.Supernet(includeListV6.ToArray()).ToList();
if (_config.TunModeItem.EnableIPv6Address) tunInbound.settings.autoSystemRoutingTable = includeList.Select(x => x.ToString())
{ .Concat(includeListV6.Select(x => x.ToString())).ToList();
tunInbound.settings.autoSystemRoutingTable = includeList.Select(x => x.ToString())
.Concat(includeListV6.Select(x => x.ToString())).ToList();
}
else
{
tunInbound.settings.autoSystemRoutingTable = includeList.Select(x => x.ToString()).ToList();
}
} }
_coreConfig.inbounds.Add(tunInbound); _coreConfig.inbounds.Add(tunInbound);
@@ -297,7 +297,8 @@ public partial class CoreConfigV2rayService
secretKey = _node.Password, secretKey = _node.Password,
reserved = Utils.String2List(protocolExtra.WgReserved)?.Select(s => s.Trim()).Select(int.Parse).ToList(), reserved = Utils.String2List(protocolExtra.WgReserved)?.Select(s => s.Trim()).Select(int.Parse).ToList(),
mtu = protocolExtra.WgMtu > 0 ? protocolExtra.WgMtu : Global.TunMtus.First(), 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 = setting;
outbound.settings.vnext = null; outbound.settings.vnext = null;
@@ -512,6 +513,7 @@ public partial class CoreConfigV2rayService
settings = new MaskSettings4Ray { value = kcpSeed }, settings = new MaskSettings4Ray { value = kcpSeed },
}); });
} }
kcpFinalmask.udp?.Reverse();
streamSettings.kcpSettings = kcpSettings; streamSettings.kcpSettings = kcpSettings;
streamSettings.finalmask = kcpFinalmask; streamSettings.finalmask = kcpFinalmask;
break; break;
@@ -666,6 +668,7 @@ public partial class CoreConfigV2rayService
version = 2, version = 2,
auth = _node.Password, auth = _node.Password,
}; };
hy2Finalmask.udp?.Reverse();
streamSettings.finalmask = hy2Finalmask; streamSettings.finalmask = hy2Finalmask;
break; break;
+82 -9
View File
@@ -11,6 +11,8 @@ public class DownloadService
public event ErrorEventHandler? Error; public event ErrorEventHandler? Error;
public string? AcceptHeader { get; init; }
private static readonly string _tag = "DownloadService"; private static readonly string _tag = "DownloadService";
/// <summary> /// <summary>
@@ -42,21 +44,88 @@ public class DownloadService
/// <summary> /// <summary>
/// Downloads a file and reports progress through events. /// Downloads a file and reports progress through events.
/// </summary> /// </summary>
public async Task DownloadFileAsync(string url, string fileName, bool blProxy, int downloadTimeout) public async Task DownloadFileAsync(FileDownloadRequest request, bool blProxy, TimeSpan connectTimeout)
{ {
try try
{ {
UpdateCompleted?.Invoke(this, new UpdateResult(false, $"{ResUI.Downloading} {url}")); UpdateCompleted?.Invoke(this, new UpdateResult(false, $"{ResUI.Downloading} {request.FileUrl}"));
var progress = new Progress<double>();
progress.ProgressChanged += (sender, value) => UpdateCompleted?.Invoke(this, new UpdateResult(value > 100, $"...{value}%"));
var webProxy = await GetWebProxy(blProxy); var webProxy = await GetWebProxy(blProxy);
await DownloaderHelper.Instance.DownloadFileAsync(webProxy, await DownloaderHelper.Instance.DownloadFileAsync(webProxy,
url, request,
fileName, OnProgress,
progress, connectTimeout);
downloadTimeout);
void OnProgress(FileDownloadState state)
{
UpdateCompleted?.Invoke(this, new UpdateResult(state.Completed, $"{Utils.HumanFy((long)state.SpeedBytesPerSecond / 1024)}/s | {Utils.HumanFy(state.DownloadedBytes / 1024)}/{Utils.HumanFy(state.TotalBytes / 1024)}"));
}
}
catch (Exception ex)
{
Logging.SaveLog(_tag, ex);
Error?.Invoke(this, new ErrorEventArgs(ex));
if (ex.InnerException != null)
{
Error?.Invoke(this, new ErrorEventArgs(ex.InnerException));
}
}
}
public async Task DownloadSmallFilesAsync(List<FileDownloadRequest> requests, bool blProxy, TimeSpan connectTimeout)
{
try
{
UpdateCompleted?.Invoke(this, new UpdateResult(false, $"{ResUI.Downloading} 0/{requests.Count}"));
var webProxy = await GetWebProxy(blProxy);
await DownloaderHelper.Instance.DownloadSmallFilesAsync(webProxy,
requests,
OnProgress,
connectTimeout);
void OnProgress(ReadOnlyMemory<FileDownloadState> states)
{
var span = states.Span;
var completedCount = 0;
var downloadingStates = new List<FileDownloadState>();
foreach (ref readonly var item in span)
{
if (item.Completed)
{
completedCount++;
}
else if (item.TotalBytes > 0)
{
downloadingStates.Add(item);
}
}
var totalSpeed = downloadingStates.Sum(x => x.SpeedBytesPerSecond);
var totalDownloadedBytes = downloadingStates.Sum(x => x.DownloadedBytes);
var totalTotalBytes = downloadingStates.Sum(x => x.TotalBytes);
var downloadingFileName = string.Join(", ", downloadingStates.Select(x => x.Request.FileName));
var allCompleted = completedCount == span.Length;
if (allCompleted)
{
// check and throw errors if any
FileDownloadState? failedState = null;
foreach (ref readonly var item in span)
{
if (!item.IsFailed)
{
continue;
}
failedState = item;
break;
}
if (failedState?.Error != null)
{
throw failedState.Error;
}
}
UpdateCompleted?.Invoke(this, new UpdateResult(allCompleted, $"{completedCount}/{span.Length} | {Utils.HumanFy((long)totalSpeed / 1024)}/s {Utils.HumanFy(totalDownloadedBytes / 1024)}/{Utils.HumanFy(totalTotalBytes / 1024)} {downloadingFileName}"));
}
} }
catch (Exception ex) catch (Exception ex)
{ {
@@ -186,6 +255,10 @@ public class DownloadService
userAgent = Utils.GetVersion(false); userAgent = Utils.GetVersion(false);
} }
client.DefaultRequestHeaders.UserAgent.TryParseAdd(userAgent); client.DefaultRequestHeaders.UserAgent.TryParseAdd(userAgent);
if (AcceptHeader.IsNotEmpty())
{
client.DefaultRequestHeaders.Accept.ParseAdd(AcceptHeader);
}
Uri uri = new(url); Uri uri = new(url);
//Authorization Header //Authorization Header
@@ -17,7 +17,11 @@ public class StatisticsSingboxService
_updateFunc = updateFunc; _updateFunc = updateFunc;
_exitFlag = false; _exitFlag = false;
_ = Task.Run(Run); _ = Task.Factory.StartNew(
Run,
CancellationToken.None,
TaskCreationOptions.LongRunning,
TaskScheduler.Default);
} }
private async Task Init() private async Task Init()
@@ -15,7 +15,11 @@ public class StatisticsXrayService
_updateFunc = updateFunc; _updateFunc = updateFunc;
_exitFlag = false; _exitFlag = false;
_ = Task.Run(Run); _ = Task.Factory.StartNew(
Run,
CancellationToken.None,
TaskCreationOptions.LongRunning,
TaskScheduler.Default);
} }
public void Close() public void Close()
+132 -95
View File
@@ -1,13 +1,13 @@
namespace ServiceLib.Services; 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 Config? _config = config;
private readonly Func<bool, string, Task>? _updateFunc = updateFunc; private readonly Func<bool, string, Task>? _updateFunc = updateFunc;
private readonly int _timeout = 30; private readonly int _timeout = 30;
private static readonly string _tag = "UpdateService"; 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 url = string.Empty;
var fileName = string.Empty; var fileName = string.Empty;
@@ -31,15 +31,15 @@ public class UpdateService(Config config, Func<bool, string, Task> updateFunc)
}; };
await UpdateFunc(false, string.Format(ResUI.MsgStartUpdating, ECoreType.v2rayN)); 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) if (result.Success)
{ {
await UpdateFunc(false, string.Format(ResUI.MsgParsingSuccessfully, ECoreType.v2rayN)); await UpdateFunc(false, string.Format(ResUI.MsgParsingSuccessfully, ECoreType.v2rayN));
await UpdateFunc(false, result.Msg); await UpdateFunc(false, result.Msg);
url = result.Url.ToString(); url = result.Url!;
fileName = Utils.GetTempPath(Utils.GetGuid()); fileName = Utils.GetTempPath(Utils.GetGuid());
await downloadHandle.DownloadFileAsync(url, fileName, true, _timeout); await downloadHandle.DownloadFileAsync(new() { FileUrl = url, FilePath = fileName }, blProxy, TimeSpan.FromSeconds(_timeout));
} }
else 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 url = string.Empty;
var fileName = string.Empty; var fileName = string.Empty;
@@ -80,16 +80,16 @@ public class UpdateService(Config config, Func<bool, string, Task> updateFunc)
}; };
await UpdateFunc(false, string.Format(ResUI.MsgStartUpdating, type)); 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) if (result.Success)
{ {
await UpdateFunc(false, string.Format(ResUI.MsgParsingSuccessfully, type)); await UpdateFunc(false, string.Format(ResUI.MsgParsingSuccessfully, type));
await UpdateFunc(false, result.Msg); await UpdateFunc(false, result.Msg);
url = result.Url.ToString(); url = result.Url!;
var ext = url.Contains(".tar.gz") ? ".tar.gz" : Path.GetExtension(url); var ext = url.Contains(".tar.gz") ? ".tar.gz" : Path.GetExtension(url);
fileName = Utils.GetTempPath(Utils.GetGuid() + ext); fileName = Utils.GetTempPath(Utils.GetGuid() + ext);
await downloadHandle.DownloadFileAsync(url, fileName, true, _timeout); await downloadHandle.DownloadFileAsync(new() { FileUrl = url, FilePath = fileName }, blProxy, TimeSpan.FromSeconds(_timeout));
} }
else 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)) if (!CoreInfoManager.Instance.IsCheckUpdateSupported(type))
{ {
@@ -109,10 +109,10 @@ public class UpdateService(Config config, Func<bool, string, Task> updateFunc)
var downloadHandle = new DownloadService(); var downloadHandle = new DownloadService();
var checkPreRelease = CoreInfoManager.Instance.GetCheckPreRelease(type, preRelease); 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>(); var msgs = new List<string>();
foreach (var type in CoreInfoManager.Instance.GetCheckUpdateCoreTypes()) foreach (var type in CoreInfoManager.Instance.GetCheckUpdateCoreTypes())
@@ -122,7 +122,7 @@ public class UpdateService(Config config, Func<bool, string, Task> updateFunc)
continue; continue;
} }
var result = await CheckHasUpdateOnly(type, preRelease); var result = await CheckHasUpdateOnly(type, preRelease, blProxy);
if (result.Success && result.Version != null) if (result.Success && result.Version != null)
{ {
var msg = string.Format(ResUI.MsgCheckUpdateHasNewVersion, type, result.Version); var msg = string.Format(ResUI.MsgCheckUpdateHasNewVersion, type, result.Version);
@@ -137,21 +137,25 @@ public class UpdateService(Config config, Func<bool, string, Task> updateFunc)
return msgs; return msgs;
} }
public async Task UpdateGeoFileAll() public async Task UpdateGeoFileAll(bool blProxy = true)
{ {
await UpdateGeoFiles(); var requests = new List<FileDownloadRequest>();
await UpdateOtherFiles(); requests.AddRange(GetGeoFilesRequest());
await UpdateSrsFileAll(); requests.AddRange(GetOtherFilesRequest());
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, blProxy);
await UpdateFunc(true, string.Format(ResUI.MsgDownloadGeoFileSuccessfully, "geo")); await UpdateFunc(true, string.Format(ResUI.MsgDownloadGeoFileSuccessfully, "geo"));
} }
#region CheckUpdate private #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 try
{ {
var result = await GetRemoteVersion(downloadHandle, type, preRelease); var result = await GetRemoteVersion(downloadHandle, type, preRelease, blProxy);
if (!result.Success || result.Version is null) if (!result.Success || result.Version is null)
{ {
return result; return result;
@@ -166,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 coreInfo = CoreInfoManager.Instance.GetCoreInfo(type);
var tagName = string.Empty; var tagName = string.Empty;
if (preRelease) if (preRelease || coreInfo?.LockedMaxVersion != null)
{ {
var url = coreInfo?.ReleaseApiUrl; 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()) if (result.IsNullOrEmpty())
{ {
return new UpdateResult(false, ""); return new UpdateResult(false, "");
@@ -183,11 +187,29 @@ public class UpdateService(Config config, Func<bool, string, Task> updateFunc)
var gitHubRelease = preRelease ? gitHubReleases?.First() : gitHubReleases?.First(r => r.Prerelease == false); var gitHubRelease = preRelease ? gitHubReleases?.First() : gitHubReleases?.First(r => r.Prerelease == false);
tagName = gitHubRelease?.TagName; tagName = gitHubRelease?.TagName;
//var body = gitHubRelease?.Body; //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 else
{ {
var url = Path.Combine(coreInfo.Url, "latest"); var url = Path.Combine(coreInfo.Url, "latest");
var lastUrl = await downloadHandle.UrlRedirectAsync(url, true); var lastUrl = await downloadHandle.UrlRedirectAsync(url, blProxy);
if (lastUrl == null) if (lastUrl == null)
{ {
return new UpdateResult(false, ""); return new UpdateResult(false, "");
@@ -198,6 +220,9 @@ public class UpdateService(Config config, Func<bool, string, Task> updateFunc)
return new UpdateResult(true, new SemanticVersion(tagName)); 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) private async Task<SemanticVersion> GetCoreVersion(ECoreType type)
{ {
try try
@@ -223,23 +248,7 @@ public class UpdateService(Config config, Func<bool, string, Task> updateFunc)
var result = await Utils.GetCliWrapOutput(filePath, coreInfo.VersionArg); var result = await Utils.GetCliWrapOutput(filePath, coreInfo.VersionArg);
var echo = result ?? ""; var echo = result ?? "";
var version = string.Empty; var version = SemVerRegex().Match(echo).Groups["version"].Value;
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;
}
return new SemanticVersion(version); return new SemanticVersion(version);
} }
catch (Exception ex) catch (Exception ex)
@@ -265,30 +274,25 @@ public class UpdateService(Config config, Func<bool, string, Task> updateFunc)
case ECoreType.v2fly: case ECoreType.v2fly:
case ECoreType.Xray: case ECoreType.Xray:
case ECoreType.v2fly_v5: 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: case ECoreType.mihomo:
{ {
curVersion = await GetCoreVersion(type); curVersion = await GetCoreVersion(type);
message = string.Format(ResUI.IsLatestCore, type, curVersion); message = string.Format(ResUI.IsLatestCore, type, curVersion.ToStandardVersionString("v"));
url = string.Format(coreUrl, version.ToVersionString("v")); url = string.Format(coreUrl, version);
break; break;
} }
case ECoreType.sing_box: case ECoreType.sing_box:
{ {
curVersion = await GetCoreVersion(type); curVersion = await GetCoreVersion(type);
message = string.Format(ResUI.IsLatestCore, type, curVersion.ToVersionString("v")); message = string.Format(ResUI.IsLatestCore, type, curVersion.ToStandardVersionString("v"));
url = string.Format(coreUrl, version.ToVersionString("v"), version); url = string.Format(coreUrl, version, version.ToString().RemovePrefix("v"));
break; break;
} }
case ECoreType.v2rayN: case ECoreType.v2rayN:
{ {
curVersion = new SemanticVersion(Utils.GetVersionInfo()); 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); url = string.Format(coreUrl, version);
break; break;
} }
@@ -296,7 +300,7 @@ public class UpdateService(Config config, Func<bool, string, Task> updateFunc)
throw new ArgumentException("Type"); 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); return new UpdateResult(false, message);
} }
@@ -363,41 +367,53 @@ public class UpdateService(Config config, Func<bool, string, Task> updateFunc)
#region Geo private #region Geo private
private async Task UpdateGeoFiles() private List<FileDownloadRequest> GetGeoFilesRequest()
{ {
var geoUrl = string.IsNullOrEmpty(_config?.ConstItem.GeoSourceUrl) var geoUrl = string.IsNullOrEmpty(_config?.ConstItem.GeoSourceUrl)
? Global.GeoUrl ? Global.GeoUrl
: _config.ConstItem.GeoSourceUrl; : _config.ConstItem.GeoSourceUrl;
List<string> files = ["geosite", "geoip"]; List<string> files = ["geosite", "geoip"];
foreach (var geoName in files) return
{ [
var fileName = $"{geoName}.dat"; .. from geoName in files
var targetPath = Utils.GetBinPath($"{fileName}"); let fileName = $"{geoName}.dat"
var url = string.Format(geoUrl, geoName); let targetPath = Utils.GetBinPath($"{fileName}")
let url = string.Format(geoUrl, geoName)
await DownloadGeoFile(url, fileName, targetPath); select new FileDownloadRequest()
} {
FileUrl = url,
FilePath = targetPath,
DisplayFileName = fileName,
},
];
} }
private async Task UpdateOtherFiles() private List<FileDownloadRequest> GetOtherFilesRequest()
{ {
//If it is not in China area, no update is required //If it is not in China area, no update is required
if (_config.ConstItem.GeoSourceUrl.IsNotEmpty()) if (_config.ConstItem.GeoSourceUrl.IsNotEmpty())
{ {
return; return [];
} }
foreach (var url in Global.OtherGeoUrls) return
{ [
var fileName = Path.GetFileName(url); .. Global.OtherGeoUrls.Select(url =>
var targetPath = Utils.GetBinPath($"{fileName}"); {
var fileName = Path.GetFileName(url);
await DownloadGeoFile(url, fileName, targetPath); var targetPath = Utils.GetBinPath($"{fileName}");
} return new FileDownloadRequest()
{
FileUrl = url,
FilePath = targetPath,
DisplayFileName = fileName,
};
}),
];
} }
private async Task UpdateSrsFileAll() private async Task<List<FileDownloadRequest>> GetSrsFileAllRequest()
{ {
var geoipFiles = new List<string>(); var geoipFiles = new List<string>();
var geoSiteFiles = new List<string>(); var geoSiteFiles = new List<string>();
@@ -432,15 +448,12 @@ public class UpdateService(Config config, Func<bool, string, Task> updateFunc)
Directory.CreateDirectory(path); Directory.CreateDirectory(path);
} }
foreach (var item in geoipFiles.Distinct()) return
{ [
await UpdateSrsFile("geoip", item); .. geoipFiles.Distinct().Select(f => (type: "geoip", file: f))
} .Concat(geoSiteFiles.Distinct().Select(f => (type: "geosite", file: f)))
.Select(item => GetSrsFileRequest(item.type, item.file)),
foreach (var item in geoSiteFiles.Distinct()) ];
{
await UpdateSrsFile("geosite", item);
}
} }
private void AddPrefixedItems(List<string>? items, string prefix, List<string> output) private void AddPrefixedItems(List<string>? items, string prefix, List<string> output)
@@ -500,7 +513,7 @@ public class UpdateService(Config config, Func<bool, string, Task> updateFunc)
} }
} }
private async Task UpdateSrsFile(string type, string srsName) private FileDownloadRequest GetSrsFileRequest(string type, string srsName)
{ {
var srsUrl = string.IsNullOrEmpty(_config.ConstItem.SrsSourceUrl) var srsUrl = string.IsNullOrEmpty(_config.ConstItem.SrsSourceUrl)
? Global.SingboxRulesetUrl ? Global.SingboxRulesetUrl
@@ -510,33 +523,57 @@ public class UpdateService(Config config, Func<bool, string, Task> updateFunc)
var targetPath = Path.Combine(Utils.GetBinPath("srss"), fileName); var targetPath = Path.Combine(Utils.GetBinPath("srss"), fileName);
var url = string.Format(srsUrl, type, $"{type}-{srsName}", srsName); var url = string.Format(srsUrl, type, $"{type}-{srsName}", srsName);
await DownloadGeoFile(url, fileName, targetPath); return new FileDownloadRequest()
{
FileUrl = url,
FilePath = targetPath,
DisplayFileName = fileName,
};
} }
private async Task DownloadGeoFile(string url, string fileName, string targetPath) private async Task DownloadGeoFiles(List<FileDownloadRequest> requests, bool blProxy)
{ {
var tmpFileName = Utils.GetTempPath(Utils.GetGuid()); var tmpFilePathDict = new Dictionary<string, string>();
var tmpFileRequests = new List<FileDownloadRequest>();
foreach (var request in requests)
{
var tmpFilePath = Utils.GetTempPath(Utils.GetGuid());
tmpFilePathDict[request.FilePath] = tmpFilePath;
tmpFileRequests.Add(request with
{
FilePath = tmpFilePath,
});
}
DownloadService downloadHandle = new(); DownloadService downloadHandle = new();
downloadHandle.UpdateCompleted += (sender2, args) => downloadHandle.UpdateCompleted += (sender2, args) =>
{ {
if (args.Success) if (args.Success)
{ {
_ = UpdateFunc(false, string.Format(ResUI.MsgDownloadGeoFileSuccessfully, fileName)); //_ = UpdateFunc(false, string.Format(ResUI.MsgDownloadGeoFileSuccessfully, fileName));
try foreach (var request in requests)
{ {
if (File.Exists(tmpFileName)) try
{ {
File.Copy(tmpFileName, targetPath, true); //if (File.Exists(tmpFileName))
//{
// File.Copy(tmpFileName, targetPath, true);
File.Delete(tmpFileName); // File.Delete(tmpFileName);
//await UpdateFunc(true, ""); // //await UpdateFunc(true, "");
//}
var tmpFileName = tmpFilePathDict[request.FilePath];
if (File.Exists(tmpFileName))
{
File.Copy(tmpFileName, request.FilePath, true);
File.Delete(tmpFileName);
}
}
catch (Exception ex)
{
_ = UpdateFunc(false, ex.Message);
} }
}
catch (Exception ex)
{
_ = UpdateFunc(false, ex.Message);
} }
} }
else else
@@ -549,7 +586,7 @@ public class UpdateService(Config config, Func<bool, string, Task> updateFunc)
_ = UpdateFunc(false, args.GetException().Message); _ = UpdateFunc(false, args.GetException().Message);
}; };
await downloadHandle.DownloadFileAsync(url, tmpFileName, true, _timeout); await downloadHandle.DownloadSmallFilesAsync(tmpFileRequests, blProxy, TimeSpan.FromSeconds(_timeout));
} }
#endregion Geo private #endregion Geo private
@@ -218,8 +218,7 @@ public partial class AddGroupServerViewModel : MyReactiveObject, ICloseable
public async Task UpdatePreviewList() public async Task UpdatePreviewList()
{ {
AllProfilePreviewItemsObs.Clear(); AllProfilePreviewItemsObs.ReplaceRange(await GroupProfileManager.GetChildProfileItemsByProtocolExtra(GetUpdatedProtocolExtra()));
AllProfilePreviewItemsObs.AddRange(await GroupProfileManager.GetChildProfileItemsByProtocolExtra(GetUpdatedProtocolExtra()));
} }
private async Task SaveServerAsync() private async Task SaveServerAsync()
@@ -26,7 +26,7 @@ public partial class AddServer2ViewModel : MyReactiveObject, ICloseable
BrowseServerCmd = ReactiveCommand.CreateFromTask(async () => BrowseServerCmd = ReactiveCommand.CreateFromTask(async () =>
{ {
var fileName = await BrowseConfigFileInteraction.Handle(RxVoid.Default); var fileName = await BrowseConfigFileInteraction.HandleSafe(RxVoid.Default);
if (fileName.IsNullOrEmpty()) if (fileName.IsNullOrEmpty())
{ {
return; return;
@@ -70,6 +70,9 @@ public partial class AddServerViewModel : MyReactiveObject, ICloseable
[Reactive] [Reactive]
public partial int WgMtu { get; set; } public partial int WgMtu { get; set; }
[Reactive]
public partial string WgDns { get; set; }
[Reactive] [Reactive]
public partial bool Uot { get; set; } public partial bool Uot { get; set; }
@@ -310,6 +313,7 @@ public partial class AddServerViewModel : MyReactiveObject, ICloseable
WgInterfaceAddress = protocolExtra.WgInterfaceAddress ?? string.Empty; WgInterfaceAddress = protocolExtra.WgInterfaceAddress ?? string.Empty;
WgReserved = protocolExtra.WgReserved ?? string.Empty; WgReserved = protocolExtra.WgReserved ?? string.Empty;
WgMtu = protocolExtra.WgMtu ?? 1280; WgMtu = protocolExtra.WgMtu ?? 1280;
WgDns = protocolExtra.WgDns ?? string.Empty;
Uot = protocolExtra.Uot ?? false; Uot = protocolExtra.Uot ?? false;
CongestionControl = protocolExtra.CongestionControl ?? string.Empty; CongestionControl = protocolExtra.CongestionControl ?? string.Empty;
InsecureConcurrency = protocolExtra.InsecureConcurrency > 0 ? protocolExtra.InsecureConcurrency : null; InsecureConcurrency = protocolExtra.InsecureConcurrency > 0 ? protocolExtra.InsecureConcurrency : null;
@@ -431,6 +435,7 @@ public partial class AddServerViewModel : MyReactiveObject, ICloseable
WgInterfaceAddress = WgInterfaceAddress.NullIfEmpty(), WgInterfaceAddress = WgInterfaceAddress.NullIfEmpty(),
WgReserved = WgReserved.NullIfEmpty(), WgReserved = WgReserved.NullIfEmpty(),
WgMtu = WgMtu >= 576 ? WgMtu : null, WgMtu = WgMtu >= 576 ? WgMtu : null,
WgDns = WgDns.NullIfEmpty(),
Uot = Uot ? true : null, Uot = Uot ? true : null,
CongestionControl = CongestionControl.NullIfEmpty(), CongestionControl = CongestionControl.NullIfEmpty(),
InsecureConcurrency = InsecureConcurrency > 0 ? InsecureConcurrency : null, InsecureConcurrency = InsecureConcurrency > 0 ? InsecureConcurrency : null,
@@ -13,6 +13,7 @@ public partial class CheckUpdateViewModel : MyReactiveObject
public ReactiveCommand<RxVoid, RxVoid> CheckUpdateCmd { get; } public ReactiveCommand<RxVoid, RxVoid> CheckUpdateCmd { get; }
public ReactiveCommand<RxVoid, RxVoid> CheckOnlyCmd { get; } public ReactiveCommand<RxVoid, RxVoid> CheckOnlyCmd { get; }
[Reactive] public partial bool EnableCheckPreReleaseUpdate { get; set; } [Reactive] public partial bool EnableCheckPreReleaseUpdate { get; set; }
[Reactive] public partial bool EnableUpdateViaProxy { get; set; }
public CheckUpdateViewModel() public CheckUpdateViewModel()
{ {
@@ -33,11 +34,13 @@ public partial class CheckUpdateViewModel : MyReactiveObject
}); });
EnableCheckPreReleaseUpdate = _config.CheckUpdateItem.CheckPreReleaseUpdate; EnableCheckPreReleaseUpdate = _config.CheckUpdateItem.CheckPreReleaseUpdate;
EnableUpdateViaProxy = _config.CheckUpdateItem.UpdateViaProxy;
this.WhenAnyValue( this.WhenAnyValue(x => x.EnableCheckPreReleaseUpdate)
x => x.EnableCheckPreReleaseUpdate, .SubscribeAsync(async _ => await OnCheckPreReleaseUpdateChanged());
y => y == true)
.Subscribe(c => _ = OnCheckPreReleaseUpdateChanged()); this.WhenAnyValue(x => x.EnableUpdateViaProxy)
.Subscribe(c => _ = OnUpdateViaProxyChanged());
RefreshCheckUpdateItems(); RefreshCheckUpdateItems();
} }
@@ -50,8 +53,7 @@ public partial class CheckUpdateViewModel : MyReactiveObject
models.Add(GetGeoFileCheckUpdateModel()); models.Add(GetGeoFileCheckUpdateModel());
CheckUpdateModels.Clear(); CheckUpdateModels.ReplaceRange(models);
CheckUpdateModels.AddRange(models);
} }
private CheckUpdateModel GetCheckUpdateModel(ECoreType coreType) private CheckUpdateModel GetCheckUpdateModel(ECoreType coreType)
@@ -98,6 +100,16 @@ public partial class CheckUpdateViewModel : MyReactiveObject
await SaveSelectedCoreTypes(); await SaveSelectedCoreTypes();
} }
private async Task OnUpdateViaProxyChanged()
{
if (_config.CheckUpdateItem.UpdateViaProxy == EnableUpdateViaProxy)
{
return;
}
_config.CheckUpdateItem.UpdateViaProxy = EnableUpdateViaProxy;
await SaveSelectedCoreTypes();
}
private async Task SaveSelectedCoreTypes() private async Task SaveSelectedCoreTypes()
{ {
_config.CheckUpdateItem.SelectedCoreTypes = _config.CheckUpdateItem.SelectedCoreTypes =
@@ -145,7 +157,7 @@ public partial class CheckUpdateViewModel : MyReactiveObject
} }
var updateService = new UpdateService(_config, async (success, msg) => await Task.CompletedTask); 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) if (result.Success && result.Version != null)
{ {
await UpdateView(item.CoreType, string.Format(ResUI.MsgCheckUpdateHasNewVersion, item.CoreType, result.Version)); await UpdateView(item.CoreType, string.Format(ResUI.MsgCheckUpdateHasNewVersion, item.CoreType, result.Version));
@@ -193,7 +205,7 @@ public partial class CheckUpdateViewModel : MyReactiveObject
} }
await CheckUpdateN(EnableCheckPreReleaseUpdate); await CheckUpdateN(EnableCheckPreReleaseUpdate);
} }
else if (item.CoreType == ECoreType.Xray) else if (item.CoreType is ECoreType.Xray or ECoreType.sing_box)
{ {
await CheckUpdateCore(item, EnableCheckPreReleaseUpdate); await CheckUpdateCore(item, EnableCheckPreReleaseUpdate);
} }
@@ -230,7 +242,7 @@ public partial class CheckUpdateViewModel : MyReactiveObject
UpdatedPlusPlus(null, ""); UpdatedPlusPlus(null, "");
} }
} }
await new UpdateService(_config, _updateUI).UpdateGeoFileAll() await new UpdateService(_config, _updateUI).UpdateGeoFileAll(EnableUpdateViaProxy)
.ContinueWith(t => UpdatedPlusPlus(null, "")); .ContinueWith(t => UpdatedPlusPlus(null, ""));
} }
@@ -245,7 +257,7 @@ public partial class CheckUpdateViewModel : MyReactiveObject
UpdatedPlusPlus(_v2rayN, msg); UpdatedPlusPlus(_v2rayN, msg);
} }
} }
await new UpdateService(_config, _updateUI).CheckUpdateGuiN(preRelease) await new UpdateService(_config, _updateUI).CheckUpdateGuiN(preRelease, EnableUpdateViaProxy)
.ContinueWith(t => UpdatedPlusPlus(_v2rayN, "")); .ContinueWith(t => UpdatedPlusPlus(_v2rayN, ""));
} }
@@ -263,7 +275,7 @@ public partial class CheckUpdateViewModel : MyReactiveObject
if (model.CoreType.HasValue) 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, "")); .ContinueWith(t => UpdatedPlusPlus(model.CoreType, ""));
} }
} }
@@ -2,33 +2,18 @@ namespace ServiceLib.ViewModels;
public partial class ClashConnectionsViewModel : MyReactiveObject 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() public ClashConnectionsViewModel()
{ {
_config = AppManager.Instance.Config; _config = AppManager.Instance.Config;
AutoRefresh = _config.ClashUIItem.ConnectionsAutoRefresh; AutoRefresh = _config.ClashUIItem.ConnectionsAutoRefresh;
var canEditRemove = this.WhenAnyValue( var canEditRemove = this.WhenAnyValue(
x => x.SelectedSource, x => x.SelectedSource,
selectedSource => selectedSource != null && selectedSource.Id.IsNotEmpty()); selectedSource => selectedSource?.Id?.IsNotEmpty() == true);
this.WhenAnyValue( this.WhenAnyValue(
x => x.AutoRefresh, x => x.AutoRefresh)
y => y == true) .Subscribe(_ => { _config.ClashUIItem.ConnectionsAutoRefresh = AutoRefresh; });
.Subscribe(c => { _config.ClashUIItem.ConnectionsAutoRefresh = AutoRefresh; });
ConnectionCloseCmd = ReactiveCommand.CreateFromTask(async () => ConnectionCloseCmd = ReactiveCommand.CreateFromTask(async () =>
{ {
await ClashConnectionClose(false); await ClashConnectionClose(false);
@@ -39,17 +24,28 @@ public partial class ClashConnectionsViewModel : MyReactiveObject
await ClashConnectionClose(true); await ClashConnectionClose(true);
}); });
_ = Init(); _ = Task.Factory.StartNew(
async () => await GetClashConnectionsTask(),
CancellationToken.None,
TaskCreationOptions.LongRunning,
TaskScheduler.Default
);
} }
private async Task Init() public BulkObservableCollection<ClashConnectionModel> ConnectionItems { get; } = [];
{
await DelayTestTask(); [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() private async Task GetClashConnections()
{ {
var ret = await ClashApiManager.Instance.GetClashConnectionsAsync(); var ret = await ClashApiManager.Instance.GetConnections();
if (ret == null) if (ret == null)
{ {
return; return;
@@ -63,13 +59,12 @@ public partial class ClashConnectionsViewModel : MyReactiveObject
public async Task RefreshConnections(List<ConnectionItem>? connections) public async Task RefreshConnections(List<ConnectionItem>? connections)
{ {
ConnectionItems.Clear();
var dtNow = DateTime.Now; var dtNow = DateTime.Now;
var lstModel = new List<ClashConnectionModel>(); var lstModel = new List<ClashConnectionModel>();
foreach (var item in connections ?? []) foreach (var item in connections ?? [])
{ {
var host = $"{(item.metadata.host.IsNullOrEmpty() ? item.metadata.destinationIP : item.metadata.host)}:{item.metadata.destinationPort}"; var host =
$"{(item.metadata.host.IsNullOrEmpty() ? item.metadata.destinationIP : item.metadata.host)}:{item.metadata.destinationPort}";
if (HostFilter.IsNotEmpty() && !host.Contains(HostFilter)) if (HostFilter.IsNotEmpty() && !host.Contains(HostFilter))
{ {
continue; continue;
@@ -83,17 +78,13 @@ public partial class ClashConnectionsViewModel : MyReactiveObject
Host = host, Host = host,
Time = (dtNow - item.start).TotalSeconds < 0 ? 1 : (dtNow - item.start).TotalSeconds, Time = (dtNow - item.start).TotalSeconds < 0 ? 1 : (dtNow - item.start).TotalSeconds,
Elapsed = (dtNow - item.start).ToString(@"hh\:mm\:ss"), Elapsed = (dtNow - item.start).ToString(@"hh\:mm\:ss"),
Chain = $"{item.rule} , {string.Join("->", item.chains ?? [])}" Chain = $"{item.rule} , {string.Join("->", item.chains ?? [])}",
}; };
lstModel.Add(model); lstModel.Add(model);
} }
if (lstModel.Count <= 0)
{
return;
}
ConnectionItems.AddRange(lstModel); ConnectionItems.ReplaceRange(lstModel);
await Task.CompletedTask; await Task.CompletedTask;
} }
@@ -103,47 +94,39 @@ public partial class ClashConnectionsViewModel : MyReactiveObject
if (!all) if (!all)
{ {
var item = SelectedSource; var item = SelectedSource;
if (item is null) if (string.IsNullOrEmpty(item?.Id))
{ {
return; return;
} }
id = item.Id; id = item.Id;
} }
else await ClashApiManager.Instance.CloseConnection(id);
{
ConnectionItems.Clear();
}
await ClashApiManager.Instance.ClashConnectionClose(id);
await GetClashConnections(); await GetClashConnections();
} }
public async Task DelayTestTask() public async Task GetClashConnectionsTask()
{ {
_ = Task.Run(async () => var numOfExecuted = 1;
while (true)
{ {
var numOfExecuted = 1; await Task.Delay(1000 * 5);
while (true) numOfExecuted++;
if (!(AutoRefresh && AppManager.Instance.ShowInTaskbar &&
AppManager.Instance.IsRunningCore(ECoreType.sing_box)))
{ {
await Task.Delay(1000 * 5); continue;
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();
} }
});
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; namespace ServiceLib.ViewModels;
public partial class ClashProxiesViewModel : MyReactiveObject public partial class ClashProxiesViewModel : MyReactiveObject
{ {
private Dictionary<string, ProxiesItem>? _proxies;
private Dictionary<string, ProvidersItem>? _providers;
private readonly int _delayTimeout = 99999999; private readonly int _delayTimeout = 99999999;
private ClashItem _clashItem = new();
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; }
public ClashProxiesViewModel() public ClashProxiesViewModel()
{ {
@@ -40,169 +13,158 @@ public partial class ClashProxiesViewModel : MyReactiveObject
{ {
await ProxiesReload(); 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 () => ProxiesSelectActivityCmd = ReactiveCommand.CreateFromTask(async () =>
{ {
await SetActiveProxy(); await SetActiveProxy();
}); });
SelectedGroup = new();
SelectedDetail = new();
AutoRefresh = _config.ClashUIItem.ProxiesAutoRefresh; AutoRefresh = _config.ClashUIItem.ProxiesAutoRefresh;
SortingSelected = _config.ClashUIItem.ProxiesSorting; SortingSelected = _config.ClashUIItem.ProxiesSorting;
RuleModeSelected = (int)_config.ClashUIItem.RuleMode; RuleModeSelected = nameof(ERuleMode.Rule);
#region WhenAnyValue && ReactiveCommand #region WhenAnyValue && ReactiveCommand
this.WhenAnyValue( this.WhenAnyValue(x => x.SelectedGroup)
x => x.SelectedGroup, .Where(y => y != null && y.Name.IsNotEmpty())
y => y != null && y.Name.IsNotEmpty()) .Subscribe(_ => RefreshProxyDetails());
.Subscribe(RefreshProxyDetails);
this.WhenAnyValue( this.WhenAnyValue(x => x.RuleModeSelected)
x => x.RuleModeSelected, .Where(y => !string.IsNullOrEmpty(y))
y => y >= 0) .Skip(1)
.Subscribe(async c => await DoRuleModeSelected(c)); .SubscribeAsync(async x => await SetRuleMode(x));
this.WhenAnyValue( this.WhenAnyValue(x => x.SortingSelected)
x => x.SortingSelected, .Where(y => y >= 0)
y => y >= 0) .Subscribe(_ => DoSortingSelected());
.Subscribe(DoSortingSelected);
this.WhenAnyValue( this.WhenAnyValue(x => x.AutoRefresh)
x => x.AutoRefresh, .Where(y => y)
y => y == true) .Subscribe(_ => { _config.ClashUIItem.ProxiesAutoRefresh = AutoRefresh; });
.Subscribe(c => { _config.ClashUIItem.ProxiesAutoRefresh = AutoRefresh; });
#endregion WhenAnyValue && ReactiveCommand #endregion WhenAnyValue && ReactiveCommand
_ = Init(); _ = Task.Factory.StartNew(
async () => await GetClashProxiesTask(),
CancellationToken.None,
TaskCreationOptions.LongRunning,
TaskScheduler.Default
);
} }
private async Task Init() public BulkObservableCollection<ClashProxyModel> ProxyGroups { get; } = [];
{ public BulkObservableCollection<ClashProxyModel> ProxyDetails { get; } = [];
await DelayTestTask();
}
private async Task DoRuleModeSelected(bool c) public BulkObservableCollection<string> ClashModes { get; } = new(Enum.GetNames<ERuleMode>().ToList());
{
if (!c)
{
return;
}
if (_config.ClashUIItem.RuleMode == (ERuleMode)RuleModeSelected)
{
return;
}
await SetRuleModeCheck((ERuleMode)RuleModeSelected);
}
public async Task SetRuleModeCheck(ERuleMode mode) [Reactive] public partial ClashProxyModel? SelectedGroup { get; set; }
{
if (_config.ClashUIItem.RuleMode == mode)
{
return;
}
await SetRuleMode(mode);
}
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) if (SortingSelected != _config.ClashUIItem.ProxiesSorting)
{ {
_config.ClashUIItem.ProxiesSorting = SortingSelected; _config.ClashUIItem.ProxiesSorting = SortingSelected;
} }
RefreshProxyDetails(c); RefreshProxyDetails();
} }
public async Task ProxiesReload() public async Task ProxiesReload()
{ {
await GetClashProxies(true); await GetClashProxies();
await ProxiesDelayTest(); 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 #region proxy function
private async Task SetRuleMode(ERuleMode mode) private async Task SetRuleMode(string mode)
{ {
_config.ClashUIItem.RuleMode = mode; await ClashApiManager.Instance.UpdateClashMode(mode);
if (mode != ERuleMode.Unchanged)
{
Dictionary<string, string> headers = new()
{
{ "mode", mode.ToString().ToLower() }
};
await ClashApiManager.Instance.ClashConfigUpdate(headers);
}
} }
private async Task GetClashProxies(bool refreshUI) private async Task GetClashProxies()
{ {
var ret = await ClashApiManager.Instance.GetClashProxiesAsync(); var ret = await ClashApiManager.Instance.GetProxies();
if (ret?.Item1 == null || ret.Item2 == null) if (ret?.IsEmpty() != false)
{ {
return; return;
} }
_proxies = ret.Item1.proxies; _clashItem = ret;
_providers = ret?.Item2.providers;
if (refreshUI) RxSchedulers.MainThreadScheduler.Schedule(() => _ = RefreshProxyGroups());
{
RxSchedulers.MainThreadScheduler.Schedule(() => _ = RefreshProxyGroups());
}
} }
public async Task RefreshProxyGroups() public async Task RefreshProxyGroups()
{ {
if (_proxies == null) if (_clashItem.IsEmpty())
{ {
return; return;
} }
var selectedName = SelectedGroup?.Name; var selectedName = SelectedGroup?.Name;
ProxyGroups.Clear();
var lstProxyGroups = new List<ClashProxyModel>(); 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 var globalName = "GLOBAL";
foreach (var kv in _proxies) 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; continue;
} }
@@ -211,52 +173,49 @@ public partial class ClashProxiesViewModel : MyReactiveObject
{ {
continue; continue;
} }
lstProxyGroups.Add(new ClashProxyModel() lstProxyGroups.Add(new ClashProxyModel
{ {
Now = kv.Value.now, Now = kv.Value.now,
Name = kv.Key, 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 (ProxyGroups is { Count: > 0 })
{ {
if (selectedName != null && ProxyGroups.Any(t => t.Name == selectedName)) SelectedGroup = ProxyGroups.FirstOrDefault(t => t.Name == selectedName) ?? ProxyGroups.First();
{
SelectedGroup = ProxyGroups.FirstOrDefault(t => t.Name == selectedName);
}
else
{
SelectedGroup = ProxyGroups.First();
}
} }
else else
{ {
SelectedGroup = new(); SelectedGroup = null;
} }
await Task.CompletedTask; await Task.CompletedTask;
} }
private void RefreshProxyDetails(bool c) private void RefreshProxyDetails()
{ {
ProxyDetails.Clear();
if (!c)
{
return;
}
var name = SelectedGroup?.Name; var name = SelectedGroup?.Name;
if (name.IsNullOrEmpty()) if (name.IsNullOrEmpty())
{ {
return; return;
} }
if (_proxies == null) if (_clashItem.IsEmpty())
{ {
return; return;
} }
_proxies.TryGetValue(name, out var proxy); _clashItem.Proxies.TryGetValue(name, out var proxy);
if (proxy?.all == null) if (proxy?.all == null)
{ {
return; return;
@@ -271,7 +230,7 @@ public partial class ClashProxiesViewModel : MyReactiveObject
} }
var delay = proxy2.history?.Count > 0 ? proxy2.history.Last().delay : -1; var delay = proxy2.history?.Count > 0 ? proxy2.history.Last().delay : -1;
lstDetails.Add(new ClashProxyModel() lstDetails.Add(new ClashProxyModel
{ {
IsActive = item == proxy.now, IsActive = item == proxy.now,
Name = item, Name = item,
@@ -280,7 +239,7 @@ public partial class ClashProxiesViewModel : MyReactiveObject
DelayName = delay <= 0 ? string.Empty : $"{delay}ms", DelayName = delay <= 0 ? string.Empty : $"{delay}ms",
}); });
} }
//sort // sort
switch (SortingSelected) switch (SortingSelected)
{ {
case 0: case 0:
@@ -290,101 +249,95 @@ public partial class ClashProxiesViewModel : MyReactiveObject
case 1: case 1:
lstDetails = lstDetails.OrderBy(t => t.Name).ToList(); lstDetails = lstDetails.OrderBy(t => t.Name).ToList();
break; 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; return null;
} }
_proxies.TryGetValue(name, out var proxy2); _clashItem.Proxies.TryGetValue(name, out var proxy2);
if (proxy2 != null) return proxy2;
{
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;
} }
public async Task SetActiveProxy() public async Task SetActiveProxy()
{ {
if (SelectedGroup == null || SelectedGroup.Name.IsNullOrEmpty()) if (SelectedGroup.Name.IsNullOrEmpty())
{ {
return; return;
} }
if (SelectedDetail == null || SelectedDetail.Name.IsNullOrEmpty()) if (SelectedDetail.Name.IsNullOrEmpty())
{ {
return; return;
} }
var name = SelectedGroup.Name; var groupName = SelectedGroup.Name;
if (name.IsNullOrEmpty()) if (groupName.IsNullOrEmpty())
{ {
return; return;
} }
var nameNode = SelectedDetail.Name; var nodeName = SelectedDetail.Name;
if (nameNode.IsNullOrEmpty()) if (nodeName.IsNullOrEmpty())
{ {
return; return;
} }
var selectedProxy = TryGetProxy(name); var selectedProxy = TryGetProxy(groupName);
if (selectedProxy == null || selectedProxy.type != "Selector") if (selectedProxy is not { type: "Selector" })
{ {
NoticeManager.Instance.Enqueue(ResUI.OperationFailed); NoticeManager.Instance.Enqueue(ResUI.OperationFailed);
return; return;
} }
await ClashApiManager.Instance.ClashSetActiveProxy(name, nameNode); await ClashApiManager.Instance.SetActiveProxy(groupName, nodeName);
await GetClashProxies();
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;
}
NoticeManager.Instance.Enqueue(ResUI.OperationSuccess); 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 }; private async Task TestProxyDelay(string name)
RxSchedulers.MainThreadScheduler.Schedule(() => {
{ var result = await ClashApiManager.Instance.TestDelay(name, _clashItem);
_ = ProxiesDelayTestResult(model); var model = new SpeedTestResult
}); {
await Task.CompletedTask; 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) public async Task ProxiesDelayTestResult(SpeedTestResult result)
@@ -394,56 +347,10 @@ public partial class ClashProxiesViewModel : MyReactiveObject
{ {
return; return;
} }
detail.Delay = Convert.ToInt32(result.Delay);
var dicResult = JsonUtils.Deserialize<Dictionary<string, object>>(result.Delay); detail.DelayName = $"{detail.Delay}ms";
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;
}
await Task.CompletedTask; await Task.CompletedTask;
} }
#endregion proxy function #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
} }
@@ -9,6 +9,7 @@ public partial class DNSSettingViewModel : MyReactiveObject, ICloseable
[Reactive] public partial bool FakeIP { get; set; } [Reactive] public partial bool FakeIP { get; set; }
[Reactive] public partial string FakeIPRange { get; set; } [Reactive] public partial string FakeIPRange { get; set; }
[Reactive] public partial bool BlockBindingQuery { get; set; } [Reactive] public partial bool BlockBindingQuery { get; set; }
[Reactive] public partial bool BlockAAAAQuery { get; set; }
[Reactive] public partial string DirectDNS { get; set; } [Reactive] public partial string DirectDNS { get; set; }
[Reactive] public partial string RemoteDNS { get; set; } [Reactive] public partial string RemoteDNS { get; set; }
[Reactive] public partial string BootstrapDNS { get; set; } [Reactive] public partial string BootstrapDNS { get; set; }
@@ -74,6 +75,7 @@ public partial class DNSSettingViewModel : MyReactiveObject, ICloseable
FakeIP = item.FakeIP ?? false; FakeIP = item.FakeIP ?? false;
FakeIPRange = item.FakeIPRange ?? string.Empty; FakeIPRange = item.FakeIPRange ?? string.Empty;
BlockBindingQuery = item.BlockBindingQuery ?? false; BlockBindingQuery = item.BlockBindingQuery ?? false;
BlockAAAAQuery = item.BlockAAAAQuery ?? false;
DirectDNS = item.DirectDNS ?? string.Empty; DirectDNS = item.DirectDNS ?? string.Empty;
RemoteDNS = item.RemoteDNS ?? string.Empty; RemoteDNS = item.RemoteDNS ?? string.Empty;
BootstrapDNS = item.BootstrapDNS ?? string.Empty; BootstrapDNS = item.BootstrapDNS ?? string.Empty;
@@ -108,6 +110,7 @@ public partial class DNSSettingViewModel : MyReactiveObject, ICloseable
_config.SimpleDNSItem.FakeIP = FakeIP; _config.SimpleDNSItem.FakeIP = FakeIP;
_config.SimpleDNSItem.FakeIPRange = FakeIPRange; _config.SimpleDNSItem.FakeIPRange = FakeIPRange;
_config.SimpleDNSItem.BlockBindingQuery = BlockBindingQuery; _config.SimpleDNSItem.BlockBindingQuery = BlockBindingQuery;
_config.SimpleDNSItem.BlockAAAAQuery = BlockAAAAQuery;
_config.SimpleDNSItem.DirectDNS = DirectDNS; _config.SimpleDNSItem.DirectDNS = DirectDNS;
_config.SimpleDNSItem.RemoteDNS = RemoteDNS; _config.SimpleDNSItem.RemoteDNS = RemoteDNS;
_config.SimpleDNSItem.BootstrapDNS = BootstrapDNS; _config.SimpleDNSItem.BootstrapDNS = BootstrapDNS;
@@ -85,8 +85,6 @@ public partial class MainWindowViewModel : MyReactiveObject
#endregion Menu #endregion Menu
private readonly SynchronizationContext _uiContext = SynchronizationContext.Current;
#region Init #region Init
public MainWindowViewModel() public MainWindowViewModel()
@@ -259,19 +257,19 @@ public partial class MainWindowViewModel : MyReactiveObject
AppEvents.AddServerViaClipboardRequested AppEvents.AddServerViaClipboardRequested
.AsObservable() .AsObservable()
.ObserveOn(RxSchedulers.MainThreadScheduler) .ObserveOn(RxSchedulers.MainThreadScheduler)
.Subscribe(async _ => await AddServerViaClipboardAsync(null)); .SubscribeAsync(async _ => await AddServerViaClipboardAsync(null));
AppEvents.HasUpdateNotified AppEvents.HasUpdateNotified
.AsObservable() .AsObservable()
.ObserveOn(RxSchedulers.MainThreadScheduler) .ObserveOn(RxSchedulers.MainThreadScheduler)
.Subscribe(async bl => BlNewUpdate = bl); .Subscribe(bl => BlNewUpdate = bl);
#endregion AppEvents #endregion AppEvents
ProfilesViewModel.RefreshServersRequested ProfilesViewModel.RefreshServersRequested
.AsObservable() .AsObservable()
.ObserveOn(RxSchedulers.MainThreadScheduler) .ObserveOn(RxSchedulers.MainThreadScheduler)
.Subscribe(async _ => await RefreshServers()); .SubscribeAsync(async _ => await RefreshServers());
var vmReloadRequestedList = new List<IObservable<RxVoid>> var vmReloadRequestedList = new List<IObservable<RxVoid>>
{ {
@@ -284,36 +282,36 @@ public partial class MainWindowViewModel : MyReactiveObject
{ {
reloadRequested reloadRequested
.ObserveOn(RxSchedulers.MainThreadScheduler) .ObserveOn(RxSchedulers.MainThreadScheduler)
.Subscribe(async _ => await Reload()); .SubscribeAsync(async _ => await Reload());
} }
StatusBarViewModel.AddServerViaScanRequested StatusBarViewModel.AddServerViaScanRequested
.AsObservable() .AsObservable()
.ObserveOn(RxSchedulers.MainThreadScheduler) .ObserveOn(RxSchedulers.MainThreadScheduler)
.Subscribe(async _ => await AddServerViaScanAsync()); .SubscribeAsync(async _ => await AddServerViaScanAsync());
StatusBarViewModel.AddServerViaClipboardRequested StatusBarViewModel.AddServerViaClipboardRequested
.AsObservable() .AsObservable()
.ObserveOn(RxSchedulers.MainThreadScheduler) .ObserveOn(RxSchedulers.MainThreadScheduler)
.Subscribe(async _ => await AddServerViaClipboardAsync(null)); .SubscribeAsync(async _ => await AddServerViaClipboardAsync(null));
StatusBarViewModel.ShowHideWindowRequested StatusBarViewModel.ShowHideWindowRequested
.AsObservable() .AsObservable()
.ObserveOn(RxSchedulers.MainThreadScheduler) .ObserveOn(RxSchedulers.MainThreadScheduler)
.Subscribe(async blShow => .SubscribeAsync(async blShow =>
{ {
await ShowHideWindowInteraction.Handle(blShow); await ShowHideWindowInteraction.HandleSafe(blShow);
}); });
StatusBarViewModel.SetDefaultServerRequested StatusBarViewModel.SetDefaultServerRequested
.AsObservable() .AsObservable()
.ObserveOn(RxSchedulers.MainThreadScheduler) .ObserveOn(RxSchedulers.MainThreadScheduler)
.Subscribe(async indexId => await ProfilesViewModel.SetDefaultServer(indexId)); .SubscribeAsync(async indexId => await ProfilesViewModel.SetDefaultServer(indexId));
StatusBarViewModel.SubscriptionsUpdateRequested StatusBarViewModel.SubscriptionsUpdateRequested
.AsObservable() .AsObservable()
.ObserveOn(RxSchedulers.MainThreadScheduler) .ObserveOn(RxSchedulers.MainThreadScheduler)
.Subscribe(async blProxy => await UpdateSubscriptionProcess("", blProxy)); .SubscribeAsync(async blProxy => await UpdateSubscriptionProcess("", blProxy));
_ = Init(); _ = Init();
} }
@@ -413,14 +411,25 @@ public partial class MainWindowViewModel : MyReactiveObject
private async Task RefreshServersDispatcherAsync() private async Task RefreshServersDispatcherAsync()
{ {
//await Observable.Start(async () => await RefreshServers(), RxSchedulers.MainThreadScheduler); //await Observable.Start(async () => await RefreshServers(), RxSchedulers.MainThreadScheduler);
_uiContext?.Post(_ => _ = RefreshServers(), null); await Signal.FromAsync(async () =>
{
await RefreshServers();
return RxVoid.Default;
})
.SubscribeOn(RxSchedulers.MainThreadScheduler)
.ToTask();
} }
private async Task RefreshSubscriptions() private async Task RefreshSubscriptions()
{ {
//await Observable.Start(async () => await ProfilesViewModel.RefreshSubscriptions(), RxSchedulers.MainThreadScheduler); //await Observable.Start(async () => await ProfilesViewModel.RefreshSubscriptions(), RxSchedulers.MainThreadScheduler);
await Signal.FromAsync(async () =>
_uiContext?.Post(_ => _ = ProfilesViewModel.RefreshSubscriptions(), null); {
await ProfilesViewModel.RefreshSubscriptions();
return RxVoid.Default;
})
.SubscribeOn(RxSchedulers.MainThreadScheduler)
.ToTask();
} }
#endregion Servers && Groups #endregion Servers && Groups
@@ -467,7 +476,7 @@ public partial class MainWindowViewModel : MyReactiveObject
var stringData = clipboardData; var stringData = clipboardData;
if (clipboardData == null) if (clipboardData == null)
{ {
var result = await ReadTextFromClipboardInteraction.Handle(RxVoid.Default); var result = await ReadTextFromClipboardInteraction.HandleSafe(RxVoid.Default);
if (result.IsNullOrEmpty()) if (result.IsNullOrEmpty())
{ {
NoticeManager.Instance.Enqueue(ResUI.OperationFailed); NoticeManager.Instance.Enqueue(ResUI.OperationFailed);
@@ -490,7 +499,7 @@ public partial class MainWindowViewModel : MyReactiveObject
public async Task AddServerViaScanAsync() public async Task AddServerViaScanAsync()
{ {
var result = await ScanScreenInteraction.Handle(RxVoid.Default); var result = await ScanScreenInteraction.HandleSafe(RxVoid.Default);
await ScanScreenResult(result); await ScanScreenResult(result);
} }
@@ -502,7 +511,7 @@ public partial class MainWindowViewModel : MyReactiveObject
public async Task AddServerViaImageAsync() public async Task AddServerViaImageAsync()
{ {
var imageFileName = await BrowseImageFileInteraction.Handle(RxVoid.Default); var imageFileName = await BrowseImageFileInteraction.HandleSafe(RxVoid.Default);
await AddScanResultAsync(imageFileName); await AddScanResultAsync(imageFileName);
} }
@@ -681,7 +690,18 @@ public partial class MainWindowViewModel : MyReactiveObject
}); });
RxSchedulers.MainThreadScheduler.Schedule(async () => 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); var showClashUI = AppManager.Instance.IsRunningCore(ECoreType.sing_box);
@@ -691,10 +711,12 @@ public partial class MainWindowViewModel : MyReactiveObject
//{ //{
// await ClashProxiesViewModel.ProxiesReload(); // await ClashProxiesViewModel.ProxiesReload();
//}, RxSchedulers.MainThreadScheduler); //}, RxSchedulers.MainThreadScheduler);
RxSchedulers.MainThreadScheduler.Schedule(async () => await Signal.FromAsync(async () =>
{ {
await ClashProxiesViewModel.ProxiesReload(); await ClashProxiesViewModel.ProxiesReload();
}); return RxVoid.Default;
}).SubscribeOn(RxSchedulers.MainThreadScheduler)
.ToTask();
} }
ReloadResult(showClashUI); ReloadResult(showClashUI);
+8 -10
View File
@@ -22,18 +22,16 @@ public partial class MsgViewModel : MyReactiveObject
AutoRefresh = _config.MsgUIItem.AutoRefresh ?? true; AutoRefresh = _config.MsgUIItem.AutoRefresh ?? true;
this.WhenAnyValue( this.WhenAnyValue(
x => x.MsgFilter) x => x.MsgFilter)
.Subscribe(c => DoMsgFilter()); .Subscribe(c => DoMsgFilter());
this.WhenAnyValue( this.WhenAnyValue(x => x.AutoRefresh)
x => x.AutoRefresh, .Subscribe(_ => _config.MsgUIItem.AutoRefresh = AutoRefresh);
y => y == true)
.Subscribe(c => _config.MsgUIItem.AutoRefresh = AutoRefresh);
AppEvents.SendMsgViewRequested AppEvents.SendMsgViewRequested
.AsObservable() .AsObservable()
//.ObserveOn(RxSchedulers.MainThreadScheduler) //.ObserveOn(RxSchedulers.MainThreadScheduler)
.Subscribe(content => _ = AppendQueueMsg(content)); .Subscribe(content => _ = AppendQueueMsg(content));
} }
public void FlushQueueMsg() public void FlushQueueMsg()
@@ -76,7 +74,7 @@ public partial class MsgViewModel : MyReactiveObject
{ {
await DispatcherShowMsgInteraction.Handle(sb.ToString()); await DispatcherShowMsgInteraction.Handle(sb.ToString());
} }
catch (Exception) catch
{ {
_queueMsg.Enqueue(sb.ToString()); _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 DefUserAgent { get; set; }
[Reactive] public partial string SendThrough { get; set; } [Reactive] public partial string SendThrough { get; set; }
[Reactive] public partial string BindInterface { 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 string Mux4SboxProtocol { get; set; }
[Reactive] public partial bool EnableCacheFile4Sbox { get; set; } [Reactive] public partial bool EnableCacheFile4Sbox { get; set; }
[Reactive] public partial int? HyUpMbps { get; set; } [Reactive] public partial int? HyUpMbps { get; set; }
@@ -152,6 +155,9 @@ public partial class OptionSettingViewModel : MyReactiveObject, ICloseable
DefUserAgent = _config.CoreBasicItem.DefUserAgent; DefUserAgent = _config.CoreBasicItem.DefUserAgent;
SendThrough = _config.CoreBasicItem.SendThrough ?? string.Empty; SendThrough = _config.CoreBasicItem.SendThrough ?? string.Empty;
BindInterface = _config.CoreBasicItem.BindInterface ?? 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; Mux4SboxProtocol = _config.Mux4SboxItem.Protocol;
EnableCacheFile4Sbox = _config.CoreBasicItem.EnableCacheFile4Sbox; EnableCacheFile4Sbox = _config.CoreBasicItem.EnableCacheFile4Sbox;
HyUpMbps = _config.HysteriaItem.UpMbps; HyUpMbps = _config.HysteriaItem.UpMbps;
@@ -327,6 +333,9 @@ public partial class OptionSettingViewModel : MyReactiveObject, ICloseable
_config.CoreBasicItem.DefUserAgent = DefUserAgent; _config.CoreBasicItem.DefUserAgent = DefUserAgent;
_config.CoreBasicItem.SendThrough = SendThrough.TrimEx(); _config.CoreBasicItem.SendThrough = SendThrough.TrimEx();
_config.CoreBasicItem.BindInterface = BindInterface.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.Mux4SboxItem.Protocol = Mux4SboxProtocol;
_config.CoreBasicItem.EnableCacheFile4Sbox = EnableCacheFile4Sbox; _config.CoreBasicItem.EnableCacheFile4Sbox = EnableCacheFile4Sbox;
_config.HysteriaItem.UpMbps = HyUpMbps ?? 0; _config.HysteriaItem.UpMbps = HyUpMbps ?? 0;
@@ -61,24 +61,22 @@ public partial class ProfilesSelectViewModel : MyReactiveObject, ICloseable
SelectFinish(); SelectFinish();
}); });
this.WhenAnyValue( this.WhenAnyValue(x => x.SelectedSub)
x => x.SelectedSub, .Where(y => y != null && !y.Remarks.IsNullOrEmpty() && _subIndexId != y.Id)
y => y != null && !y.Remarks.IsNullOrEmpty() && _subIndexId != y.Id) .SubscribeAsync(async _ => await SubSelectedChangedAsync());
.Subscribe(async c => await SubSelectedChangedAsync(c));
this.WhenAnyValue( this.WhenAnyValue(x => x.ServerFilter)
x => x.ServerFilter, .Where(y => y != null && _serverFilter != y)
y => y != null && _serverFilter != y) .SubscribeAsync(async _ => await ServerFilterChanged());
.Subscribe(async c => await ServerFilterChanged(c));
// React to ConfigType filter changes // React to ConfigType filter changes
this.WhenAnyValue(x => x.FilterExclude) this.WhenAnyValue(x => x.FilterExclude)
.Skip(1) .Skip(1)
.Subscribe(async _ => await RefreshServersBiz()); .SubscribeAsync(async _ => await RefreshServersBiz());
this.WhenAnyValue(x => x.FilterConfigTypes) this.WhenAnyValue(x => x.FilterConfigTypes)
.Skip(1) .Skip(1)
.Subscribe(async _ => await RefreshServersBiz()); .SubscribeAsync(async _ => await RefreshServersBiz());
#endregion WhenAnyValue && ReactiveCommand #endregion WhenAnyValue && ReactiveCommand
@@ -128,31 +126,17 @@ public partial class ProfilesSelectViewModel : MyReactiveObject, ICloseable
#region Servers && Groups #region Servers && Groups
private async Task SubSelectedChangedAsync(bool c) private async Task SubSelectedChangedAsync()
{ {
if (!c)
{
return;
}
_subIndexId = SelectedSub?.Id; _subIndexId = SelectedSub?.Id;
await RefreshServers(); await RefreshServers();
try await ProfilesFocusInteraction.HandleSafe(RxVoid.Default);
{
await ProfilesFocusInteraction.Handle(RxVoid.Default);
}
catch (UnhandledInteractionException<RxVoid, RxVoid>)
{
}
} }
private async Task ServerFilterChanged(bool c) private async Task ServerFilterChanged()
{ {
if (!c)
{
return;
}
_serverFilter = ServerFilter; _serverFilter = ServerFilter;
if (_serverFilter.IsNullOrEmpty()) if (_serverFilter.IsNullOrEmpty())
{ {
@@ -169,8 +153,7 @@ public partial class ProfilesSelectViewModel : MyReactiveObject, ICloseable
{ {
var lstModel = await GetProfileItemsEx(_subIndexId, _serverFilter); var lstModel = await GetProfileItemsEx(_subIndexId, _serverFilter);
ProfileItems.Clear(); ProfileItems.ReplaceRange(lstModel);
ProfileItems.AddRange(lstModel);
if (lstModel.Count > 0) if (lstModel.Count > 0)
{ {
var selected = lstModel.FirstOrDefault(t => t.IndexId == _config.IndexId); var selected = lstModel.FirstOrDefault(t => t.IndexId == _config.IndexId);
@@ -183,8 +166,7 @@ public partial class ProfilesSelectViewModel : MyReactiveObject, ICloseable
var subItems = await AppManager.Instance.SubItems(); var subItems = await AppManager.Instance.SubItems();
subItems.Insert(0, new SubItem { Remarks = ResUI.AllGroupServers }); subItems.Insert(0, new SubItem { Remarks = ResUI.AllGroupServers });
SubItems.Clear(); SubItems.ReplaceRange(subItems);
SubItems.AddRange(subItems);
SelectedSub = (_config.SubIndexId.IsNotEmpty() SelectedSub = (_config.SubIndexId.IsNotEmpty()
? subItems.FirstOrDefault(t => t.Id == _config.SubIndexId) ? subItems.FirstOrDefault(t => t.Id == _config.SubIndexId)
@@ -304,8 +286,7 @@ public partial class ProfilesSelectViewModel : MyReactiveObject, ICloseable
: ProfileItems.OrderByDescending(KeySelector, comparer); : ProfileItems.OrderByDescending(KeySelector, comparer);
var list = sorted.ToList(); var list = sorted.ToList();
ProfileItems.Clear(); ProfileItems.ReplaceRange(list);
ProfileItems.AddRange(list);
_dicHeaderSort[colName] = !asc; _dicHeaderSort[colName] = !asc;
@@ -103,19 +103,16 @@ public partial class ProfilesViewModel : MyReactiveObject
x => x.SelectedProfile, x => x.SelectedProfile,
selectedSource => selectedSource != null && !selectedSource.IndexId.IsNullOrEmpty()); selectedSource => selectedSource != null && !selectedSource.IndexId.IsNullOrEmpty());
this.WhenAnyValue( this.WhenAnyValue(x => x.SelectedSub)
x => x.SelectedSub, .Where(y => y != null && !y.Remarks.IsNullOrEmpty() && _config.SubIndexId != y.Id)
y => y != null && !y.Remarks.IsNullOrEmpty() && _config.SubIndexId != y.Id) .SubscribeAsync(async _ => await SubSelectedChangedAsync());
.Subscribe(async c => await SubSelectedChangedAsync(c)); this.WhenAnyValue(x => x.SelectedMoveToGroup)
this.WhenAnyValue( .Where(y => y != null && !y.Remarks.IsNullOrEmpty())
x => x.SelectedMoveToGroup, .SubscribeAsync(async _ => await MoveToGroup());
y => y != null && !y.Remarks.IsNullOrEmpty())
.Subscribe(async c => await MoveToGroup(c));
this.WhenAnyValue( this.WhenAnyValue(x => x.ServerFilter)
x => x.ServerFilter, .Where(y => y != null && _serverFilter != y)
y => y != null && _serverFilter != y) .SubscribeAsync(async _ => await ServerFilterChanged());
.Subscribe(async c => await ServerFilterChanged(c));
//servers delete //servers delete
EditServerCmd = ReactiveCommand.CreateFromTask(async () => EditServerCmd = ReactiveCommand.CreateFromTask(async () =>
@@ -249,7 +246,7 @@ public partial class ProfilesViewModel : MyReactiveObject
AppEvents.DispatcherStatisticsRequested AppEvents.DispatcherStatisticsRequested
.AsObservable() .AsObservable()
.ObserveOn(RxSchedulers.MainThreadScheduler) .ObserveOn(RxSchedulers.MainThreadScheduler)
.Subscribe(async result => await UpdateStatistics(result)); .SubscribeAsync(async result => await UpdateStatistics(result));
#endregion AppEvents #endregion AppEvents
@@ -335,31 +332,17 @@ public partial class ProfilesViewModel : MyReactiveObject
#region Servers && Groups #region Servers && Groups
private async Task SubSelectedChangedAsync(bool c) private async Task SubSelectedChangedAsync()
{ {
if (!c)
{
return;
}
_config.SubIndexId = SelectedSub?.Id; _config.SubIndexId = SelectedSub?.Id;
await RefreshServers(); await RefreshServers();
try await ProfilesFocusInteraction.HandleSafe(RxVoid.Default);
{
await ProfilesFocusInteraction.Handle(RxVoid.Default);
}
catch (UnhandledInteractionException<RxVoid, RxVoid>)
{
}
} }
private async Task ServerFilterChanged(bool c) private async Task ServerFilterChanged()
{ {
if (!c)
{
return;
}
_serverFilter = ServerFilter; _serverFilter = ServerFilter;
if (_serverFilter.IsNullOrEmpty()) if (_serverFilter.IsNullOrEmpty())
{ {
@@ -381,8 +364,7 @@ public partial class ProfilesViewModel : MyReactiveObject
var lstModel = await GetProfileItemsEx(_config.SubIndexId, _serverFilter); var lstModel = await GetProfileItemsEx(_config.SubIndexId, _serverFilter);
_lstProfile = JsonUtils.Deserialize<List<ProfileItem>>(JsonUtils.Serialize(lstModel)) ?? []; _lstProfile = JsonUtils.Deserialize<List<ProfileItem>>(JsonUtils.Serialize(lstModel)) ?? [];
ProfileItems.Clear(); ProfileItems.ReplaceRange(lstModel ?? []);
ProfileItems.AddRange(lstModel ?? []);
if (lstModel?.Count > 0) if (lstModel?.Count > 0)
{ {
ProfileItemModel? selected = null; ProfileItemModel? selected = null;
@@ -395,13 +377,7 @@ public partial class ProfilesViewModel : MyReactiveObject
SelectedProfile = selected ?? lstModel.First(); SelectedProfile = selected ?? lstModel.First();
} }
try await DispatcherRefreshServersBizInteraction.HandleSafe(RxVoid.Default);
{
await DispatcherRefreshServersBizInteraction.Handle(RxVoid.Default);
}
catch (UnhandledInteractionException<RxVoid, RxVoid>)
{
}
} }
public async Task RefreshSubscriptions() public async Task RefreshSubscriptions()
@@ -409,8 +385,7 @@ public partial class ProfilesViewModel : MyReactiveObject
var subItems = await AppManager.Instance.SubItems(); var subItems = await AppManager.Instance.SubItems();
subItems.Insert(0, new SubItem { Remarks = ResUI.AllGroupServers }); subItems.Insert(0, new SubItem { Remarks = ResUI.AllGroupServers });
SubItems.Clear(); SubItems.ReplaceRange(subItems);
SubItems.AddRange(subItems);
SelectedSub = (_config.SubIndexId.IsNotEmpty() SelectedSub = (_config.SubIndexId.IsNotEmpty()
? subItems.FirstOrDefault(t => t.Id == _config.SubIndexId) ? subItems.FirstOrDefault(t => t.Id == _config.SubIndexId)
@@ -419,7 +394,7 @@ public partial class ProfilesViewModel : MyReactiveObject
public async Task AdjustMainLvColWidth() public async Task AdjustMainLvColWidth()
{ {
await AdjustMainLvColWidthInteraction.Handle(RxVoid.Default); await AdjustMainLvColWidthInteraction.HandleSafe(RxVoid.Default);
} }
private async Task<List<ProfileItemModel>?> GetProfileItemsEx(string subid, string filter) private async Task<List<ProfileItemModel>?> GetProfileItemsEx(string subid, string filter)
@@ -535,7 +510,7 @@ public partial class ProfilesViewModel : MyReactiveObject
{ {
return; return;
} }
if (await ShowYesNoInteraction.Handle(ResUI.RemoveServer) == false) if (await ShowYesNoInteraction.HandleSafe(ResUI.RemoveServer) == false)
{ {
return; return;
} }
@@ -556,7 +531,7 @@ public partial class ProfilesViewModel : MyReactiveObject
private async Task RemoveDuplicateServer() private async Task RemoveDuplicateServer()
{ {
if (await ShowYesNoInteraction.Handle(ResUI.RemoveServer) == false) if (await ShowYesNoInteraction.HandleSafe(ResUI.RemoveServer) == false)
{ {
return; return;
} }
@@ -631,7 +606,7 @@ public partial class ProfilesViewModel : MyReactiveObject
return; return;
} }
await ShareServerInteraction.Handle(url); await ShareServerInteraction.HandleSafe(url);
} }
private async Task GenGroupAllServer() private async Task GenGroupAllServer()
@@ -684,13 +659,8 @@ public partial class ProfilesViewModel : MyReactiveObject
} }
//move server //move server
private async Task MoveToGroup(bool c) private async Task MoveToGroup()
{ {
if (!c)
{
return;
}
var lstSelected = await GetProfileItems(true); var lstSelected = await GetProfileItems(true);
if (lstSelected == null) if (lstSelected == null)
{ {
@@ -799,13 +769,13 @@ public partial class ProfilesViewModel : MyReactiveObject
} }
else else
{ {
await SetClipboardDataInteraction.Handle((string)result.Data); await SetClipboardDataInteraction.HandleSafe((string)result.Data);
NoticeManager.Instance.SendMessage(ResUI.OperationSuccess); NoticeManager.Instance.SendMessage(ResUI.OperationSuccess);
} }
} }
else else
{ {
await SaveFileDialogInteraction.Handle(item); await SaveFileDialogInteraction.HandleSafe(item);
} }
} }
@@ -854,11 +824,11 @@ public partial class ProfilesViewModel : MyReactiveObject
{ {
if (blEncode) if (blEncode)
{ {
await SetClipboardDataInteraction.Handle(Utils.Base64Encode(sb.ToString())); await SetClipboardDataInteraction.HandleSafe(Utils.Base64Encode(sb.ToString()));
} }
else else
{ {
await SetClipboardDataInteraction.Handle(sb.ToString()); await SetClipboardDataInteraction.HandleSafe(sb.ToString());
} }
NoticeManager.Instance.SendMessage(ResUI.BatchExportURLSuccessfully); NoticeManager.Instance.SendMessage(ResUI.BatchExportURLSuccessfully);
} }
@@ -881,7 +851,7 @@ public partial class ProfilesViewModel : MyReactiveObject
if (!result.IsNullOrEmpty()) if (!result.IsNullOrEmpty())
{ {
await SetClipboardDataInteraction.Handle(result); await SetClipboardDataInteraction.HandleSafe(result);
NoticeManager.Instance.SendMessage(ResUI.BatchExportURLSuccessfully); NoticeManager.Instance.SendMessage(ResUI.BatchExportURLSuccessfully);
} }
else else
@@ -913,7 +883,7 @@ public partial class ProfilesViewModel : MyReactiveObject
if (await AppManager.Instance.WindowDialog.ShowDialogAsync(subEditViewModel) == true) if (await AppManager.Instance.WindowDialog.ShowDialogAsync(subEditViewModel) == true)
{ {
await RefreshSubscriptions(); await RefreshSubscriptions();
await SubSelectedChangedAsync(true); await SubSelectedChangedAsync();
} }
} }
@@ -925,14 +895,14 @@ public partial class ProfilesViewModel : MyReactiveObject
return; return;
} }
if (await ShowYesNoInteraction.Handle(ResUI.RemoveServer) == false) if (await ShowYesNoInteraction.HandleSafe(ResUI.RemoveServer) == false)
{ {
return; return;
} }
await ConfigHandler.DeleteSubItem(_config, item.Id); await ConfigHandler.DeleteSubItem(_config, item.Id);
await RefreshSubscriptions(); await RefreshSubscriptions();
await SubSelectedChangedAsync(true); await SubSelectedChangedAsync();
} }
#endregion Subscription #endregion Subscription
@@ -25,6 +25,21 @@ public partial class RoutingRuleDetailsViewModel : MyReactiveObject, ICloseable
[Reactive] [Reactive]
public partial bool AutoSort { get; set; } public partial bool AutoSort { get; set; }
[Reactive]
public partial string OutboundTag { get; set; }
[Reactive]
public partial string Remarks { get; set; }
[Reactive]
public partial string Port { get; set; }
[Reactive]
public partial string Network { get; set; }
[Reactive]
public partial bool Enabled { get; set; }
public ReactiveCommand<RxVoid, RxVoid> SelectProfileCmd { get; } public ReactiveCommand<RxVoid, RxVoid> SelectProfileCmd { get; }
public ReactiveCommand<RxVoid, RxVoid> SaveCmd { get; } public ReactiveCommand<RxVoid, RxVoid> SaveCmd { get; }
@@ -57,6 +72,11 @@ public partial class RoutingRuleDetailsViewModel : MyReactiveObject, ICloseable
IP = Utils.List2String(SelectedSource.Ip, true); IP = Utils.List2String(SelectedSource.Ip, true);
Process = Utils.List2String(SelectedSource.Process, true); Process = Utils.List2String(SelectedSource.Process, true);
RuleType = SelectedSource.RuleType?.ToString(); RuleType = SelectedSource.RuleType?.ToString();
OutboundTag = SelectedSource.OutboundTag;
Remarks = SelectedSource.Remarks;
Port = SelectedSource.Port;
Network = SelectedSource.Network;
Enabled = SelectedSource.Enabled;
} }
private async Task SaveRulesAsync() private async Task SaveRulesAsync()
@@ -80,6 +100,11 @@ public partial class RoutingRuleDetailsViewModel : MyReactiveObject, ICloseable
SelectedSource.Protocol = ProtocolItems?.ToList(); SelectedSource.Protocol = ProtocolItems?.ToList();
SelectedSource.InboundTag = InboundTagItems?.ToList(); SelectedSource.InboundTag = InboundTagItems?.ToList();
SelectedSource.RuleType = RuleType.IsNullOrEmpty() ? null : Enum.Parse<ERuleType>(RuleType); SelectedSource.RuleType = RuleType.IsNullOrEmpty() ? null : Enum.Parse<ERuleType>(RuleType);
SelectedSource.OutboundTag = OutboundTag;
SelectedSource.Remarks = Remarks;
SelectedSource.Port = Port;
SelectedSource.Network = Network;
SelectedSource.Enabled = Enabled;
var hasRule = SelectedSource.Domain?.Count > 0 var hasRule = SelectedSource.Domain?.Count > 0
|| SelectedSource.Ip?.Count > 0 || SelectedSource.Ip?.Count > 0
@@ -110,8 +135,7 @@ public partial class RoutingRuleDetailsViewModel : MyReactiveObject, ICloseable
var profileItem = await profileSelectViewModel.GetProfileItem(); var profileItem = await profileSelectViewModel.GetProfileItem();
if (profileItem != null) if (profileItem != null)
{ {
SelectedSource.OutboundTag = profileItem.Remarks; OutboundTag = profileItem.Remarks;
SelectedSource = JsonUtils.DeepCopy(SelectedSource);
} }
} }
} }
@@ -48,7 +48,7 @@ public partial class RoutingRuleSettingViewModel : MyReactiveObject, ICloseable
}); });
ImportRulesFromFileCmd = ReactiveCommand.CreateFromTask(async () => ImportRulesFromFileCmd = ReactiveCommand.CreateFromTask(async () =>
{ {
var fileName = await BrowseRulesFileInteraction.Handle(RxVoid.Default); var fileName = await BrowseRulesFileInteraction.HandleSafe(RxVoid.Default);
await ImportRulesFromFileAsync(fileName); await ImportRulesFromFileAsync(fileName);
}); });
ImportRulesFromClipboardCmd = ReactiveCommand.CreateFromTask(async () => ImportRulesFromClipboardCmd = ReactiveCommand.CreateFromTask(async () =>
@@ -100,8 +100,6 @@ public partial class RoutingRuleSettingViewModel : MyReactiveObject, ICloseable
public void RefreshRulesItems() public void RefreshRulesItems()
{ {
RulesItems.Clear();
var models = new List<RulesItemModel>(); var models = new List<RulesItemModel>();
foreach (var item in _rules) foreach (var item in _rules)
{ {
@@ -120,7 +118,7 @@ public partial class RoutingRuleSettingViewModel : MyReactiveObject, ICloseable
}; };
models.Add(it); models.Add(it);
} }
RulesItems.AddRange(models); RulesItems.ReplaceRange(models);
} }
public async Task RuleEditAsync(bool blNew) public async Task RuleEditAsync(bool blNew)
@@ -156,7 +154,7 @@ public partial class RoutingRuleSettingViewModel : MyReactiveObject, ICloseable
NoticeManager.Instance.Enqueue(ResUI.PleaseSelectRules); NoticeManager.Instance.Enqueue(ResUI.PleaseSelectRules);
return; return;
} }
if (await ShowYesNoInteraction.Handle(ResUI.RemoveServer) == false) if (await ShowYesNoInteraction.HandleSafe(ResUI.RemoveServer) == false)
{ {
return; return;
} }
@@ -199,7 +197,7 @@ public partial class RoutingRuleSettingViewModel : MyReactiveObject, ICloseable
DefaultIgnoreCondition = JsonIgnoreCondition.WhenWritingNull, DefaultIgnoreCondition = JsonIgnoreCondition.WhenWritingNull,
PropertyNamingPolicy = JsonNamingPolicy.CamelCase, PropertyNamingPolicy = JsonNamingPolicy.CamelCase,
}; };
await SetClipboardDataInteraction.Handle(JsonUtils.Serialize(lst, options)); await SetClipboardDataInteraction.HandleSafe(JsonUtils.Serialize(lst, options));
} }
} }
@@ -277,7 +275,7 @@ public partial class RoutingRuleSettingViewModel : MyReactiveObject, ICloseable
var stringData = clipboardData; var stringData = clipboardData;
if (clipboardData == null) if (clipboardData == null)
{ {
var result = await ReadTextFromClipboardInteraction.Handle(RxVoid.Default); var result = await ReadTextFromClipboardInteraction.HandleSafe(RxVoid.Default);
if (result.IsNullOrEmpty()) if (result.IsNullOrEmpty())
{ {
NoticeManager.Instance.Enqueue(ResUI.OperationFailed); NoticeManager.Instance.Enqueue(ResUI.OperationFailed);
@@ -315,7 +313,7 @@ public partial class RoutingRuleSettingViewModel : MyReactiveObject, ICloseable
private async Task<int> AddBatchRoutingRulesAsync(RoutingItem routingItem, string? clipboardData) private async Task<int> AddBatchRoutingRulesAsync(RoutingItem routingItem, string? clipboardData)
{ {
var blReplace = false; var blReplace = false;
if (await ShowYesNoInteraction.Handle(ResUI.AddBatchRoutingRulesYesNo) == false) if (await ShowYesNoInteraction.HandleSafe(ResUI.AddBatchRoutingRulesYesNo) == false)
{ {
blReplace = true; blReplace = true;
} }
@@ -61,10 +61,10 @@ public partial class RoutingSettingViewModel : MyReactiveObject
x => x.DomainStrategy4Singbox) x => x.DomainStrategy4Singbox)
.Skip(1) .Skip(1)
.DistinctUntilChanged() .DistinctUntilChanged()
.Subscribe(x => .SubscribeAsync(async x =>
{ {
IsModified = true; IsModified = true;
_ = SaveSettingsAsync(); await SaveSettingsAsync();
}); });
} }
@@ -83,7 +83,6 @@ public partial class RoutingSettingViewModel : MyReactiveObject
public async Task RefreshRoutingItems() public async Task RefreshRoutingItems()
{ {
RoutingItems.Clear();
var models = new List<RoutingItemModel>(); var models = new List<RoutingItemModel>();
var routings = await AppManager.Instance.RoutingItems(); var routings = await AppManager.Instance.RoutingItems();
@@ -102,7 +101,7 @@ public partial class RoutingSettingViewModel : MyReactiveObject
}; };
models.Add(it); models.Add(it);
} }
RoutingItems.AddRange(models); RoutingItems.ReplaceRange(models);
} }
/// <summary> /// <summary>
@@ -147,7 +146,7 @@ public partial class RoutingSettingViewModel : MyReactiveObject
NoticeManager.Instance.Enqueue(ResUI.PleaseSelectRules); NoticeManager.Instance.Enqueue(ResUI.PleaseSelectRules);
return; return;
} }
if (await ShowYesNoInteraction.Handle(ResUI.RemoveServer) == false) if (await ShowYesNoInteraction.HandleSafe(ResUI.RemoveServer) == false)
{ {
return; return;
} }
@@ -123,26 +123,21 @@ public partial class StatusBarViewModel : MyReactiveObject
#region WhenAnyValue && ReactiveCommand #region WhenAnyValue && ReactiveCommand
this.WhenAnyValue( this.WhenAnyValue(x => x.SelectedRouting)
x => x.SelectedRouting, .Where(y => y != null && !y.Remarks.IsNullOrEmpty())
y => y != null && !y.Remarks.IsNullOrEmpty()) .SubscribeAsync(async _ => await RoutingSelectedChangedAsync());
.Subscribe(async c => await RoutingSelectedChangedAsync(c));
this.WhenAnyValue( this.WhenAnyValue(x => x.SelectedServer)
x => x.SelectedServer, .Where(y => y != null && !y.Text.IsNullOrEmpty())
y => y != null && !y.Text.IsNullOrEmpty()) .Subscribe(_ => ServerSelectedChanged());
.Subscribe(ServerSelectedChanged);
SystemProxySelected = (int)_config.SystemProxyItem.SysProxyType; SystemProxySelected = (int)_config.SystemProxyItem.SysProxyType;
this.WhenAnyValue( this.WhenAnyValue(x => x.SystemProxySelected)
x => x.SystemProxySelected, .Where(y => y >= 0)
y => y >= 0) .SubscribeAsync(async _ => await DoSystemProxySelected());
.Subscribe(async c => await DoSystemProxySelected(c));
this.WhenAnyValue( this.WhenAnyValue(x => x.EnableTun)
x => x.EnableTun, .SubscribeAsync(async _ => await DoEnableTun());
y => y == true)
.Subscribe(async c => await DoEnableTun(c));
CopyProxyCmdToClipboardCmd = ReactiveCommand.CreateFromTask(async () => CopyProxyCmdToClipboardCmd = ReactiveCommand.CreateFromTask(async () =>
{ {
@@ -207,12 +202,12 @@ public partial class StatusBarViewModel : MyReactiveObject
AppEvents.DispatcherStatisticsRequested AppEvents.DispatcherStatisticsRequested
.AsObservable() .AsObservable()
.ObserveOn(RxSchedulers.MainThreadScheduler) .ObserveOn(RxSchedulers.MainThreadScheduler)
.Subscribe(async result => await UpdateStatistics(result)); .SubscribeAsync(async result => await UpdateStatistics(result));
AppEvents.SysProxyChangeRequested AppEvents.SysProxyChangeRequested
.AsObservable() .AsObservable()
.ObserveOn(RxSchedulers.MainThreadScheduler) .ObserveOn(RxSchedulers.MainThreadScheduler)
.Subscribe(async result => await SetListenerType(result)); .SubscribeAsync(async result => await SetListenerType(result));
#endregion AppEvents #endregion AppEvents
@@ -243,7 +238,7 @@ public partial class StatusBarViewModel : MyReactiveObject
sb.AppendLine($"{cmd} HTTPS_PROXY={Global.HttpProtocol}{address}"); sb.AppendLine($"{cmd} HTTPS_PROXY={Global.HttpProtocol}{address}");
sb.AppendLine($"{cmd} ALL_PROXY={Global.Socks5Protocol}{address}"); sb.AppendLine($"{cmd} ALL_PROXY={Global.Socks5Protocol}{address}");
await SetClipboardDataInteraction.Handle(sb.ToString()); await SetClipboardDataInteraction.HandleSafe(sb.ToString());
} }
private async Task AddServerViaClipboard() private async Task AddServerViaClipboard()
@@ -297,29 +292,17 @@ public partial class StatusBarViewModel : MyReactiveObject
return; return;
} }
var models = new List<ComboItem>(); var models = lstModel.Select(it => new ComboItem { ID = it.IndexId, Text = it.GetSummary() }).ToList();
BlServers = true;
foreach (var it in lstModel)
{
var name = it.GetSummary();
var item = new ComboItem() { ID = it.IndexId, Text = name }; BlServers = true;
models.Add(item); Servers.ReplaceRange(models);
if (_config.IndexId == it.IndexId)
{ // Update the ItemsSource before SelectedItem so a collection reset does not clear the tray selection.
SelectedServer = item; SelectedServer = models.FirstOrDefault(it => it.ID == _config.IndexId) ?? new();
}
}
Servers.Clear();
Servers.AddRange(models);
} }
private void ServerSelectedChanged(bool c) private void ServerSelectedChanged()
{ {
if (!c)
{
return;
}
if (SelectedServer == null) if (SelectedServer == null)
{ {
return; return;
@@ -331,20 +314,32 @@ public partial class StatusBarViewModel : MyReactiveObject
SetDefaultServerRequested.Publish(SelectedServer.ID); SetDefaultServerRequested.Publish(SelectedServer.ID);
} }
public async Task TestServerAvailability() public async Task<AvailabilityCheckResult?> TestServerAvailability()
{ {
var item = await ConfigHandler.GetDefaultServer(_config); var item = await ConfigHandler.GetDefaultServer(_config);
if (item == null) if (item == null)
{ {
return; return null;
} }
await TestServerAvailabilitySub(ResUI.Speedtesting); 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); NoticeManager.Instance.SendMessageEx(msg);
await TestServerAvailabilitySub(msg); await TestServerAvailabilitySub(msg);
return result;
} }
private async Task TestServerAvailabilitySub(string msg) private async Task TestServerAvailabilitySub(string msg)
@@ -389,14 +384,7 @@ public partial class StatusBarViewModel : MyReactiveObject
if (blChange) if (blChange)
{ {
try await DispatcherRefreshIconInteraction.HandleSafe(RxVoid.Default);
{
await DispatcherRefreshIconInteraction.Handle(RxVoid.Default);
}
catch (UnhandledInteractionException<RxVoid, RxVoid>)
{
// Ignore
}
} }
} }
@@ -404,19 +392,13 @@ public partial class StatusBarViewModel : MyReactiveObject
{ {
var routings = await AppManager.Instance.RoutingItems(); var routings = await AppManager.Instance.RoutingItems();
RoutingItems.Clear(); RoutingItems.ReplaceRange(routings);
RoutingItems.AddRange(routings);
SelectedRouting = routings.FirstOrDefault(t => t.IsActive == true); SelectedRouting = routings.FirstOrDefault(t => t.IsActive == true);
} }
private async Task RoutingSelectedChangedAsync(bool c) private async Task RoutingSelectedChangedAsync()
{ {
if (!c)
{
return;
}
if (SelectedRouting == null) if (SelectedRouting == null)
{ {
return; return;
@@ -432,16 +414,12 @@ public partial class StatusBarViewModel : MyReactiveObject
{ {
NoticeManager.Instance.SendMessageEx(ResUI.TipChangeRouting); NoticeManager.Instance.SendMessageEx(ResUI.TipChangeRouting);
ReloadRequested.Publish(); ReloadRequested.Publish();
await DispatcherRefreshIconInteraction.Handle(RxVoid.Default); await DispatcherRefreshIconInteraction.HandleSafe(RxVoid.Default);
} }
} }
private async Task DoSystemProxySelected(bool c) private async Task DoSystemProxySelected()
{ {
if (!c)
{
return;
}
if (_config.SystemProxyItem.SysProxyType == (ESysProxyType)SystemProxySelected) if (_config.SystemProxyItem.SysProxyType == (ESysProxyType)SystemProxySelected)
{ {
return; return;
@@ -449,7 +427,7 @@ public partial class StatusBarViewModel : MyReactiveObject
await SetListenerType((ESysProxyType)SystemProxySelected); await SetListenerType((ESysProxyType)SystemProxySelected);
} }
private async Task DoEnableTun(bool c) private async Task DoEnableTun()
{ {
if (_config.TunModeItem.EnableTun == EnableTun) if (_config.TunModeItem.EnableTun == EnableTun)
{ {
@@ -469,7 +447,7 @@ public partial class StatusBarViewModel : MyReactiveObject
} }
else else
{ {
var password = await PasswordInputInteraction.Handle(RxVoid.Default); var password = await PasswordInputInteraction.HandleSafe(RxVoid.Default);
if (password.IsNullOrEmpty()) if (password.IsNullOrEmpty())
{ {
_config.TunModeItem.EnableTun = false; _config.TunModeItem.EnableTun = false;

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