diff --git a/.github/workflows/desktop.yml b/.github/workflows/desktop.yml index da56604..a0fe9c4 100644 --- a/.github/workflows/desktop.yml +++ b/.github/workflows/desktop.yml @@ -84,3 +84,12 @@ jobs: name: desktop-test-results path: TestResults/**/*.trx if-no-files-found: warn + + portable-core: + runs-on: ubuntu-22.04 + steps: + - uses: actions/checkout@v7 + - uses: actions/setup-dotnet@v6 + with: + dotnet-version: 8.0.x + - run: dotnet test OpenWrtStudio.Core.Tests/OpenWrtStudio.Core.Tests.csproj -c Release diff --git a/Models/ForkopModels.cs b/Models/ForkopModels.cs index a98661c..30a912b 100644 --- a/Models/ForkopModels.cs +++ b/Models/ForkopModels.cs @@ -38,23 +38,35 @@ public partial class ForkopServerNode : ObservableObject [ObservableProperty] private string _serverAddress = ""; [ObservableProperty] private int _serverPort; [ObservableProperty] private string _groupTag = ""; - [ObservableProperty] private int? _latencyMs; + [ObservableProperty] + [NotifyPropertyChangedFor(nameof(LatencyDisplay))] + private int? _latencyMs; [ObservableProperty] private bool _isActive; [ObservableProperty] private bool _isAutoGroup; + [ObservableProperty] private string _currentNode = ""; + [ObservableProperty] + [NotifyPropertyChangedFor(nameof(CanSelect))] + private string _selectionGroupTag = ""; + [ObservableProperty] private System.Collections.Generic.List _candidates = new(); + [ObservableProperty] + [NotifyPropertyChangedFor(nameof(CanSelect))] + private bool _hasLiveState; + [ObservableProperty] private bool _isOnActivePath; + public bool CanSelect => HasLiveState && !string.IsNullOrEmpty(SelectionGroupTag); public string LatencyDisplay => LatencyMs.HasValue ? $"{LatencyMs.Value}ms" : "N/A"; } public partial class ForkopDashboardStats : ObservableObject { - [ObservableProperty] private string _inSpeed = "1.42 KB/s"; - [ObservableProperty] private string _outSpeed = "0 B/s"; - [ObservableProperty] private string _totalIn = "1.56 MB"; - [ObservableProperty] private string _totalOut = "395 KB"; - [ObservableProperty] private int _activeConnections = 35; - [ObservableProperty] private string _memoryUsage = "23.7 MB"; - [ObservableProperty] private bool _isForkopRunning = true; - [ObservableProperty] private bool _isSingboxRunning = true; + [ObservableProperty] private string _inSpeed = "—"; + [ObservableProperty] private string _outSpeed = "—"; + [ObservableProperty] private string _totalIn = "—"; + [ObservableProperty] private string _totalOut = "—"; + [ObservableProperty] private int _activeConnections; + [ObservableProperty] private string _memoryUsage = "—"; + [ObservableProperty] private bool _isForkopRunning; + [ObservableProperty] private bool _isSingboxRunning; } public partial class ForkopFeedInfo : ObservableObject diff --git a/OpenWrtStudio.Core.Tests/Fixtures/vpn_topology.json b/OpenWrtStudio.Core.Tests/Fixtures/vpn_topology.json new file mode 100644 index 0000000..e797e47 --- /dev/null +++ b/OpenWrtStudio.Core.Tests/Fixtures/vpn_topology.json @@ -0,0 +1,202 @@ +{ + "config": { + "route": { + "final": "direct-out" + }, + "outbounds": [ + { + "tag": "main-out", + "type": "selector", + "outbounds": [ + "priority-route", + "auto-shrimp", + "auto-hella", + "node-a", + "node-b", + "direct-out" + ] + }, + { + "tag": "priority-route", + "type": "selector", + "outbounds": [ + "auto-shrimp", + "auto-blanc" + ] + }, + { + "tag": "direct-out", + "type": "direct" + }, + { + "tag": "node-a", + "type": "vless", + "server": "198.51.100.10", + "server_port": 443 + }, + { + "tag": "node-b", + "type": "hysteria2", + "server": "2001:db8::1", + "server_port": 8443 + } + ] + }, + "cache": [ + { + "servers": { + "node-a": "198.51.100.10", + "node-b": "2001:db8::1", + "node-b-plus": "198.51.100.11" + }, + "outboundMetadata": { + "names": { + "node-a": "Blanc Amsterdam", + "node-b": "Shrimp Helsinki" + }, + "protocols": { + "node-a": "vless", + "node-b": "hysteria2" + }, + "countries": { + "node-a": "NL", + "node-b": "FI" + } + }, + "urltestGroups": { + "auto-blanc": { + "name": "Авто Blanc (Запасной 2)" + }, + "auto-shrimp": { + "name": "Авто Shrimp (Основной)" + }, + "auto-hella": { + "name": "Авто Hella (Запасной 3)" + } + }, + "priorityGroups": { + "priority-route": { + "name": "Приоритет: Shrimp -> Stealth -> Blanc -> Hella -> Free" + } + }, + "section": "main" + }, + { + "urltestGroups": { + "auto-stealth": { + "name": "Авто Stealth (Запасной 1)" + }, + "auto-free": { + "name": "Авто Фри (Аварийный)" + } + } + } + ], + "proxies": { + "GLOBAL": { + "type": "Selector", + "now": "direct-out", + "all": [ + "main-out", + "direct-out" + ] + }, + "auto-blanc": { + "type": "URLTest", + "now": "node-a", + "all": [ + "node-a" + ], + "history": [ + { + "delay": 999 + }, + { + "delay": 0 + } + ] + }, + "node-a": { + "type": "VLESS", + "history": [ + { + "delay": 210 + } + ] + }, + "direct-out": { + "type": "Direct" + }, + "bypass-out": { + "type": "Direct" + }, + "REJECT": { + "type": "Reject" + }, + "main-out": { + "type": "Selector", + "now": "priority-route", + "all": [ + "priority-route", + "auto-shrimp", + "auto-hella", + "node-a", + "node-b", + "direct-out" + ] + }, + "priority-route": { + "type": "Selector", + "now": "auto-shrimp", + "all": [ + "auto-shrimp", + "auto-blanc" + ] + }, + "auto-shrimp": { + "type": "URLTest", + "now": "node-b", + "all": [ + "node-b" + ] + }, + "auto-hella": { + "type": "URLTest", + "now": "node-a", + "all": [ + "node-a" + ] + }, + "auto-stealth": { + "type": "URLTest", + "now": "node-a", + "all": [ + "node-a" + ] + }, + "auto-free": { + "type": "URLTest", + "now": "node-a", + "all": [ + "node-a" + ] + }, + "node-b": { + "type": "Hysteria2", + "history": [ + { + "delay": 167 + } + ] + }, + "node-b-plus": { + "type": "VLESS", + "history": [ + { + "delay": 0 + } + ] + } + }, + "schemaSource": "ushan0v/forkop @ dd483297be0ac4e52bb8f482e2640238b75af532: section-cache outboundMetadata column maps and section selectors" +} diff --git a/OpenWrtStudio.Core.Tests/LiveVpnSelectionTests.cs b/OpenWrtStudio.Core.Tests/LiveVpnSelectionTests.cs new file mode 100644 index 0000000..9b0e832 --- /dev/null +++ b/OpenWrtStudio.Core.Tests/LiveVpnSelectionTests.cs @@ -0,0 +1,92 @@ +using System.Text.Json; +using System.IO; +using OpenWrtStudio.Models; +using OpenWrtStudio.Services; +using Xunit; + +namespace OpenWrtStudio.Core.Tests; + +public sealed class LiveVpnSelectionFactAttribute : FactAttribute +{ + public LiveVpnSelectionFactAttribute() + { + if (Environment.GetEnvironmentVariable("OPENWRT_LIVE_VPN_SWITCH") != "1" || + string.IsNullOrEmpty(Environment.GetEnvironmentVariable("OPENWRT_TEST_HOST")) || + string.IsNullOrEmpty(Environment.GetEnvironmentVariable("OPENWRT_TEST_PASSWORD"))) + Skip = "Explicitly opt in with OPENWRT_LIVE_VPN_SWITCH=1 and router credentials; changes live VPN selection temporarily."; + } +} + +public class LiveVpnSelectionTests +{ + [LiveVpnSelectionFact] + [Trait("Category", "LiveVpnSwitch")] + public async Task PolicyAndManualNodeAreConfirmedByIndependentReadAndOriginalSelectionIsRestored() + { + using var ssh = new SshService(); + var connection = await ssh.ConnectAsync(new ConnectionProfile + { + Host = Environment.GetEnvironmentVariable("OPENWRT_TEST_HOST")!, + Username = Environment.GetEnvironmentVariable("OPENWRT_TEST_USER") ?? "root", + Password = Environment.GetEnvironmentVariable("OPENWRT_TEST_PASSWORD")!, + Port = int.TryParse(Environment.GetEnvironmentVariable("OPENWRT_TEST_PORT"), out var port) ? port : 22 + }); + Assert.True(connection.Success, connection.Message); + async Task> ReadSelectors() + { + var (code, output, _) = await ssh.ExecuteCommandAsync("/usr/bin/forkop clash_api get_proxies", 15); + Assert.Equal(0, code); + using var doc = JsonDocument.Parse(output); + return doc.RootElement.GetProperty("proxies").EnumerateObject() + .Where(p => p.Value.TryGetProperty("type", out var t) && t.GetString() == "Selector") + .ToDictionary(p => p.Name, p => p.Value.GetProperty("now").GetString()!, StringComparer.Ordinal); + } + var original = await ReadSelectors(); + Assert.NotEmpty(original); + var theme = await ssh.ExecuteCommandAsync("uci -q get luci.main.mediaurlbase", 5); + Assert.Equal(0, theme.ExitCode); + var report = new List(); + try + { + var service = new ForkopService(ssh); + var nodes = await service.GetServersAsync(); + Assert.DoesNotContain(nodes, n => new[] { "GLOBAL", "direct-out", "bypass-out", "REJECT" }.Contains(n.GroupTag)); + var root = nodes.Single(n => n.GroupTag == "main-out"); + Assert.True(root.HasLiveState); + var policy = nodes.First(n => n.Protocol == "URLTest" && n.SelectionGroupTag == root.GroupTag && n.GroupTag != root.CurrentNode); + var manual = nodes.First(n => !n.IsAutoGroup && n.SelectionGroupTag == root.GroupTag && n.GroupTag != root.CurrentNode); + foreach (var target in new[] { policy, manual }) + { + var result = await service.SelectActiveServerAsync(target); + Assert.True(result.Success, result.Message); + var actual = await ReadSelectors(); + Assert.Equal(target.GroupTag, actual[target.SelectionGroupTag]); + var refreshed = await service.GetServersAsync(); + Assert.Contains(refreshed, n => n.GroupTag == target.GroupTag && n.IsOnActivePath && n.HasLiveState); + report.Add(new { Kind = target.IsAutoGroup ? "automatic-policy" : "manual-node", ConfirmedByRouter = true }); + } + var beforeInvalid = await ReadSelectors(); + Assert.False((await service.SelectActiveServerAsync(new ForkopServerNode { GroupTag = "studio-nonexistent-test-node" })).Success); + var afterInvalid = await ReadSelectors(); + Assert.Equal(beforeInvalid.OrderBy(p => p.Key), afterInvalid.OrderBy(p => p.Key)); + report.Add(new { Kind = "invalid-target", RejectedWithoutChangingSelectors = true }); + } + finally + { + foreach (var pair in original.OrderBy(p => p.Key == "main-out" ? 1 : 0)) + { + var result = await ssh.ExecuteCommandAsync($"/usr/bin/forkop clash_api set_group_proxy {ShellHelper.EscapeArg(pair.Key)} {ShellHelper.EscapeArg(pair.Value)}", 15); + Assert.Equal(0, result.ExitCode); + } + var restored = await ReadSelectors(); + Assert.Equal(original.OrderBy(p => p.Key), restored.OrderBy(p => p.Key)); + var finalTheme = await ssh.ExecuteCommandAsync("uci -q get luci.main.mediaurlbase", 5); + Assert.Equal(theme.Output.Trim(), finalTheme.Output.Trim()); + report.Add(new { OriginalSelectorsRestored = true, OriginalThemeUnchanged = true }); + var path = Environment.GetEnvironmentVariable("OPENWRT_VPN_REPORT_PATH"); + if (!string.IsNullOrEmpty(path)) + await File.WriteAllTextAsync(path, JsonSerializer.Serialize(report, new JsonSerializerOptions { WriteIndented = true })); + await ssh.DisconnectAsync(); + } + } +} diff --git a/OpenWrtStudio.Core.Tests/OpenWrtStudio.Core.Tests.csproj b/OpenWrtStudio.Core.Tests/OpenWrtStudio.Core.Tests.csproj new file mode 100644 index 0000000..e60b3c1 --- /dev/null +++ b/OpenWrtStudio.Core.Tests/OpenWrtStudio.Core.Tests.csproj @@ -0,0 +1,17 @@ + + net8.0enableenabletrue + + + + + + + + + + + + + + + diff --git a/OpenWrtStudio.Core.Tests/VpnSelectionTests.cs b/OpenWrtStudio.Core.Tests/VpnSelectionTests.cs new file mode 100644 index 0000000..a69f5dc --- /dev/null +++ b/OpenWrtStudio.Core.Tests/VpnSelectionTests.cs @@ -0,0 +1,53 @@ +using OpenWrtStudio.Models; +using OpenWrtStudio.Services; +using Xunit; +namespace OpenWrtStudio.Core.Tests; +public class VpnSelectionTests +{ + [Theory] + [InlineData(true, 0, true)] + [InlineData(false, 0, false)] + [InlineData(true, 1, false)] + public async Task SwitchingRequiresSuccessAndConfirmedRuntimeState(bool applied, int exitCode, bool expected) + { + var ssh = new RecordingSsh(applied, exitCode); var service = new ForkopService(ssh); + var nodes = await service.GetServersAsync(); + var result = await service.SelectActiveServerAsync(nodes.Single(n => n.GroupTag == "node-a")); + Assert.Equal(expected, result.Success); + var command = Assert.Single(ssh.Commands.Where(c => c.Contains("set_group_proxy"))); + Assert.Contains("'main-out' 'node-a'", command); + Assert.DoesNotContain("|| true", command); Assert.DoesNotContain("uci set", command); + } + [Fact] + public async Task RemovedOrUnselectableTargetDoesNotSendSwitchCommand() + { + var ssh = new RecordingSsh(true,0); var service = new ForkopService(ssh); + Assert.False((await service.SelectActiveServerAsync(new() { GroupTag = "direct-out", Name = "Direct" })).Success); + Assert.False((await service.SelectActiveServerAsync(new() { GroupTag = "missing" })).Success); + Assert.DoesNotContain(ssh.Commands, c => c.Contains("set_group_proxy")); + } + private sealed class RecordingSsh(bool applied,int exitCode) : ISshService + { + public List Commands { get; } = new(); + private bool switched; + public bool IsConnected => true; + public ConnectionProfile? CurrentProfile => null; + public event EventHandler? ConnectionChanged { add { } remove { } } + public Task<(int,string,string)> ExecuteCommandAsync(string command,int timeoutSeconds=30) + { + Commands.Add(command); + if (command.Contains("set_group_proxy")) { switched = applied && exitCode==0; return Task.FromResult((exitCode,"","denied")); } + var snapshot=VpnTopologyTests.Snapshot(); + if(switched) snapshot=snapshot.Replace("\"now\": \"priority-route\"", "\"now\": \"node-a\""); + return Task.FromResult((0,snapshot,"")); + } + public Task<(bool,string)> ConnectAsync(ConnectionProfile p)=>throw new NotSupportedException(); + public Task DisconnectAsync()=>throw new NotSupportedException(); + public Task RunStreamingCommandAsync(string c,Action a,CancellationToken t)=>throw new NotSupportedException(); + public Task<(bool,string)> UploadFileContentAsync(string c,string p)=>throw new NotSupportedException(); + public Task<(bool,string)> DownloadFileContentAsync(string p)=>throw new NotSupportedException(); + public Task<(bool,byte[]?,string)> DownloadBinaryFileAsync(string p)=>throw new NotSupportedException(); + public Task<(bool,string)> UploadBinaryFileAsync(byte[] d,string p)=>throw new NotSupportedException(); + public Task<(bool,string)> TestConnectionAsync(ConnectionProfile p)=>throw new NotSupportedException(); + } +} diff --git a/OpenWrtStudio.Core.Tests/VpnTopologyTests.cs b/OpenWrtStudio.Core.Tests/VpnTopologyTests.cs new file mode 100644 index 0000000..2f9880e --- /dev/null +++ b/OpenWrtStudio.Core.Tests/VpnTopologyTests.cs @@ -0,0 +1,77 @@ +using System.Text.Json; +using System.IO; +using OpenWrtStudio.Models; +using OpenWrtStudio.Services; +using Xunit; + +namespace OpenWrtStudio.Core.Tests; + +public class VpnTopologyTests +{ + public static string Snapshot() + { + using var json = JsonDocument.Parse(File.ReadAllText(Path.Combine(AppContext.BaseDirectory, "Fixtures/vpn_topology.json"))); + var f = json.RootElement; + return $"===SECTION_CACHE===\n{string.Join("\n", f.GetProperty("cache").EnumerateArray().Select(x => x.GetRawText()))}\n===SINGBOX_CONFIG===\n{f.GetProperty("config").GetRawText()}\n===CLASH_PROXIES===\n{{\"proxies\":{f.GetProperty("proxies").GetRawText()}}}"; + } + [Fact] + public void ReadsActualGroupsFromAllCachesAndHidesInfrastructure() + { + var nodes = ForkopNodeParser.Parse(Snapshot()); + Assert.Equal(5, nodes.Count(n => n.Protocol == "URLTest")); + Assert.Contains(nodes, n => n.Name == "Авто Hella (Запасной 3)"); + Assert.Contains(nodes, n => n.Name == "Приоритет: Shrimp -> Stealth -> Blanc -> Hella -> Free" && n.Protocol == "Priority"); + Assert.DoesNotContain(nodes, n => new[] { "direct-out", "bypass-out", "GLOBAL", "REJECT" }.Contains(n.GroupTag)); + Assert.Equal(nodes.Count, nodes.Select(n => n.GroupTag).Distinct().Count()); + } + [Fact] + public void ActiveSelectionFollowsTheRoutingRootNotLastSelectorOrNameSubstring() + { + var nodes = ForkopNodeParser.Parse(Snapshot()); + Assert.Equal(new[] { "auto-shrimp", "main-out", "node-b", "priority-route" }, nodes.Where(n => n.IsOnActivePath).Select(n => n.GroupTag).OrderBy(x => x)); + Assert.False(nodes.Single(n => n.GroupTag == "node-a").IsActive); + Assert.Equal("priority-route", Assert.Single(nodes.Where(n => n.IsAutoGroup && n.IsActive)).GroupTag); + Assert.Equal(167, nodes.Single(n => n.GroupTag == "priority-route").LatencyMs); + Assert.Null(nodes.Single(n => n.GroupTag == "node-b-plus").LatencyMs); + Assert.Null(nodes.Single(n => n.GroupTag == "auto-blanc").LatencyMs); + Assert.Equal("main-out", nodes.Single(n => n.GroupTag == "node-a").SelectionGroupTag); + } + [Fact] + public void EmptyMalformedOrOfflineSnapshotsNeverInventGroupsOrActiveNodes() + { + Assert.Empty(ForkopNodeParser.Parse("")); + Assert.Empty(ForkopNodeParser.Parse("===CLASH_PROXIES===\n{bad")); + var configOnly = "===SINGBOX_CONFIG===\n{\"outbounds\":[{\"tag\":\"custom\",\"type\":\"urltest\",\"outbounds\":[\"node\"]},{\"tag\":\"node\",\"type\":\"vless\",\"server\":\"example.org\"}]}"; + var nodes = ForkopNodeParser.Parse(configOnly); + Assert.Equal(2, nodes.Count); + Assert.All(nodes, n => { Assert.False(n.IsActive); Assert.Null(n.LatencyMs); Assert.Empty(n.SelectionGroupTag); }); + } + [Fact] + public void HandlesLargeTopologiesCyclesAndUnorderedHistory() + { + var proxies = Enumerable.Range(0, 300).ToDictionary(i => $"node-{i}", _ => new { type = "VLESS" }); + Assert.Equal(300, ForkopNodeParser.Parse("===CLASH_PROXIES===\n" + JsonSerializer.Serialize(new { proxies })).Count); + var cycle = Snapshot().Replace("\"now\": \"auto-shrimp\"", "\"now\": \"main-out\""); + Assert.DoesNotContain(ForkopNodeParser.Parse(cycle), n => !n.IsAutoGroup && n.IsActive); + const string history = "===CLASH_PROXIES===\n{\"proxies\":{\"test\":{\"type\":\"VLESS\",\"history\":[{\"time\":\"2026-10-19T01:00:00Z\",\"delay\":7},{\"time\":\"2026-10-18T01:00:00Z\",\"delay\":99}]}}}"; + Assert.Equal(7, Assert.Single(ForkopNodeParser.Parse(history)).LatencyMs); + } + [Fact] + public async Task DisconnectedServiceReturnsNoSampleNodesSubscriptionsOrLatency() + { + using var ssh = new SshService(); var service = new ForkopService(ssh); + Assert.Empty(await service.GetServersAsync()); Assert.Empty(await service.GetSubscriptionInfoListAsync()); + var existing = new ForkopServerNode { LatencyMs = 123 }; + await service.TestLatenciesAsync(new() { existing }); Assert.Null(existing.LatencyMs); + Assert.False((await service.SelectActiveServerAsync(existing)).Success); + } + [Fact] + public void LatencyChangesNotifyTheDisplayedValueIncludingTimeout() + { + var node = new ForkopServerNode(); var names = new List(); + node.PropertyChanged += (_, e) => names.Add(e.PropertyName); + node.LatencyMs = 100; node.LatencyMs = null; + Assert.Equal(2, names.Count(n => n == nameof(node.LatencyDisplay))); + Assert.Equal("N/A", node.LatencyDisplay); + } +} diff --git a/OpenWrtStudio.Tests/CoreRegressionTests.cs b/OpenWrtStudio.Tests/CoreRegressionTests.cs index a79e5cf..8e7e5cf 100644 --- a/OpenWrtStudio.Tests/CoreRegressionTests.cs +++ b/OpenWrtStudio.Tests/CoreRegressionTests.cs @@ -255,7 +255,7 @@ public void Monitor_RejectsInvalidPollingIntervals(int interval) public async Task Dashboard_ConcurrentRefreshesUseOnePollAndKeepLoadingUntilItFinishes() { var router = new FakeRouter { Pending = new(TaskCreationOptions.RunContinuationsAsynchronously) }; - var dashboard = new DashboardViewModel(router, new FakeSsh()); + var dashboard = new DashboardViewModel(router, new FakeSsh(), action => action()); var first = dashboard.RefreshDashboardAsync(); await router.Started.Task.WaitAsync(TimeSpan.FromSeconds(3)); await dashboard.RefreshDashboardAsync().WaitAsync(TimeSpan.FromSeconds(3)); @@ -275,7 +275,7 @@ public async Task Dashboard_DisconnectDiscardsOldSnapshotEvenAfterReconnecting(b { var router = new FakeRouter { Pending = new(TaskCreationOptions.RunContinuationsAsynchronously) }; var ssh = new FakeSsh(); - var dashboard = new DashboardViewModel(router, ssh); + var dashboard = new DashboardViewModel(router, ssh, action => action()); var refresh = dashboard.RefreshDashboardAsync(); await router.Started.Task.WaitAsync(TimeSpan.FromSeconds(3)); ssh.SetConnected(false); @@ -296,7 +296,7 @@ public async Task Dashboard_DisconnectDiscardsOldRefreshError(bool reconnect) { var router = new FakeRouter { Pending = new(TaskCreationOptions.RunContinuationsAsynchronously) }; var ssh = new FakeSsh(); - var dashboard = new DashboardViewModel(router, ssh); + var dashboard = new DashboardViewModel(router, ssh, action => action()); var refresh = dashboard.RefreshDashboardAsync(); await router.Started.Task.WaitAsync(TimeSpan.FromSeconds(3)); ssh.SetConnected(false); @@ -312,7 +312,7 @@ public async Task Dashboard_DisconnectDiscardsOldRefreshError(bool reconnect) public async Task Dashboard_CurrentRefreshErrorIsVisibleAndReleasesLoadingState() { var router = new FakeRouter { Pending = new(TaskCreationOptions.RunContinuationsAsynchronously) }; - var dashboard = new DashboardViewModel(router, new FakeSsh()); + var dashboard = new DashboardViewModel(router, new FakeSsh(), action => action()); var refresh = dashboard.RefreshDashboardAsync(); router.Pending.SetException(new IOException("Router request failed")); await refresh.WaitAsync(TimeSpan.FromSeconds(3)); diff --git a/OpenWrtStudio.Tests/ForkopTests.cs b/OpenWrtStudio.Tests/ForkopTests.cs index 54dd54b..fafbb32 100644 --- a/OpenWrtStudio.Tests/ForkopTests.cs +++ b/OpenWrtStudio.Tests/ForkopTests.cs @@ -1,132 +1,9 @@ -using System; -using System.Linq; -using System.Threading.Tasks; using OpenWrtStudio.Models; using OpenWrtStudio.Services; using Xunit; - namespace OpenWrtStudio.Tests; - public class ForkopTests { - [Fact] - public async Task ForkopService_GetSectionConfigAsync_ReturnsDefaultSubscriptionsAndRulesets() - { - var service = new ForkopService(new SshService()); - var section = await service.GetSectionConfigAsync(); - - Assert.NotNull(section); - Assert.True(section.IsEnabled); - Assert.Equal("VPN Прокси", section.Name); - Assert.Equal("Connection", section.Action); - Assert.Equal("br-lan", section.SelectedInterface); - - Assert.True(section.SubscriptionUrls.Count >= 4); - Assert.Contains(section.SubscriptionUrls, s => s.Contains("blancvpn")); - Assert.Contains(section.SubscriptionUrls, s => s.Contains("stealthsurf")); - Assert.Contains(section.SubscriptionUrls, s => s.Contains("abuzvpn")); - Assert.Contains(section.SubscriptionUrls, s => s.Contains("vlessforu")); - - Assert.True(section.Rulesets.Count >= 10); - Assert.Contains("Russia inside", section.Rulesets); - Assert.Contains("Youtube", section.Rulesets); - Assert.Contains("Discord", section.Rulesets); - Assert.Contains("Telegram", section.Rulesets); - } - - [Fact] - public async Task ForkopService_GetSubscriptionInfoListAsync_ContainsAllLiveSubscriptions() - { - var service = new ForkopService(new SshService()); - var list = await service.GetSubscriptionInfoListAsync(); - - Assert.NotEmpty(list); - Assert.Contains(list, s => s.Name == "BlancVPN"); - Assert.Contains(list, s => s.Name == "StealthSurf" && s.TrafficUsed.Contains("230")); - Assert.Contains(list, s => s.Name == "AbuzVPN" && s.TrafficTotal.Contains("32.2 GB")); - Assert.Contains(list, s => s.Name.Contains("Vlessforu") && s.ServerCount == 207); - } - - [Fact] - public async Task ForkopService_GetServersAsync_ContainsAutoGroupsAndNodes() - { - var service = new ForkopService(new SshService()); - var servers = await service.GetServersAsync(); - - Assert.NotEmpty(servers); - var priorityGroup = servers.FirstOrDefault(s => s.Protocol == "Priority"); - Assert.NotNull(priorityGroup); - Assert.True(priorityGroup.IsActive); - Assert.True(priorityGroup.IsAutoGroup); - - var hysteriaNode = servers.FirstOrDefault(s => s.Protocol == "Hysteria2"); - Assert.NotNull(hysteriaNode); - Assert.True(hysteriaNode.LatencyMs > 0); - Assert.Equal($"{hysteriaNode.LatencyMs}ms", hysteriaNode.LatencyDisplay); - } - - [Fact] - public async Task ForkopService_TestLatenciesAsync_UpdatesAllNodeLatencies() - { - var service = new ForkopService(new SshService()); - var servers = await service.GetServersAsync(); - - var updated = await service.TestLatenciesAsync(servers); - Assert.Equal(servers.Count, updated.Count); - Assert.All(updated.Where(s => s.Protocol != "URLTest" || s.Name.Contains("Stealth")), s => Assert.NotNull(s.LatencyMs)); - } - - [Fact] - public void ForkopServerNode_LatencyDisplay_FormatsCorrectly() - { - var nodeWithPing = new ForkopServerNode { LatencyMs = 236 }; - Assert.Equal("236ms", nodeWithPing.LatencyDisplay); - - var nodeNull = new ForkopServerNode { LatencyMs = null }; - Assert.Equal("N/A", nodeNull.LatencyDisplay); - } - - [Fact] - public void ForkopService_ParseAllRouterNodes_ParsesHundredsOfNodesCorrectly() - { - var service = new ForkopService(new SshService()); - var sampleOutput = @" -===CLASH_PROXIES=== -{ - ""proxies"": { - ""main-priority-main_priority-out"": { ""type"": ""Selector"", ""now"": ""Blanc 🇳🇱 Amsterdam"" }, - ""Blanc 🇳🇱 Amsterdam"": { ""type"": ""VLESS"", ""history"": [{ ""delay"": 210 }] }, - ""Stealth 🚀 Fast"": { ""type"": ""Hysteria2"", ""history"": [{ ""delay"": 236 }] }, - ""Abuz ⚡ Poland"": { ""type"": ""VLESS"", ""history"": [{ ""delay"": 279 }] }, - ""Vless4U 🇫🇮 Finland"": { ""type"": ""VLESS"", ""history"": [{ ""delay"": 190 }] } - } -} -===SINGBOX_CONFIG=== -{ - ""outbounds"": [ - { ""tag"": ""Blanc 🇳🇱 Amsterdam"", ""type"": ""vless"", ""server"": ""143.20.254.145"", ""server_port"": 443 }, - { ""tag"": ""Stealth 🚀 Fast"", ""type"": ""hysteria2"", ""server"": ""93.114.47.73"", ""server_port"": 443 }, - { ""tag"": ""Abuz ⚡ Poland"", ""type"": ""vless"", ""server"": ""93.114.47.95"", ""server_port"": 443 }, - { ""tag"": ""Vless4U 🇫🇮 Finland"", ""type"": ""vless"", ""server"": ""93.114.47.162"", ""server_port"": 443 } - ] -} -===SECTION_CACHE=== -{ - ""servers"": { - ""Blanc 🇳🇱 Amsterdam"": ""143.20.254.145"", - ""Stealth 🚀 Fast"": ""93.114.47.73"" - } -} -"; - var nodes = service.ParseAllRouterNodes(sampleOutput); - Assert.NotEmpty(nodes); - Assert.True(nodes.Count >= 8); // 4 auto groups + 4 servers - Assert.Contains(nodes, n => n.IsAutoGroup && n.Protocol == "Priority"); - Assert.Contains(nodes, n => n.Name == "Blanc 🇳🇱 Amsterdam" && n.IsActive && n.LatencyMs == 210); - Assert.Contains(nodes, n => n.Name == "Stealth 🚀 Fast" && n.Protocol == "Hysteria2" && n.Provider == "Stealth"); - Assert.Contains(nodes, n => n.Name == "Vless4U 🇫🇮 Finland" && n.Provider == "Vless4U"); - } - [Fact] public void ForkopViewModel_ApplyServerFilter_FiltersByProviderAndQuery() { diff --git a/OpenWrtStudio.Tests/OpenWrtStudio.Tests.csproj b/OpenWrtStudio.Tests/OpenWrtStudio.Tests.csproj index dfe18ad..e30d004 100644 --- a/OpenWrtStudio.Tests/OpenWrtStudio.Tests.csproj +++ b/OpenWrtStudio.Tests/OpenWrtStudio.Tests.csproj @@ -22,6 +22,8 @@ + + diff --git a/OpenWrtStudio.csproj b/OpenWrtStudio.csproj index 05b25fa..77417f1 100644 --- a/OpenWrtStudio.csproj +++ b/OpenWrtStudio.csproj @@ -12,13 +12,14 @@ true true true - 2.5.19 - 2.5.19.0 - 2.5.19.0 + 2.5.20 + 2.5.20.0 + 2.5.20.0 + diff --git a/README.md b/README.md index bcfcb93..2a6faa4 100644 --- a/README.md +++ b/README.md @@ -1,6 +1,6 @@
-# 🌐 OpenWrt Studio (WrtPilot) v2.5.19 +# 🌐 OpenWrt Studio (WrtPilot) v2.5.20 — кандидат с исправлением VPN **Современный центр управления, глубокой настройки, мониторинга и автоматического устранения неполадок для роутеров OpenWrt.** @@ -16,6 +16,12 @@ --- +## Исправление VPN в 2.5.20 + +Удалены заранее заданные авто-группы, демонстрационные данные и случайные задержки. Имена и raw tag читаются из реальных кэшей ForkOP и Clash API. Активная политика секции отделена от вложенного пути маршрутизации; переключение подтверждается повторным чтением состояния. Сценарии проверяются общим с Android набором данных, основанным на схеме upstream ForkOP. + +Сборки кандидата Windows доступны в артефактах workflow Desktop build and tests. Linux x86_64 (AppImage и tar.gz) собирается из Flutter-приложения: [инструкция](https://github.com/RichkovGit/OpenWrtStudio-Mobile/blob/codex/fix-vpn-selection/LINUX_INSTALL.md). WPF-интерфейс Windows под Linux не переносится. + ## 📖 О проекте **OpenWrt Studio** — это автономное настольное Windows-приложение, созданное на стыке высоких технологий и максимального удобства («всё для людей»). Программа заменяет десятки разрозненных веб-страниц LuCI и терминальных команд единым, быстрым и красивым Fluent Design интерфейсом. diff --git a/RELEASE_NOTES.md b/RELEASE_NOTES.md index 663547a..42c0bab 100644 --- a/RELEASE_NOTES.md +++ b/RELEASE_NOTES.md @@ -1,3 +1,20 @@ +# OpenWrt Studio v2.5.20 + +Проверено 19.10.2026 на реальном роутере и Android-телефоне: переключения подтверждены независимым чтением состояния, исходный VPN и тема восстановлены. Подробности: [VPN_VALIDATION.md](VPN_VALIDATION.md). + +Исправлено отображение и переключение VPN/ForkOP: + +- Группы, их имена и порядок выбора читаются из конфигурации, кэшей и живого API. Четыре заранее заданные группы больше не создаются. +- Выбранный путь вычисляется от корня маршрутизации через вложенные селекторы. Неактивные группы и похожие имена не подменяют текущий сервер. +- Direct/bypass/reject исключены из VPN-серверов. Поддерживаются несколько последовательных JSON-кэшей. +- Удалены демонстрационные серверы/подписки и случайные задержки. Неизвестное время отображается как N/A. +- Команда переключения отправляется только в фактический селектор, содержащий нужный raw tag. Успех подтверждается новым снимком роутера; до этого активные метки не меняются. +- Добавлены общие сценарии с Android и независимые от WPF тесты парсера/команд. + +Linux GUI собирается из Flutter-версии в [OpenWrtStudio-Mobile](https://github.com/RichkovGit/OpenWrtStudio-Mobile), с AppImage/tar.gz и отдельной инструкцией. WPF-приложение остаётся Windows-приложением. + +--- + # OpenWrt Studio v2.5.19 Обновлённый обзор сети, отчёт для поддержки и исправления сохранения настроек роутера, SSH, профилей и обновлений приложения. diff --git a/Services/ForkopNodeParser.cs b/Services/ForkopNodeParser.cs new file mode 100644 index 0000000..319e6b1 --- /dev/null +++ b/Services/ForkopNodeParser.cs @@ -0,0 +1,192 @@ +using System.Text; +using System.Text.Json; +using OpenWrtStudio.Models; + +namespace OpenWrtStudio.Services; + +/// Combines configured metadata with live Clash state without inventing nodes or selections. +public static class ForkopNodeParser +{ + private sealed class Entry(string tag) + { + public string Tag = tag, Name = tag, Type = "", Server = "", Now = ""; + public int Port; + public int? Delay; + public bool Live, Mutable, HasDelaySample; + public List Members = new(); + } + + public static List Parse(string output) + { + var entries = new Dictionary(StringComparer.Ordinal); + Entry Get(string tag) => entries.TryGetValue(tag, out var e) ? e : entries[tag] = new(tag); + string final = ""; + var sections = new HashSet(StringComparer.Ordinal); + foreach (var doc in Documents(Section(output, "SINGBOX_CONFIG"))) + { + using (doc) + { + var root = doc.RootElement; + if (root.TryGetProperty("route", out var route)) final = Text(route, "final"); + if (!root.TryGetProperty("outbounds", out var list) || list.ValueKind != JsonValueKind.Array) continue; + foreach (var item in list.EnumerateArray()) + { + var tag = Text(item, "tag"); if (tag.Length == 0) continue; + var e = Get(tag); e.Type = Text(item, "type"); e.Server = Text(item, "server"); + e.Port = Number(item, "server_port") ?? 0; + e.Members = Strings(item, "outbounds"); + e.Mutable = e.Type.Equals("selector", StringComparison.OrdinalIgnoreCase); + } + } + } + foreach (var doc in Documents(Section(output, "SECTION_CACHE"))) + { + using (doc) + { + var root = doc.RootElement; + var section = Text(root, "section"); if (section.Length > 0) sections.Add(section); + if (root.TryGetProperty("servers", out var servers) && servers.ValueKind == JsonValueKind.Object) + foreach (var p in servers.EnumerateObject()) + { + var e = Get(p.Name); + if (p.Value.ValueKind == JsonValueKind.String) e.Server = p.Value.GetString() ?? ""; + else Metadata(e, p.Value); + } + if (root.TryGetProperty("outboundMetadata", out var metadata) && metadata.ValueKind == JsonValueKind.Object) + { + if (metadata.TryGetProperty("names", out var names) && names.ValueKind == JsonValueKind.Object) + foreach (var p in names.EnumerateObject()) + if (p.Value.ValueKind == JsonValueKind.String) Get(p.Name).Name = p.Value.GetString() ?? p.Name; + if (metadata.TryGetProperty("protocols", out var protocols) && protocols.ValueKind == JsonValueKind.Object) + foreach (var p in protocols.EnumerateObject()) + if (p.Value.ValueKind == JsonValueKind.String) Get(p.Name).Type = p.Value.GetString() ?? ""; + foreach (var p in metadata.EnumerateObject()) + if (!new[] { "names", "protocols", "countries", "transports", "securities" }.Contains(p.Name)) Metadata(Get(p.Name), p.Value); + } + foreach (var (key, kind) in new[] { ("urltestGroups", "URLTest"), ("priorityGroups", "Priority") }) + { + if (!root.TryGetProperty(key, out var groups)) continue; + if (groups.ValueKind == JsonValueKind.Object) + foreach (var p in groups.EnumerateObject()) + { + var tag = Text(p.Value, "tag"); if (tag.Length == 0) tag = p.Name; + var e = Get(tag); Metadata(e, p.Value); e.Type = kind; + if (p.Value.ValueKind == JsonValueKind.String) e.Name = p.Value.GetString() ?? tag; + if (p.Value.ValueKind == JsonValueKind.Array) e.Members = p.Value.EnumerateArray().Where(x => x.ValueKind == JsonValueKind.String).Select(x => x.GetString()!).ToList(); + } + } + } + } + foreach (var doc in Documents(Section(output, "CLASH_PROXIES"))) + { + using (doc) + { + if (!doc.RootElement.TryGetProperty("proxies", out var proxies) || proxies.ValueKind != JsonValueKind.Object) continue; + foreach (var p in proxies.EnumerateObject()) + { + if (p.Value.ValueKind != JsonValueKind.Object) continue; + var e = Get(p.Name); var type = Text(p.Value, "type"); + // Cache metadata may describe a Priority policy implemented as a live Selector. + if (e.Type != "Priority" || !type.Equals("Selector", StringComparison.OrdinalIgnoreCase)) e.Type = type.Length > 0 ? type : e.Type; + e.Live = true; e.Mutable = type.Equals("Selector", StringComparison.OrdinalIgnoreCase); + e.Now = Text(p.Value, "now"); + if (p.Value.TryGetProperty("all", out _)) e.Members = Strings(p.Value, "all"); + e.Delay = Delay(p.Value); + e.HasDelaySample = p.Value.TryGetProperty("history", out var samples) && samples.ValueKind == JsonValueKind.Array && samples.GetArrayLength() > 0 || Number(p.Value, "delay").HasValue; + } + } + } + bool Group(Entry e) => e.Members.Count > 0 || new[] { "selector", "urltest", "priority", "fallback", "loadbalance", "load-balance" }.Contains(e.Type.ToLowerInvariant()); + bool Internal(Entry e) => new[] { "direct", "reject", "block", "dns", "pass", "compatible" }.Contains(e.Type.ToLowerInvariant()) || new[] { "DIRECT", "REJECT", "GLOBAL", "direct-out", "bypass-out", "block-out", "dns-out" }.Contains(e.Tag); + var groupsList = entries.Values.Where(e => Group(e) && !Internal(e)).ToList(); + var referenced = groupsList.SelectMany(e => e.Members).ToHashSet(StringComparer.Ordinal); + var roots = groupsList.Where(e => !referenced.Contains(e.Tag)).ToList(); + var rootTags = sections.Select(section => section + "-out" + (section is "direct" or "bypass" ? "-1" : "")) + .Where(tag => entries.TryGetValue(tag, out var e) && Group(e) && !Internal(e)).ToHashSet(StringComparer.Ordinal); + if (rootTags.Count == 0) + { + if (entries.TryGetValue(final, out var e) && Group(e) && !Internal(e)) rootTags.Add(final); + else if (entries.TryGetValue("main-out", out e) && Group(e)) rootTags.Add("main-out"); + else if (roots.Count == 1) rootTags.Add(roots[0].Tag); + } + var active = new HashSet(StringComparer.Ordinal); + foreach (var root in rootTags) + { + var current = root; + while (entries.TryGetValue(current, out var selected) && selected.Live && active.Add(current)) current = selected.Now; + } + var selectedPolicies = rootTags.Select(tag => entries[tag].Now).ToHashSet(StringComparer.Ordinal); + int? ResolveDelay(Entry e, HashSet seen) + { + if (!seen.Add(e.Tag)) return null; + if (e.HasDelaySample) return e.Delay; + return entries.TryGetValue(e.Now, out var next) ? ResolveDelay(next, seen) : null; + } + string SelectGroup(Entry target) + { + var parents = groupsList.Where(e => e.Live && e.Mutable && e.Tag != target.Tag && e.Members.Contains(target.Tag)).ToList(); + var directParents = parents.Where(e => rootTags.Contains(e.Tag)).ToList(); + if (directParents.Count == 1) return directParents[0].Tag; + var activeParents = parents.Where(e => active.Contains(e.Tag)).ToList(); + return activeParents.Count == 1 ? activeParents[0].Tag : parents.Count == 1 ? parents[0].Tag : ""; + } + return entries.Values.Where(e => !Internal(e) && (Group(e) || e.Live || e.Server.Length > 0)) + .OrderByDescending(Group).ThenBy(e => e.Name, StringComparer.Ordinal) + .Select(e => new ForkopServerNode + { + Id = e.Tag, GroupTag = e.Tag, Name = e.Name, Protocol = Protocol(e.Type), + ServerAddress = e.Server, ServerPort = e.Port, Provider = Provider(e.Name), + Subtitle = Group(e) ? (e.Now.Length > 0 ? $"Выбран: {(entries.TryGetValue(e.Now, out var next) ? next.Name : e.Now)}" : "Текущий выбор недоступен") : e.Server.Length > 0 ? $"{e.Server}{(e.Port > 0 ? $":{e.Port}" : "")}" : "Прокси-сервер", + CurrentNode = e.Now, Candidates = e.Members, SelectionGroupTag = SelectGroup(e), + HasLiveState = e.Live, IsAutoGroup = Group(e), IsActive = Group(e) ? selectedPolicies.Contains(e.Tag) : active.Contains(e.Tag), IsOnActivePath = active.Contains(e.Tag), + LatencyMs = ResolveDelay(e, new(StringComparer.Ordinal)) + }).ToList(); + } + + private static void Metadata(Entry e, JsonElement item) + { + if (item.ValueKind != JsonValueKind.Object) return; + foreach (var field in new[] { "displayName", "label", "name", "title" }) + { var label = Text(item, field); if (label.Length > 0) { e.Name = label; break; } } + var type = Text(item, "type"); if (type.Length > 0) e.Type = type; + var server = Text(item, "server"); if (server.Length > 0) e.Server = server; + e.Port = Number(item, "server_port") ?? Number(item, "port") ?? e.Port; + foreach (var field in new[] { "outbounds", "all", "members", "nodes" }) + if (item.TryGetProperty(field, out var list) && list.ValueKind == JsonValueKind.Array) { e.Members = Strings(item, field); break; } + } + private static int? Delay(JsonElement item) + { + if (item.TryGetProperty("history", out var history) && history.ValueKind == JsonValueKind.Array && history.GetArrayLength() > 0) + { + var latest = history[history.GetArrayLength() - 1]; + var dated = history.EnumerateArray().Select(entry => (Entry: entry, Valid: DateTimeOffset.TryParse(Text(entry, "time"), out var time), Time: time)).Where(x => x.Valid).OrderBy(x => x.Time).ToList(); + if (dated.Count > 0) latest = dated[^1].Entry; + var value = Number(latest, "delay"); return value > 0 ? value : null; + } + var delay = Number(item, "delay"); return delay > 0 ? delay : null; + } + private static string Text(JsonElement item, string key) => item.ValueKind == JsonValueKind.Object && item.TryGetProperty(key, out var v) && v.ValueKind == JsonValueKind.String ? v.GetString() ?? "" : ""; + private static int? Number(JsonElement item, string key) => item.ValueKind == JsonValueKind.Object && item.TryGetProperty(key, out var v) && v.ValueKind == JsonValueKind.Number && v.TryGetInt32(out var n) ? n : null; + private static List Strings(JsonElement item, string key) => item.TryGetProperty(key, out var v) && v.ValueKind == JsonValueKind.Array ? v.EnumerateArray().Where(x => x.ValueKind == JsonValueKind.String).Select(x => x.GetString()!).ToList() : new(); + public static string Section(string text, string tag) + { + var start = text.IndexOf($"==={tag}===", StringComparison.Ordinal); if (start < 0) return ""; + start += tag.Length + 6; var end = text.IndexOf("===", start, StringComparison.Ordinal); + return (end < 0 ? text[start..] : text[start..end]).Trim(); + } + public static IEnumerable Documents(string text) + { + // Each section-cache file is a separate JSON document; concatenating them is not a JSON object. + var bytes = Encoding.UTF8.GetBytes(text); var offset = 0; var docs = new List(); + while (offset < bytes.Length) + { + while (offset < bytes.Length && char.IsWhiteSpace((char)bytes[offset])) offset++; + if (offset == bytes.Length) break; + try { var reader = new Utf8JsonReader(bytes.AsSpan(offset)); var doc = JsonDocument.ParseValue(ref reader); if (doc.RootElement.ValueKind == JsonValueKind.Object) docs.Add(doc); else doc.Dispose(); offset += (int)reader.BytesConsumed; } + catch (JsonException) { break; } + } + return docs; + } + private static string Protocol(string type) => type.ToLowerInvariant() switch { "urltest" => "URLTest", "priority" => "Priority", "selector" => "Selector", "fallback" => "Fallback", "hysteria2" or "hy2" => "Hysteria2", "shadowsocks" or "ss" => "Shadowsocks", "vmess" => "VMess", "trojan" => "Trojan", "wireguard" => "WireGuard", "" => "Unknown", _ => type.ToUpperInvariant() }; + private static string Provider(string name) => name.Split(new[] { ' ', '|' }, StringSplitOptions.RemoveEmptyEntries).FirstOrDefault() ?? "Прочие"; +} diff --git a/Services/ForkopService.cs b/Services/ForkopService.cs index bc4d36b..04958b7 100644 --- a/Services/ForkopService.cs +++ b/Services/ForkopService.cs @@ -245,25 +245,6 @@ public async Task GetSectionConfigAsync() { var section = new ForkopSection(); - // Default subscriptions and rulesets matching Forkop configuration - var defaultSubs = new[] - { - "https://e3c21848.withblancvpn.online/s/95e38fa08f064eaeab14a8ee4a56ec189", - "https://subscriptions.production.stealthsurfconnectivity.net/to/3e3d1e0477f3065d55effb37", - "https://sub.abuzvpn.com/-WFFdGwSRQYSvDMR", - "https://sub.vlessfo.ru/vlessforu/working_configs.txt" - }; - - var defaultRules = new[] - { - "Russia inside", "Block", "Новости", "Porn", "Anime", "H.O.D.C.A", - "Geo Block", "Youtube", "Discord", "Meta", "Twitter (X)", "Google AI", - "HDRəzka", "Tik-Tok", "Telegram", "Roblox", "Supercell", "GitHub" - }; - - foreach (var s in defaultSubs) section.SubscriptionUrls.Add(s); - foreach (var r in defaultRules) section.Rulesets.Add(r); - if (!_ssh.IsConnected) return section; try @@ -412,10 +393,10 @@ public async Task GetDashboardStatsAsync() { const string statsCmd = @" echo '===PROCS===' -pidof forkop 2>/dev/null -pidof sing-box 2>/dev/null +[ -x /etc/init.d/forkop ] && /etc/init.d/forkop running >/dev/null 2>&1 && echo forkop:running +pidof sing-box >/dev/null 2>&1 && echo sing-box:running echo '===CONNS===' -wc -l < /proc/net/nf_conntrack 2>/dev/null || cat /proc/sys/net/netfilter/nf_conntrack_count 2>/dev/null || echo '35' +wc -l < /proc/net/nf_conntrack 2>/dev/null || cat /proc/sys/net/netfilter/nf_conntrack_count 2>/dev/null || echo '0' echo '===MEM===' free -m 2>/dev/null | grep Mem | awk '{printf ""%.1f MB"", $3}' echo '===DEV===' @@ -425,8 +406,8 @@ pidof sing-box 2>/dev/null if (code == 0 && !string.IsNullOrWhiteSpace(output)) { var procs = ExtractSection(output, "PROCS"); - stats.IsForkopRunning = procs.Contains("forkop") || procs.Length > 2; - stats.IsSingboxRunning = procs.Contains("sing-box") || procs.Length > 2; + stats.IsForkopRunning = procs.Contains("forkop:running"); + stats.IsSingboxRunning = procs.Contains("sing-box:running"); var conns = ExtractSection(output, "CONNS"); if (int.TryParse(conns.Trim(), out var count)) @@ -451,53 +432,7 @@ pidof sing-box 2>/dev/null public async Task> GetSubscriptionInfoListAsync() { - var defaultList = new List - { - new() - { - Name = "BlancVPN", - Url = "https://e3c21848.withblancvpn.online/s/...", - TrafficUsed = "1.2 GB", - TrafficTotal = "∞", - ExpireDate = "01.01.2030", - ServerCount = 49, - Description = "Основной провайдер обхода блокировок.", - TrafficPercentage = 5 - }, - new() - { - Name = "StealthSurf", - Url = "https://subscriptions.production.stealthsurfconnectivity.net/to/...", - TrafficUsed = "230.2 GB", - TrafficTotal = "∞", - ExpireDate = "09.10.2026", - ServerCount = 28, - Description = "Если перестал работать VPN или вы его обновили, то нажмите для применения новых настроек.", - TrafficPercentage = 15 - }, - new() - { - Name = "AbuzVPN", - Url = "https://sub.abuzvpn.com/-WFFdGwSRQYSvDMR", - TrafficUsed = "32.9 MB", - TrafficTotal = "32.2 GB", - ExpireDate = "26775 дней", - ServerCount = 42, - Description = "AbuzVPN — лучший ускоритель для интернета. Тарификация ведётся только на локациях WhiteList. ЕСЛИ НЕ РАБОТАЕТ — ОБНОВИТЕ ПОДПИСКУ", - TrafficPercentage = 0.5 - }, - new() - { - Name = "Tg: Vlessforu ❤️ FREE", - Url = "https://sub.vlessfo.ru/vlessforu/working_configs.txt", - TrafficUsed = "0 B", - TrafficTotal = "73.7 TB", - ExpireDate = "11.09.6767", - ServerCount = 207, - Description = "Серверов: 207 | Обновлено: 09.09.2026 15:05 | Обязательно подпишитесь на VlessForu в Телеграме! LTE для крайних случаев! Без торрентов!", - TrafficPercentage = 0.1 - } - }; + var defaultList = new List(); if (!_ssh.IsConnected) return defaultList; @@ -523,17 +458,7 @@ public async Task> GetSubscriptionInfoListAsync() public async Task> GetServersAsync() { - var defaultList = new List - { - new() { Name = "Авто Blanc (Основной)", Protocol = "URLTest", Subtitle = "Автовыбор лучшего", Provider = "Blanc", LatencyMs = null, IsAutoGroup = true }, - new() { Name = "Авто Stealth (Запасной)", Protocol = "URLTest", Subtitle = "Резервный пул", Provider = "Stealth", LatencyMs = 236, IsAutoGroup = true }, - new() { Name = "Авто Фри (Резерв)", Protocol = "URLTest", Subtitle = "Бесплатные ноды", Provider = "Vless4U", LatencyMs = null, IsAutoGroup = true }, - new() { Name = "Приоритет: Blanc -> Stealth -> Free", Protocol = "Priority", Subtitle = "Отказоустойчивая цепочка", Provider = "Системный", LatencyMs = 238, IsActive = true, IsAutoGroup = true }, - new() { Name = "Abuz 🇷🇺 GAME | Нидерланды", Protocol = "Hysteria2", Subtitle = "Быстрый UDP/Gaming", Provider = "Abuz", LatencyMs = 210 }, - new() { Name = "Stealth 🚀 Премиум-конфиг (ранний доступ)", Protocol = "Hysteria2", Subtitle = "Анти-DPI / Трафик", Provider = "Stealth", LatencyMs = 236 }, - new() { Name = "Abuz ⚡ WebSocket | Польша", Protocol = "VLESS", Subtitle = "CDN / Port 443", Provider = "Abuz", LatencyMs = 279 }, - new() { Name = "Abuz 🛡️ Shadowsocks | Польша", Protocol = "Shadowsocks", Subtitle = "Защищенный прокси", Provider = "Abuz", LatencyMs = 297 } - }; + var defaultList = new List(); if (!_ssh.IsConnected) return defaultList; @@ -543,9 +468,9 @@ public async Task> GetServersAsync() echo '===SECTION_CACHE===' cat /var/run/forkop/section-cache/*.json 2>/dev/null || cat /etc/forkop/section-cache/*.json 2>/dev/null echo '===SINGBOX_CONFIG===' -head -c 600000 /etc/sing-box/config.json 2>/dev/null +cat /etc/sing-box/config.json 2>/dev/null echo '===CLASH_PROXIES===' -/usr/bin/forkop clash_api get_proxies 2>/dev/null || curl -s http://192.168.10.1:9090/proxies 2>/dev/null || curl -s http://127.0.0.1:9090/proxies 2>/dev/null +/usr/bin/forkop clash_api get_proxies 2>/dev/null echo '===PASSWALL_NODES===' uci -q show passwall 2>/dev/null | grep -E '\.remarks=|\.type=|\.address=|\.port=' 2>/dev/null echo '===MIHOMO_CONFIG===' @@ -614,9 +539,9 @@ public List ParseSubscriptionMetadata(string jsonText) } else { - info.TrafficUsed = "1.2 GB"; + info.TrafficUsed = "—"; info.TrafficTotal = "∞"; - info.TrafficPercentage = 5.0; + info.TrafficPercentage = 0; } if (item.TryGetProperty("expire", out var expEl) && expEl.TryGetInt64(out var expVal)) @@ -672,188 +597,8 @@ public List ParseSubscriptionMetadata(string jsonText) public List ParseAllRouterNodes(string output) { - var result = new List(); - - var cacheText = ExtractSection(output, "SECTION_CACHE"); - var singboxText = ExtractSection(output, "SINGBOX_CONFIG"); - var clashText = ExtractSection(output, "CLASH_PROXIES"); - var passwallText = ExtractSection(output, "PASSWALL_NODES"); - var mihomoText = ExtractSection(output, "MIHOMO_CONFIG"); - - // 1. Parse Clash proxies for active node and latency history - string activeNodeName = ""; - var clashMap = new Dictionary(StringComparer.OrdinalIgnoreCase); - - if (!string.IsNullOrWhiteSpace(clashText)) - { - try - { - using var clashDoc = JsonDocument.Parse(clashText); - if (clashDoc.RootElement.TryGetProperty("proxies", out var proxiesObj) && proxiesObj.ValueKind == JsonValueKind.Object) - { - foreach (var prop in proxiesObj.EnumerateObject()) - { - var pName = prop.Name; - var pEl = prop.Value; - var pType = pEl.TryGetProperty("type", out var tEl) ? (tEl.GetString() ?? "") : ""; - var pNow = pEl.TryGetProperty("now", out var nEl) ? (nEl.GetString() ?? "") : ""; - - int? delay = null; - if (pEl.TryGetProperty("history", out var histEl) && histEl.ValueKind == JsonValueKind.Array && histEl.GetArrayLength() > 0) - { - var lastEntry = histEl.EnumerateArray().Last(); - if (lastEntry.TryGetProperty("delay", out var dEl) && dEl.TryGetInt32(out var dv) && dv > 0) - { - delay = dv; - } - } - - clashMap[pName] = (pType, pNow, delay); - - if (pName.Contains("priority", StringComparison.OrdinalIgnoreCase) || pName == "main-out" || pType.Equals("Selector", StringComparison.OrdinalIgnoreCase)) - { - if (!string.IsNullOrWhiteSpace(pNow)) - { - activeNodeName = pNow; - } - } - } - } - } - catch { } - } - - // 2. Parse Singbox Outbounds - var singboxMap = new Dictionary(StringComparer.OrdinalIgnoreCase); - if (!string.IsNullOrWhiteSpace(singboxText)) - { - try - { - using var sbDoc = JsonDocument.Parse(singboxText); - if (sbDoc.RootElement.TryGetProperty("outbounds", out var outboundsEl) && outboundsEl.ValueKind == JsonValueKind.Array) - { - foreach (var ob in outboundsEl.EnumerateArray()) - { - var tag = ob.TryGetProperty("tag", out var tagEl) ? (tagEl.GetString() ?? "") : ""; - if (string.IsNullOrWhiteSpace(tag)) continue; - - var obType = ob.TryGetProperty("type", out var tEl) ? (tEl.GetString() ?? "") : "vless"; - var srv = ob.TryGetProperty("server", out var srvEl) ? (srvEl.GetString() ?? "") : ""; - int port = 443; - if (ob.TryGetProperty("server_port", out var pEl) && pEl.TryGetInt32(out var pv)) - port = pv; - - singboxMap[tag] = (obType, srv, port); - } - } - } - catch { } - } - - // 3. Parse Cache JSON (servers map, urltest groups, priority groups) - var cacheServersMap = new Dictionary(StringComparer.OrdinalIgnoreCase); - if (!string.IsNullOrWhiteSpace(cacheText)) - { - try - { - using var cacheDoc = JsonDocument.Parse(cacheText); - if (cacheDoc.RootElement.TryGetProperty("servers", out var srvsObj) && srvsObj.ValueKind == JsonValueKind.Object) - { - foreach (var prop in srvsObj.EnumerateObject()) - { - var ip = prop.Value.ValueKind == JsonValueKind.String ? (prop.Value.GetString() ?? "") : prop.Value.ToString(); - cacheServersMap[prop.Name] = ip; - } - } - } - catch { } - } - - // 4. Build Auto-Groups (URLTest & Priority) - var autoGroupsDefs = new List<(string GroupTag, string DisplayName, string Protocol, string Subtitle, string Provider)> - { - ("main-urltest-blanc_urltest-out", "Авто Blanc (Основной)", "URLTest", "Автовыбор лучшего", "Blanc"), - ("main-urltest-stealth_urltest-out", "Авто Stealth (Запасной)", "URLTest", "Резервный пул", "Stealth"), - ("main-urltest-free_urltest-out", "Авто Фри (Резерв)", "URLTest", "Бесплатные ноды", "Vless4U"), - ("main-priority-main_priority-out", "Приоритет: Blanc -> Stealth -> Free", "Priority", "Отказоустойчивая цепочка", "Системный") - }; - - foreach (var (gTag, gName, gProto, gSub, gProv) in autoGroupsDefs) - { - clashMap.TryGetValue(gTag, out var cInfo); - var nowInGroup = cInfo.Now; - var subText = !string.IsNullOrWhiteSpace(nowInGroup) ? $"{gSub} (Активен: {nowInGroup})" : gSub; - - result.Add(new ForkopServerNode - { - Name = gName, - GroupTag = gTag, - Protocol = gProto, - Subtitle = subText, - Provider = gProv, - LatencyMs = cInfo.Latency ?? (gProto == "Priority" ? 238 : 236), - IsAutoGroup = true, - IsActive = (gProto == "Priority" && !string.IsNullOrWhiteSpace(activeNodeName)) || (activeNodeName.Equals(gTag, StringComparison.OrdinalIgnoreCase)) - }); - } - - // 5. Build all individual proxy server nodes (240+ servers) - var allServerTags = new HashSet(StringComparer.OrdinalIgnoreCase); - - foreach (var k in cacheServersMap.Keys) allServerTags.Add(k); - foreach (var k in singboxMap.Keys) allServerTags.Add(k); - foreach (var k in clashMap.Keys) allServerTags.Add(k); - - // Filter out groups, direct, bypass, global, default - var ignoredPrefixes = new[] { "GLOBAL", "direct-out", "bypass-out", "default", "main-out", "main-urltest", "main-priority" }; - - var sortedTags = allServerTags - .Where(t => !ignoredPrefixes.Any(p => t.StartsWith(p, StringComparison.OrdinalIgnoreCase))) - .OrderBy(t => t) - .ToList(); - - foreach (var tag in sortedTags) - { - singboxMap.TryGetValue(tag, out var sbInfo); - cacheServersMap.TryGetValue(tag, out var cacheIp); - clashMap.TryGetValue(tag, out var clInfo); - - var proto = !string.IsNullOrWhiteSpace(sbInfo.Type) ? sbInfo.Type : (!string.IsNullOrWhiteSpace(clInfo.Type) ? clInfo.Type : "VLESS"); - proto = FormatProtocolName(proto); - - var srvAddr = !string.IsNullOrWhiteSpace(cacheIp) ? cacheIp : (sbInfo.Server ?? ""); - int srvPort = sbInfo.Port > 0 ? sbInfo.Port : 443; - - var subtitle = !string.IsNullOrWhiteSpace(srvAddr) ? $"{srvAddr}:{srvPort}" : "Прокси-сервер"; - var provider = DetectProvider(tag); - - bool isActive = !string.IsNullOrWhiteSpace(activeNodeName) && (tag.Equals(activeNodeName, StringComparison.OrdinalIgnoreCase) || tag.Contains(activeNodeName, StringComparison.OrdinalIgnoreCase)); - - result.Add(new ForkopServerNode - { - Name = tag, - Protocol = proto, - Subtitle = subtitle, - Provider = provider, - ServerAddress = srvAddr, - ServerPort = srvPort, - LatencyMs = clInfo.Latency, - IsActive = isActive, - IsAutoGroup = false - }); - } - - // 6. Fallback: Parse PassWall nodes if Forkop returned no nodes - if (result.Count <= 4 && !string.IsNullOrWhiteSpace(passwallText)) - { - var pwNodes = ParsePasswallNodes(passwallText); - foreach (var pw in pwNodes) - { - result.Add(pw); - } - } - - return result; + var nodes = ForkopNodeParser.Parse(output); + return nodes.Count > 0 ? nodes : ParsePasswallNodes(ExtractSection(output, "PASSWALL_NODES")); } private static string DetectProvider(string name) @@ -897,7 +642,7 @@ private static List ParsePasswallNodes(string output) var key = match.Groups[2].Value; var val = match.Groups[3].Value; - if (!nodes.ContainsKey(id)) nodes[id] = ("", "VLESS", "", 443); + if (!nodes.ContainsKey(id)) nodes[id] = ("", "Unknown", "", 0); var cur = nodes[id]; if (key == "remarks") cur.Remarks = val; @@ -914,9 +659,10 @@ private static List ParsePasswallNodes(string output) if (string.IsNullOrWhiteSpace(node.Remarks) && string.IsNullOrWhiteSpace(node.Addr)) continue; list.Add(new ForkopServerNode { + Id = "passwall:" + id, GroupTag = id, Name = !string.IsNullOrWhiteSpace(node.Remarks) ? node.Remarks : $"PassWall {node.Addr}", Protocol = node.Type, - Subtitle = $"{node.Addr}:{node.Port}", + Subtitle = node.Port > 0 ? $"{node.Addr}:{node.Port}" : node.Addr, Provider = "PassWall", ServerAddress = node.Addr, ServerPort = node.Port, @@ -929,99 +675,44 @@ private static List ParsePasswallNodes(string output) public async Task> TestLatenciesAsync(List currentNodes) { - var rng = new Random(); - - if (_ssh.IsConnected) + if (!_ssh.IsConnected) { - try - { - // Trigger latency test on router - await _ssh.ExecuteCommandAsync("/usr/bin/forkop clash_api get_group_latency 'main-priority-main_priority-out' 2>/dev/null || true", 6); - - // Fetch fresh delay measurements from Clash API - var (code, outStr, _) = await _ssh.ExecuteCommandAsync("/usr/bin/forkop clash_api get_proxies 2>/dev/null || curl -s http://192.168.10.1:9090/proxies 2>/dev/null", 5); - if (code == 0 && !string.IsNullOrWhiteSpace(outStr)) - { - using var doc = JsonDocument.Parse(outStr); - if (doc.RootElement.TryGetProperty("proxies", out var proxiesObj) && proxiesObj.ValueKind == JsonValueKind.Object) - { - var delayMap = new Dictionary(StringComparer.OrdinalIgnoreCase); - foreach (var prop in proxiesObj.EnumerateObject()) - { - if (prop.Value.TryGetProperty("history", out var histEl) && histEl.ValueKind == JsonValueKind.Array && histEl.GetArrayLength() > 0) - { - var last = histEl.EnumerateArray().Last(); - if (last.TryGetProperty("delay", out var dEl) && dEl.TryGetInt32(out var dv) && dv > 0) - { - delayMap[prop.Name] = dv; - } - } - } - - foreach (var node in currentNodes) - { - if (delayMap.TryGetValue(node.Name, out var dl) || (!string.IsNullOrWhiteSpace(node.GroupTag) && delayMap.TryGetValue(node.GroupTag, out dl))) - { - node.LatencyMs = dl; - } - else - { - node.LatencyMs = rng.Next(180, 290); - } - } - - return currentNodes; - } - } - } - catch { } + foreach (var node in currentNodes) node.LatencyMs = null; + return currentNodes; } - - // Fallback / simulated latency for display + foreach (var group in currentNodes.Where(n => n.IsAutoGroup && n.Protocol == "URLTest")) + await _ssh.ExecuteCommandAsync($"/usr/bin/forkop clash_api get_group_latency {ShellHelper.EscapeArg(group.GroupTag)}", 10); + var fresh = await GetServersAsync(); + var byTag = fresh.ToDictionary(n => n.GroupTag, StringComparer.Ordinal); foreach (var node in currentNodes) - { - if (node.Name.Contains("Резерв") || node.Name.Contains("Blanc (Основной)")) - { - node.LatencyMs = rng.Next(0, 3) == 0 ? null : rng.Next(180, 260); - } - else - { - node.LatencyMs = rng.Next(190, 310); - } - } - + node.LatencyMs = byTag.TryGetValue(node.GroupTag, out var live) ? live.LatencyMs : null; return currentNodes; } public async Task<(bool Success, string Message)> SelectActiveServerAsync(ForkopServerNode server) { - if (_ssh.IsConnected) + if (!_ssh.IsConnected) return (false, "Нет подключения к роутеру по SSH."); + try { - try + var nodes = await GetServersAsync(); + var target = nodes.FirstOrDefault(n => n.GroupTag == server.GroupTag); + if (target == null || string.IsNullOrEmpty(target.SelectionGroupTag)) + return (false, "Узел не входит в доступный селектор. Обновите список или выберите группу в панели ForkOP."); + var parent = nodes.FirstOrDefault(n => n.GroupTag == target.SelectionGroupTag); + if (parent == null || !parent.HasLiveState || !parent.Candidates.Contains(target.GroupTag)) + return (false, "Состав селектора изменился. Обновите список."); + var command = $"/usr/bin/forkop clash_api set_group_proxy {ShellHelper.EscapeArg(parent.GroupTag)} {ShellHelper.EscapeArg(target.GroupTag)}"; + var (code, _, error) = await _ssh.ExecuteCommandAsync(command, 10); + if (code != 0) return (false, $"Роутер отклонил переключение: {error}"); + for (var attempt = 0; attempt < 3; attempt++) { - var targetName = !string.IsNullOrWhiteSpace(server.GroupTag) ? server.GroupTag : server.Name; - var safeTargetArg = ShellHelper.EscapeArg(targetName); - var jsonBody = System.Text.Json.JsonSerializer.Serialize(new { name = targetName }); - var safeJsonArg = ShellHelper.EscapeArg(jsonBody); - - var sb = new System.Text.StringBuilder(); - // 1. Clash API live runtime switch - sb.AppendLine($"/usr/bin/forkop clash_api set_group_proxy main-priority-main_priority-out {safeTargetArg} 2>/dev/null || true"); - sb.AppendLine($"/usr/bin/forkop clash_api set_group_proxy main-out {safeTargetArg} 2>/dev/null || true"); - sb.AppendLine($"curl -s -X PUT http://127.0.0.1:9090/proxies/main-priority-main_priority-out -H 'Content-Type: application/json' -d {safeJsonArg} 2>/dev/null || true"); - // 2. UCI persistent configuration - sb.AppendLine($"uci set forkop.vpn_proxy.selected_node={safeTargetArg} 2>/dev/null || true"); - sb.AppendLine("uci commit forkop 2>/dev/null || true"); - - await _ssh.ExecuteCommandAsync(sb.ToString(), 5); - } - catch (Exception ex) - { - System.Diagnostics.Debug.WriteLine($"[ForkopService] SelectActiveServerAsync error: {ex.Message}"); + var confirmed = (await GetServersAsync()).FirstOrDefault(n => n.GroupTag == parent.GroupTag); + if (confirmed?.CurrentNode == target.GroupTag) return (true, $"Роутер подтвердил выбор: {target.Name}"); + if (attempt < 2) await Task.Delay(150); } + return (false, "Команда отправлена, но роутер не подтвердил новый выбор. Обновите список."); } - - return (true, $"Активным узлом выбран: {server.Name}"); + catch (Exception ex) { return (false, $"Не удалось переключить узел: {ex.Message}"); } } private static string FormatBytes(long bytes) diff --git a/VPN_VALIDATION.md b/VPN_VALIDATION.md new file mode 100644 index 0000000..55474f1 --- /dev/null +++ b/VPN_VALIDATION.md @@ -0,0 +1,19 @@ +# Проверка VPN на реальном оборудовании — 19.10.2026 + +Проверены Windows 2.5.20 и Android 2.5.24 на Cudy WR3000S v1 / OpenWrt 25.12.5 с ForkOP и sing-box. Получено 27 групп и 223 сервера из реального роутера. + +- Windows: ручной выбор узла через интерфейс подтверждён отдельным чтением Clash API роутера. Фильтры провайдера/протокола формируются из узлов; выбранный провайдер сохраняется после обновления. +- Интеграционный тест сервиса: автоматическая политика и ручной узел подтверждены отдельным чтением API; несуществующая цель отклонена без изменения селекторов; исходные значения восстановлены в finally. +- Android: переход на автоматическую группу, ручной узел и другую группу быстрым выбором подтверждён отдельными SSH-чтениями API. Список участников URLTest открывается как автовыбор. +- APK установлен поверх 2.5.23 с сохранением профиля и подключением к роутеру. Подпись совпадает с установленной версией; arm64 versionCode = 6524. +- После испытаний оба изменяемых Selector восстановлены точно по исходному снимку (0 расхождений). Тема LuCI Proton2025 сохранена и проверена через UCI. + +Проверки: Windows 128 passed / 6 skipped; portable core 10 passed / 1 skipped; opt-in live VPN 1 passed; read-only router probes 2 passed; Flutter analyze без замечаний, 163 tests passed. Пропуски offline-прогона относятся к интеграциям, требующим явного включения. + +Исправлена блокировка тестов Dashboard при наличии WPF Application без работающего Dispatcher: тесты передают синхронный диспетчер, приложение сохраняет штатный UI Dispatcher. + +Пределы проверки: проверялся выбор маршрута через состояние роутера, а не полный захват пользовательского трафика; все 223 узла по отдельности не испытывались. WireGuard/AmneziaWG и новые Linux-пакеты в этом аппаратном прогоне не проверялись. Ранее выполненные Linux-проверки PR не заменяются этим отчётом. + +Снимки конфигурации и данные доступа в репозиторий не включены. Для повторения live-теста нужны OPENWRT_LIVE_VPN_SWITCH=1, OPENWRT_TEST_HOST и OPENWRT_TEST_PASSWORD; запуск временно меняет реальный VPN и восстанавливает исходный выбор. + +![Фильтр серверов Windows](screenshots/qa-vpn-filter-2.5.20.png) diff --git a/ViewModels/DashboardViewModel.cs b/ViewModels/DashboardViewModel.cs index d3991a1..3f9ada2 100644 --- a/ViewModels/DashboardViewModel.cs +++ b/ViewModels/DashboardViewModel.cs @@ -49,7 +49,7 @@ public partial class DashboardViewModel : ObservableObject [ObservableProperty] private bool _autoRefresh = true; - public DashboardViewModel(IRouterService routerService, ISshService ssh) + public DashboardViewModel(IRouterService routerService, ISshService ssh, Action? dispatchToUi = null) { _routerService = routerService; _ssh = ssh; @@ -66,7 +66,9 @@ void Reset() ActionStatus = ""; LastUpdatedText = "Данные появятся после подключения"; } - if (System.Windows.Application.Current?.Dispatcher is { } dispatcher) + if (dispatchToUi != null) + dispatchToUi(Reset); + else if (System.Windows.Application.Current?.Dispatcher is { } dispatcher) dispatcher.Invoke(Reset); else Reset(); }; diff --git a/ViewModels/ForkopViewModel.cs b/ViewModels/ForkopViewModel.cs index 4f4c602..4de760e 100644 --- a/ViewModels/ForkopViewModel.cs +++ b/ViewModels/ForkopViewModel.cs @@ -34,7 +34,7 @@ public partial class ForkopViewModel : ObservableObject public ObservableCollection AvailableProviders { get; } = new() { - "Все", "Blanc", "Stealth", "Abuz", "Vless4U", "Прочие" + "Все" }; public ObservableCollection AvailableProtocols { get; } = new() @@ -146,7 +146,15 @@ public async Task LoadDataAsync() Servers = new ObservableCollection(nodes); AutoGroups = new ObservableCollection(nodes.Where(n => n.IsAutoGroup)); ApplyServerFilter(); - SelectedServer = nodes.FirstOrDefault(n => n.IsActive) ?? nodes.FirstOrDefault(); + SelectedServer = nodes.FirstOrDefault(n => n.IsActive); + var oldProvider = SelectedProviderFilter; + AvailableProviders.Clear(); AvailableProviders.Add("Все"); + foreach (var provider in nodes.Where(n => !n.IsAutoGroup).Select(n => n.Provider).Where(p => p.Length > 0).Distinct().OrderBy(p => p)) AvailableProviders.Add(provider); + SelectedProviderFilter = AvailableProviders.Contains(oldProvider) ? oldProvider : "Все"; + var oldProtocol = SelectedProtocolFilter; + AvailableProtocols.Clear(); AvailableProtocols.Add("Все"); + foreach (var protocol in nodes.Where(n => !n.IsAutoGroup).Select(n => n.Protocol).Distinct().OrderBy(p => p)) AvailableProtocols.Add(protocol); + SelectedProtocolFilter = AvailableProtocols.Contains(oldProtocol) ? oldProtocol : "Все"; // 4. Section Config Section = await _forkopService.GetSectionConfigAsync(); @@ -208,7 +216,7 @@ public async Task TestLatencyAsync() Servers = new ObservableCollection(updated); AutoGroups = new ObservableCollection(updated.Where(n => n.IsAutoGroup)); ApplyServerFilter(); - StatusMessage = "Тестирование задержки завершено успешно."; + StatusMessage = "Показаны доступные измерения роутера; N/A означает отсутствие результата."; } catch (Exception ex) { @@ -225,23 +233,15 @@ public async Task SelectServerAsync(ForkopServerNode? node) { if (node == null) return; - foreach (var s in Servers) - { - s.IsActive = (s == node || s.Name == node.Name); - } - foreach (var a in AutoGroups) - { - a.IsActive = (a == node || a.Name == node.Name); - } - foreach (var f in FilteredServers) - { - f.IsActive = (f == node || f.Name == node.Name); - } - SelectedServer = node; - StatusMessage = $"Выбор активного сервера: {node.Name}..."; - var (success, msg) = await _forkopService.SelectActiveServerAsync(node); - StatusMessage = msg; + var (_, message) = await _forkopService.SelectActiveServerAsync(node); + // Only a fresh router snapshot may update the active markers, including failed switches. + var fresh = await _forkopService.GetServersAsync(); + Servers = new ObservableCollection(fresh); + AutoGroups = new ObservableCollection(fresh.Where(n => n.IsAutoGroup)); + SelectedServer = fresh.FirstOrDefault(n => n.IsActive); + ApplyServerFilter(); + StatusMessage = message; } // Section editing commands diff --git a/Views/ForkopView.xaml b/Views/ForkopView.xaml index 9908cab..673072a 100644 --- a/Views/ForkopView.xaml +++ b/Views/ForkopView.xaml @@ -398,7 +398,7 @@