实现 .NET 10 托盘版网关保活
包含分层探测、可补偿的 IPv4 路由/DNS 切换、自动策略、托盘与分组设置界面、只读诊断工具、离线测试和发布脚本。
This commit is contained in:
@@ -0,0 +1,4 @@
|
||||
<Project Sdk="Microsoft.NET.Sdk">
|
||||
<PropertyGroup><OutputType>Exe</OutputType></PropertyGroup>
|
||||
<ItemGroup><ProjectReference Include="../GatewayKeepalive.Windows/GatewayKeepalive.Windows.csproj" /></ItemGroup>
|
||||
</Project>
|
||||
@@ -0,0 +1,8 @@
|
||||
using GatewayKeepalive.Windows;
|
||||
|
||||
if (args.Length == 0 || args.Contains("--help"))
|
||||
{
|
||||
Console.WriteLine("网关保活只读诊断\n --diagnose [--adapter GUID] [--output report.json]\n --probe --adapter GUID [--config settings.json]\n --dry-run --adapter GUID [--direct|--proxy] [--config settings.json]\n此工具只读取/探测网络和生成计划,不修改路由、DNS或启动项。");
|
||||
return 0;
|
||||
}
|
||||
return await DiagnosticCommands.RunAsync(args);
|
||||
@@ -0,0 +1,237 @@
|
||||
namespace GatewayKeepalive.Core;
|
||||
|
||||
public sealed class GatewayController : ITrayController
|
||||
{
|
||||
private readonly INetworkBackend _backend;
|
||||
private readonly IHealthProbe _probe;
|
||||
private readonly JsonStore _store;
|
||||
private readonly SwitchService _switcher;
|
||||
private readonly SwitchPolicy _policy = new();
|
||||
private readonly Action<string> _log;
|
||||
private readonly Func<bool> _getAutostart;
|
||||
private readonly Action<bool> _setAutostart;
|
||||
private readonly SemaphoreSlim _gate = new(1, 1);
|
||||
private readonly CancellationTokenSource _stop = new();
|
||||
private Task? _loop;
|
||||
private SessionState? _session;
|
||||
private AppSettings _settings;
|
||||
private AppStatus _status = AppStatus.Initial;
|
||||
private bool _sessionLoaded;
|
||||
private bool _stopped;
|
||||
private int _refreshPending;
|
||||
private DateTimeOffset? _lastSwitchAt;
|
||||
private long? _lastHealthCheckTimestamp;
|
||||
private string? _lastEvent;
|
||||
private DateTimeOffset? _lastEventAt;
|
||||
public AppSettings Settings => _settings;
|
||||
public AppStatus Status => _status;
|
||||
public string DataDirectory => _store.DirectoryPath;
|
||||
public bool AutostartEnabled => _getAutostart();
|
||||
public event Action<AppStatus>? StatusChanged;
|
||||
|
||||
public GatewayController(INetworkBackend backend, IHealthProbe probe, JsonStore store, Action<string> log, Func<bool>? getAutostart = null, Action<bool>? setAutostart = null)
|
||||
{
|
||||
_backend = backend; _probe = probe; _store = store; _log = log;
|
||||
_getAutostart = getAutostart ?? (() => false);
|
||||
_setAutostart = setAutostart ?? (_ => throw new NotSupportedException("自启不可用。"));
|
||||
_settings = store.Read<AppSettings>("settings.json") ?? new();
|
||||
_settings.Validate(false);
|
||||
_backend.ConfigureIdentity(_settings);
|
||||
_session = store.Read<SessionState>("session.json");
|
||||
_sessionLoaded = true;
|
||||
_switcher = new(backend, probe, store, Log);
|
||||
}
|
||||
|
||||
public void Start()
|
||||
{
|
||||
if (_loop is not null) return;
|
||||
_loop = Task.Run(async () =>
|
||||
{
|
||||
while (!_stop.IsCancellationRequested)
|
||||
{
|
||||
try { await RefreshAsync(); }
|
||||
catch (OperationCanceledException) when (_stop.IsCancellationRequested) { break; }
|
||||
catch (Exception e) { Log("后台检查异常:" + e); Publish(_status with { Mode = NetworkMode.Degraded, Message = e.Message }); }
|
||||
try { await Task.Delay(TimeSpan.FromSeconds(_settings.IntervalSeconds), _stop.Token); }
|
||||
catch (OperationCanceledException) { break; }
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
public Task<IReadOnlyList<AdapterInfo>> ListAdaptersAsync() => Task.Run(() => _backend.ListAdaptersAsync(_stop.Token));
|
||||
public void SetAutostart(bool enabled) => _setAutostart(enabled);
|
||||
|
||||
public async Task RefreshAsync()
|
||||
{
|
||||
if (Interlocked.Exchange(ref _refreshPending, 1) != 0) return;
|
||||
try { await Locked(CheckCoreAsync); }
|
||||
finally { Interlocked.Exchange(ref _refreshPending, 0); }
|
||||
}
|
||||
|
||||
private async Task CheckCoreAsync()
|
||||
{
|
||||
var now = TimeProvider.System.GetTimestamp();
|
||||
if (_lastHealthCheckTimestamp is { } previous && TimeProvider.System.GetElapsedTime(previous, now).TotalSeconds > Math.Max(30, _settings.IntervalSeconds * 3 + _settings.RoundTimeoutMs / 1000))
|
||||
{
|
||||
_policy.Reset();
|
||||
Log("检测中断较久(可能休眠唤醒),清零连续计数并重新确认网络。");
|
||||
}
|
||||
_lastHealthCheckTimestamp = now;
|
||||
if (!_settings.Configured) { Publish(AppStatus.Initial); return; }
|
||||
NetworkSnapshot current;
|
||||
try { current = await _backend.CaptureAsync(_settings.AdapterId, _stop.Token); }
|
||||
catch (Exception e) when (e is not OperationCanceledException)
|
||||
{
|
||||
_policy.Reset();
|
||||
Publish(_status with { Mode = NetworkMode.Disconnected, Network = null, Health = null, Message = e.Message, SuccessCount = 0, FailureCount = 0 }); return;
|
||||
}
|
||||
if (!_sessionLoaded) { _session = _store.Read<SessionState>("session.json"); _sessionLoaded = true; }
|
||||
var journal = _store.Read<SwitchJournal>(SwitchService.JournalFile);
|
||||
if (journal is not null)
|
||||
{
|
||||
if (journal.Stage == "Verified" && _session is not null && NetworkComparison.SameConfiguration(_session.Expected, current) && NetworkComparison.SameConfiguration(journal.Intended, current)) _store.Delete(SwitchService.JournalFile);
|
||||
else { _policy.Reset(); Publish(new(NetworkMode.RecoveryRequired, ControlPolicy.Paused, current, null, 0, 0, "发现未完成切换,请点击恢复处理;自动写入已停止。", _lastSwitchAt)); return; }
|
||||
}
|
||||
if (!current.Adapter.Up || current.Adapter.Addresses.Length == 0) { _policy.Reset(); Publish(new(NetworkMode.Disconnected, _settings.Policy, current, null, 0, 0, "目标网卡未连接", _lastSwitchAt)); return; }
|
||||
if (_session is null || _session.SettingsFingerprint != SettingsFingerprint(_settings) || !NetworkComparison.SameConfiguration(_session.Expected, current))
|
||||
{
|
||||
_policy.Reset(); Publish(new(NetworkMode.ExternalConfiguration, ControlPolicy.Paused, current, null, 0, 0, "网络或配置发生变化,请核对设置并重新保存以接管。", _lastSwitchAt)); return;
|
||||
}
|
||||
var actualMode = Classify(current);
|
||||
if (actualMode == NetworkMode.ExternalConfiguration) { _policy.Reset(); Publish(new(actualMode, ControlPolicy.Paused, current, null, 0, 0, "当前网关不是配置的主/旁路由。", _lastSwitchAt)); return; }
|
||||
var health = await _probe.CheckAsync(_settings, current, _stop.Token);
|
||||
var decision = _policy.Evaluate(_settings, actualMode, health);
|
||||
var degraded = !health.InternetHealthy || (actualMode == NetworkMode.Proxy && !health.ProxyHealthy);
|
||||
Publish(new(degraded ? NetworkMode.Degraded : actualMode, _settings.Policy, current, health, _policy.SuccessCount, _policy.FailureCount,
|
||||
!health.InternetHealthy ? "当前 IPv4 出口检测失败" : actualMode == NetworkMode.Proxy && !health.ProxyHealthy ? "旁路由健康检查失败,当前出口仍可达" : actualMode == NetworkMode.Direct && !health.ProxyHealthy ? "主路由直连正常,旁路由暂不可用" : _settings.Policy == ControlPolicy.Paused ? "已暂停自动切换,继续监测" : "检测正常", _lastSwitchAt));
|
||||
Log($"检查 {actualMode} 旁路由={health.ProxyHealthy} 出口={health.InternetHealthy} 成功={_policy.SuccessCount} 失败={_policy.FailureCount}");
|
||||
if (decision.HasValue)
|
||||
{
|
||||
try { await SwitchCoreAsync(decision.Value); }
|
||||
catch (Exception e) when (e is not OperationCanceledException) { Log("自动切换失败:" + e.Message); }
|
||||
}
|
||||
}
|
||||
|
||||
public Task SaveSettingsAsync(AppSettings settings) => Locked(async () =>
|
||||
{
|
||||
settings.Validate();
|
||||
if (_store.Read<SwitchJournal>(SwitchService.JournalFile) is not null) throw new RecoveryRequiredException("请先恢复未完成的切换。");
|
||||
_backend.ConfigureIdentity(settings);
|
||||
var snapshot = await _backend.CaptureAsync(settings.AdapterId, _stop.Token);
|
||||
_switcher.Plan(settings, snapshot, false, null);
|
||||
_switcher.Plan(settings, snapshot, true, null);
|
||||
var sameSession = _session is not null && NetworkComparison.SameConfiguration(_session.Expected, snapshot);
|
||||
var baseline = sameSession ? _session!.Baseline : snapshot;
|
||||
var originalDirectDns = snapshot.Gateway == settings.DirectGateway ? snapshot.Dns : sameSession ? _session!.DirectDns : null;
|
||||
if (settings.PreserveOriginalDirectDns && originalDirectDns is null) throw new InvalidOperationException("当前已在旁路由且没有可信的原直连 DNS。请取消“直连恢复原 DNS”,明确选择自动或静态 DNS 后保存。");
|
||||
var directDns = settings.PreserveOriginalDirectDns ? originalDirectDns : settings.DirectDnsAutomatic ? DnsConfiguration.Auto : new(false, settings.DirectDnsServers, []);
|
||||
var newSession = new SessionState(baseline, snapshot, directDns, SettingsFingerprint(settings));
|
||||
_store.Write("session.json", newSession);
|
||||
_store.Write("settings.json", settings);
|
||||
_session = newSession; _sessionLoaded = true; _settings = settings;
|
||||
_policy.Reset();
|
||||
Publish(new(Classify(snapshot), settings.Policy, snapshot, null, 0, 0, "配置已保存并接管当前网络", _lastSwitchAt));
|
||||
if (settings.Policy is ControlPolicy.ForceDirect or ControlPolicy.ForceProxy) await SwitchCoreAsync(settings.Policy == ControlPolicy.ForceProxy);
|
||||
});
|
||||
|
||||
public Task SetPolicyAsync(ControlPolicy policy) => Locked(async () =>
|
||||
{
|
||||
if (!Enum.IsDefined(policy)) throw new ArgumentException("模式无效。");
|
||||
if (!_settings.Configured) throw new InvalidOperationException("请先打开设置并选择网卡。");
|
||||
var current = await _backend.CaptureAsync(_settings.AdapterId, _stop.Token);
|
||||
if (_session is null || _session.SettingsFingerprint != SettingsFingerprint(_settings) || !NetworkComparison.SameConfiguration(_session.Expected, current)) throw new InvalidOperationException("请核对并重新保存设置后接管当前网络。");
|
||||
if (_store.Read<SwitchJournal>(SwitchService.JournalFile) is not null) throw new RecoveryRequiredException("请先处理未完成切换。");
|
||||
var changed = _settings with { Policy = policy };
|
||||
_store.Write("settings.json", changed); _settings = changed; _policy.Reset();
|
||||
Publish(_status with { Policy = policy, Message = "已切换控制策略" });
|
||||
if (policy is ControlPolicy.ForceDirect or ControlPolicy.ForceProxy) await SwitchCoreAsync(policy == ControlPolicy.ForceProxy);
|
||||
});
|
||||
|
||||
private async Task SwitchCoreAsync(bool proxy)
|
||||
{
|
||||
if (_session is null) throw new InvalidOperationException("尚未接管网络。");
|
||||
Publish(_status with { Mode = NetworkMode.Switching, Message = proxy ? "正在切换旁路由…" : "正在恢复直连…" });
|
||||
try
|
||||
{
|
||||
var actual = await _switcher.SwitchAsync(_settings, _session.Expected, proxy, _session.DirectDns, _stop.Token);
|
||||
var session = _session with { Expected = actual };
|
||||
_store.Write("session.json", session); _session = session;
|
||||
_store.Delete(SwitchService.JournalFile);
|
||||
_lastSwitchAt = DateTimeOffset.Now; _policy.Switched();
|
||||
Publish(new(proxy ? NetworkMode.Proxy : NetworkMode.Direct, _settings.Policy, actual, null, 0, 0, proxy ? "已切换旁路由并验证出口" : "已恢复主路由直连并验证出口", _lastSwitchAt));
|
||||
}
|
||||
catch (Exception e)
|
||||
{
|
||||
_policy.Failed();
|
||||
var recovery = e is RecoveryRequiredException || _store.Read<SwitchJournal>(SwitchService.JournalFile) is not null;
|
||||
if (recovery) { var paused = _settings with { Policy = ControlPolicy.Paused }; _store.Write("settings.json", paused); _settings = paused; }
|
||||
Publish(_status with { Mode = recovery ? NetworkMode.RecoveryRequired : Classify(_session.Expected), Policy = _settings.Policy, Message = e.Message, SuccessCount = 0, FailureCount = 0 });
|
||||
throw;
|
||||
}
|
||||
}
|
||||
|
||||
public Task RestoreAsync() => Locked(async () =>
|
||||
{
|
||||
// Pause must reach durable storage BEFORE any restore can change the network.
|
||||
var settings = _settings with { Policy = ControlPolicy.Paused };
|
||||
_store.Write("settings.json", settings); _settings = settings; _policy.Reset();
|
||||
var journal = _store.Read<SwitchJournal>(SwitchService.JournalFile);
|
||||
NetworkSnapshot restored;
|
||||
if (journal is not null) restored = await _switcher.RollbackAsync(journal, _stop.Token, retainJournal: true);
|
||||
else
|
||||
{
|
||||
if (_session is null) throw new InvalidOperationException("没有可恢复的接管前快照。");
|
||||
restored = await _switcher.RestoreBaselineAsync(_session.Expected, _session.Baseline, _stop.Token);
|
||||
}
|
||||
var session = _session is not null && NetworkComparison.SameNetwork(_session.Baseline, restored) ? _session with { Expected = restored } : new SessionState(restored, restored, null, SettingsFingerprint(_settings));
|
||||
_store.Write("session.json", session); _session = session; _sessionLoaded = true;
|
||||
_store.Delete(SwitchService.JournalFile); _policy.Reset();
|
||||
Publish(new(Classify(restored), ControlPolicy.Paused, restored, null, 0, 0, "配置已恢复并暂停自动切换", _lastSwitchAt));
|
||||
});
|
||||
|
||||
public async Task StopAsync()
|
||||
{
|
||||
if (_stopped) return;
|
||||
await Locked(() =>
|
||||
{
|
||||
if (!_sessionLoaded) { _session = _store.Read<SessionState>("session.json"); _sessionLoaded = true; }
|
||||
return Task.CompletedTask;
|
||||
});
|
||||
// Restore while the serial gate still accepts work; failure leaves the application available.
|
||||
if (_settings.RestoreOnExit && (_session is not null || _store.Read<SwitchJournal>(SwitchService.JournalFile) is not null))
|
||||
{
|
||||
await RestoreAsync();
|
||||
if (_session is not null && !NetworkComparison.SameConfiguration(_session.Expected, _session.Baseline)) await RestoreAsync();
|
||||
}
|
||||
_stop.Cancel();
|
||||
if (_loop is not null) await _loop;
|
||||
await _gate.WaitAsync();
|
||||
try { _stopped = true; }
|
||||
finally { _gate.Release(); }
|
||||
}
|
||||
|
||||
private Task Locked(Func<Task> work) => Task.Run(async () =>
|
||||
{
|
||||
await _gate.WaitAsync(_stop.Token);
|
||||
try { if (_stopped) return; await work(); }
|
||||
finally { _gate.Release(); }
|
||||
});
|
||||
|
||||
private NetworkMode Classify(NetworkSnapshot snapshot) => snapshot.Gateway == _settings.ProxyGateway ? NetworkMode.Proxy : snapshot.Gateway == _settings.DirectGateway ? NetworkMode.Direct : NetworkMode.ExternalConfiguration;
|
||||
private static string SettingsFingerprint(AppSettings settings) => Convert.ToHexString(System.Security.Cryptography.SHA256.HashData(System.Text.Json.JsonSerializer.SerializeToUtf8Bytes(settings with { Policy = ControlPolicy.Paused, Notifications = true, RestoreOnExit = false }, JsonStore.Options)));
|
||||
private void Log(string text) { try { _log(text); } catch (IOException) { } catch (UnauthorizedAccessException) { } }
|
||||
private void Publish(AppStatus status)
|
||||
{
|
||||
if ((status.LastSwitchAt is not null && status.LastSwitchAt != _status.LastSwitchAt) ||
|
||||
(status.Mode != _status.Mode && status.Mode is NetworkMode.RecoveryRequired or NetworkMode.ExternalConfiguration or NetworkMode.Disconnected or NetworkMode.Degraded) ||
|
||||
status.Message.StartsWith("切换未成功", StringComparison.Ordinal) || status.Message.StartsWith("配置已恢复", StringComparison.Ordinal))
|
||||
{
|
||||
_lastEvent = status.Message;
|
||||
_lastEventAt = DateTimeOffset.Now;
|
||||
}
|
||||
status = status with { LastEvent = _lastEvent, LastEventAt = _lastEventAt, ActivePath = status.Network is null ? status.Mode == NetworkMode.Initializing ? NetworkMode.Initializing : NetworkMode.Disconnected : Classify(status.Network) };
|
||||
_status = status;
|
||||
foreach (Action<AppStatus> handler in StatusChanged?.GetInvocationList() ?? [])
|
||||
try { handler(status); } catch (Exception e) { Log("界面订阅失败:" + e.Message); }
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1 @@
|
||||
<Project Sdk="Microsoft.NET.Sdk" />
|
||||
@@ -0,0 +1,120 @@
|
||||
using System.Net;
|
||||
using System.Net.Sockets;
|
||||
|
||||
namespace GatewayKeepalive.Core;
|
||||
|
||||
public enum ControlPolicy { Auto, ForceDirect, ForceProxy, Paused }
|
||||
public enum NetworkMode { Initializing, Direct, Proxy, Switching, Degraded, Disconnected, ExternalConfiguration, RecoveryRequired }
|
||||
|
||||
public sealed record AppSettings
|
||||
{
|
||||
public int SchemaVersion { get; init; } = 1;
|
||||
public bool Configured { get; init; }
|
||||
public Guid AdapterId { get; init; }
|
||||
public string DirectGateway { get; init; } = "192.168.6.1";
|
||||
public string ProxyGateway { get; init; } = "192.168.6.2";
|
||||
public string ProxyDns { get; init; } = "192.168.6.2";
|
||||
public bool PreserveOriginalDirectDns { get; init; } = true;
|
||||
public bool DirectDnsAutomatic { get; init; } = true;
|
||||
public string[] DirectDnsServers { get; init; } = [];
|
||||
public bool UseSocks { get; init; } = true;
|
||||
public int SocksPort { get; init; } = 1070;
|
||||
public string[] TestHosts { get; init; } = ["www.google.com"];
|
||||
public int RequiredHealthyTargets { get; init; } = 1;
|
||||
public string[] DirectTestHosts { get; init; } = ["www.baidu.com", "www.microsoft.com"];
|
||||
public int RequiredDirectHealthyTargets { get; init; } = 1;
|
||||
public int IntervalSeconds { get; init; } = 5;
|
||||
public int SuccessThreshold { get; init; } = 3;
|
||||
public int FailureThreshold { get; init; } = 3;
|
||||
public int RecoveryCooldownSeconds { get; init; } = 30;
|
||||
public int FailureCooldownSeconds { get; init; } = 5;
|
||||
public int ProbeTimeoutMs { get; init; } = 2500;
|
||||
public int RoundTimeoutMs { get; init; } = 4000;
|
||||
public ControlPolicy Policy { get; init; } = ControlPolicy.Paused;
|
||||
public bool RestoreOnExit { get; init; }
|
||||
public bool Notifications { get; init; } = true;
|
||||
|
||||
public void Validate(bool requireConfigured = true)
|
||||
{
|
||||
if (SchemaVersion != 1) throw new ArgumentException("配置版本不受支持。");
|
||||
if ((requireConfigured && !Configured) || ((requireConfigured || Configured) && AdapterId == Guid.Empty)) throw new ArgumentException("请先选择网卡并保存配置。");
|
||||
static bool Ipv4(string s) => IPAddress.TryParse(s, out var ip) && ip.AddressFamily == AddressFamily.InterNetwork && !ip.Equals(IPAddress.Any) && !ip.Equals(IPAddress.Broadcast) && !IPAddress.IsLoopback(ip) && ip.GetAddressBytes()[0] < 224;
|
||||
if (!Ipv4(DirectGateway) || !Ipv4(ProxyGateway) || !Ipv4(ProxyDns)) throw new ArgumentException("网关和旁路由 DNS 必须是有效 IPv4 地址。");
|
||||
if (DirectGateway == ProxyGateway) throw new ArgumentException("主路由与旁路由不能相同。");
|
||||
if (!DirectDnsAutomatic && (DirectDnsServers.Length == 0 || DirectDnsServers.Any(x => !Ipv4(x)))) throw new ArgumentException("请填写有效的直连 DNS 地址。");
|
||||
if (SocksPort is < 1 or > 65535) throw new ArgumentException("SOCKS5 端口范围为 1~65535。");
|
||||
static bool Target(string s) => !string.IsNullOrWhiteSpace(s) && s.Length <= 253 && !s.Contains('/') && (IPAddress.TryParse(s, out var ip) ? ip.AddressFamily == AddressFamily.InterNetwork : Uri.CheckHostName(s) == UriHostNameType.Dns);
|
||||
if (TestHosts.Length is < 1 or > 5 || TestHosts.Any(x => !Target(x))) throw new ArgumentException("探测目标需为 1~5 个主机名或 IPv4 地址,不包含协议和路径。");
|
||||
if (RequiredHealthyTargets < 1 || RequiredHealthyTargets > TestHosts.Length) throw new ArgumentException("成功目标数超出探测目标数量。");
|
||||
if (DirectTestHosts.Length is < 1 or > 5 || DirectTestHosts.Any(x => !Target(x))) throw new ArgumentException("直连探测目标需为 1~5 个主机名或 IPv4 地址。");
|
||||
if (RequiredDirectHealthyTargets < 1 || RequiredDirectHealthyTargets > DirectTestHosts.Length) throw new ArgumentException("直连成功目标数超出探测目标数量。");
|
||||
if (IntervalSeconds is < 1 or > 3600 || SuccessThreshold is < 1 or > 100 || FailureThreshold is < 1 or > 100) throw new ArgumentException("检测间隔或连续阈值超出范围。");
|
||||
if (RecoveryCooldownSeconds is < 0 or > 3600 || FailureCooldownSeconds is < 0 or > 3600) throw new ArgumentException("冷却时间范围为 0~3600 秒。");
|
||||
if (ProbeTimeoutMs is < 100 or > 30000 || RoundTimeoutMs < ProbeTimeoutMs || RoundTimeoutMs > 60000) throw new ArgumentException("探针超时需为 100~30000 毫秒,整轮预算不能小于探针超时且不得超过 60000 毫秒。");
|
||||
if (!Enum.IsDefined(Policy)) throw new ArgumentException("控制模式无效。");
|
||||
}
|
||||
}
|
||||
|
||||
public sealed record Ipv4Address(string Address, int PrefixLength);
|
||||
public sealed record AdapterInfo(Guid Id, int Index, string Name, bool Up, Ipv4Address[] Addresses, uint InterfaceMetric, string NetworkFingerprint);
|
||||
public sealed record RouteEntry(string Gateway, uint Metric, ulong InterfaceLuid, uint InterfaceIndex, uint Protocol = 3, uint ValidLifetime = uint.MaxValue, uint PreferredLifetime = uint.MaxValue, bool Publish = false, bool Immortal = false);
|
||||
public sealed record DnsConfiguration(bool Automatic, string[] Servers, string[] ProfileServers)
|
||||
{
|
||||
public static DnsConfiguration Auto { get; } = new(true, [], []);
|
||||
}
|
||||
public sealed record NetworkSnapshot(AdapterInfo Adapter, RouteEntry[] Routes, DnsConfiguration Dns, string[] EffectiveDns, DateTimeOffset CapturedAt)
|
||||
{
|
||||
public RouteEntry? PreferredRoute => Routes.OrderBy(x => x.Metric).FirstOrDefault();
|
||||
public string? Gateway => PreferredRoute?.Gateway;
|
||||
}
|
||||
public sealed record ProbeResult(string Name, bool Success, long ElapsedMs, string Detail);
|
||||
public sealed record HealthReport(ProbeResult[] Checks, bool ProxyHealthy, bool InternetHealthy, DateTimeOffset CheckedAt);
|
||||
public sealed record AppStatus(NetworkMode Mode, ControlPolicy Policy, NetworkSnapshot? Network, HealthReport? Health, int SuccessCount, int FailureCount, string Message, DateTimeOffset? LastSwitchAt = null)
|
||||
{
|
||||
public string? LastEvent { get; init; }
|
||||
public DateTimeOffset? LastEventAt { get; init; }
|
||||
public NetworkMode ActivePath { get; init; } = NetworkMode.Initializing;
|
||||
public static AppStatus Initial { get; } = new(NetworkMode.Initializing, ControlPolicy.Paused, null, null, 0, 0, "请先选择网卡并保存配置");
|
||||
}
|
||||
|
||||
public interface IHealthProbe
|
||||
{
|
||||
Task<HealthReport> CheckAsync(AppSettings settings, NetworkSnapshot snapshot, CancellationToken cancellationToken);
|
||||
Task<bool> VerifyInternetAsync(AppSettings settings, NetworkSnapshot snapshot, CancellationToken cancellationToken);
|
||||
}
|
||||
public interface INetworkBackend
|
||||
{
|
||||
void ConfigureIdentity(AppSettings settings) { }
|
||||
Task<IReadOnlyList<AdapterInfo>> ListAdaptersAsync(CancellationToken cancellationToken);
|
||||
Task<NetworkSnapshot> CaptureAsync(Guid adapterId, CancellationToken cancellationToken);
|
||||
Task WriteRoutesAsync(NetworkSnapshot desired, CancellationToken cancellationToken);
|
||||
Task WriteDnsAsync(Guid adapterId, DnsConfiguration desired, CancellationToken cancellationToken);
|
||||
async Task WriteRoutesGuardedAsync(NetworkSnapshot desired, NetworkSnapshot expected, CancellationToken cancellationToken)
|
||||
{
|
||||
var current = await CaptureAsync(expected.Adapter.Id, cancellationToken);
|
||||
if (!NetworkComparison.SameConfiguration(expected, current)) throw new InvalidOperationException("路由写入前现场已变化,停止覆盖外部配置。");
|
||||
await WriteRoutesAsync(desired, cancellationToken);
|
||||
}
|
||||
async Task WriteDnsGuardedAsync(Guid adapterId, DnsConfiguration desired, NetworkSnapshot expected, CancellationToken cancellationToken)
|
||||
{
|
||||
var current = await CaptureAsync(adapterId, cancellationToken);
|
||||
if (!NetworkComparison.SameConfiguration(expected, current)) throw new InvalidOperationException("DNS 写入前现场已变化,停止覆盖外部配置。");
|
||||
await WriteDnsAsync(adapterId, desired, cancellationToken);
|
||||
}
|
||||
bool IsAdministrator { get; }
|
||||
}
|
||||
public interface ITrayController
|
||||
{
|
||||
AppSettings Settings { get; }
|
||||
AppStatus Status { get; }
|
||||
string DataDirectory { get; }
|
||||
bool AutostartEnabled { get; }
|
||||
event Action<AppStatus>? StatusChanged;
|
||||
Task<IReadOnlyList<AdapterInfo>> ListAdaptersAsync();
|
||||
Task RefreshAsync();
|
||||
Task SetPolicyAsync(ControlPolicy policy);
|
||||
Task SaveSettingsAsync(AppSettings settings);
|
||||
Task RestoreAsync();
|
||||
Task StopAsync();
|
||||
void SetAutostart(bool enabled);
|
||||
}
|
||||
@@ -0,0 +1,78 @@
|
||||
using System.Text.Json;
|
||||
using System.Text.Json.Serialization;
|
||||
|
||||
namespace GatewayKeepalive.Core;
|
||||
|
||||
public sealed class JsonStore(string directory)
|
||||
{
|
||||
public string DirectoryPath { get; } = Path.GetFullPath(directory);
|
||||
public static JsonSerializerOptions Options { get; } = new() { WriteIndented = true, PropertyNameCaseInsensitive = true, RespectNullableAnnotations = true, Encoder = System.Text.Encodings.Web.JavaScriptEncoder.Create(System.Text.Unicode.UnicodeRanges.All), Converters = { new JsonStringEnumConverter() } };
|
||||
|
||||
public T? Read<T>(string file) where T : class
|
||||
{
|
||||
var path = Path.Combine(DirectoryPath, file);
|
||||
return File.Exists(path) ? JsonSerializer.Deserialize<T>(File.ReadAllText(path), Options) ?? throw new InvalidDataException($"配置文件为空:{file}") : null;
|
||||
}
|
||||
|
||||
public void Write<T>(string file, T value)
|
||||
{
|
||||
Directory.CreateDirectory(DirectoryPath);
|
||||
var path = Path.Combine(DirectoryPath, file);
|
||||
var temporary = path + "." + Guid.NewGuid().ToString("N") + ".tmp";
|
||||
try
|
||||
{
|
||||
using (var stream = new FileStream(temporary, FileMode.CreateNew, FileAccess.Write, FileShare.None, 4096, FileOptions.WriteThrough))
|
||||
{
|
||||
JsonSerializer.Serialize(stream, value, Options);
|
||||
stream.Flush(true);
|
||||
}
|
||||
if (File.Exists(path)) File.Replace(temporary, path, path + ".bak");
|
||||
else File.Move(temporary, path);
|
||||
}
|
||||
finally { if (File.Exists(temporary)) File.Delete(temporary); }
|
||||
}
|
||||
|
||||
public void Delete(string file) => File.Delete(Path.Combine(DirectoryPath, file));
|
||||
}
|
||||
|
||||
public sealed class FileLog(string directory)
|
||||
{
|
||||
private readonly object _gate = new();
|
||||
public void Write(string message)
|
||||
{
|
||||
lock (_gate)
|
||||
{
|
||||
Directory.CreateDirectory(directory);
|
||||
var today = DateTime.Now;
|
||||
File.AppendAllText(Path.Combine(directory, $"gateway-{today:yyyy-MM-dd}.log"), $"[{today:yyyy-MM-dd HH:mm:ss.fff}] {message}{Environment.NewLine}");
|
||||
foreach (var file in Directory.EnumerateFiles(directory, "gateway-*.log"))
|
||||
if (File.GetLastWriteTimeUtc(file) < DateTime.UtcNow.AddDays(-14))
|
||||
try { File.Delete(file); } catch (IOException) { }
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
public sealed record SwitchJournal(NetworkSnapshot Before, NetworkSnapshot Intended, string Stage, DateTimeOffset StartedAt);
|
||||
public sealed record SessionState(NetworkSnapshot Baseline, NetworkSnapshot Expected, DnsConfiguration? DirectDns, string SettingsFingerprint = "");
|
||||
|
||||
public static class NetworkComparison
|
||||
{
|
||||
public static bool SameNetwork(NetworkSnapshot left, NetworkSnapshot right) =>
|
||||
left.Adapter.Id == right.Adapter.Id && left.Adapter.Index == right.Adapter.Index &&
|
||||
!string.IsNullOrWhiteSpace(left.Adapter.NetworkFingerprint) && left.Adapter.NetworkFingerprint == right.Adapter.NetworkFingerprint;
|
||||
|
||||
public static bool SameDns(DnsConfiguration a, DnsConfiguration b) => a.Automatic == b.Automatic && a.Servers.SequenceEqual(b.Servers, StringComparer.OrdinalIgnoreCase) && a.ProfileServers.SequenceEqual(b.ProfileServers, StringComparer.OrdinalIgnoreCase);
|
||||
private static string Key(RouteEntry r) => $"{r.InterfaceLuid}/{r.InterfaceIndex}/{r.Gateway}";
|
||||
private static bool SameRoute(RouteEntry a, RouteEntry b) => Key(a) == Key(b) && a.Metric == b.Metric && a.Protocol == b.Protocol && a.Publish == b.Publish && a.Immortal == b.Immortal;
|
||||
public static bool SameRoutes(RouteEntry[] a, RouteEntry[] b) => a.Length == b.Length && a.All(x => b.Any(y => SameRoute(x, y)));
|
||||
public static bool SameConfiguration(NetworkSnapshot a, NetworkSnapshot b) => SameNetwork(a, b) && SameRoutes(a.Routes, b.Routes) && SameDns(a.Dns, b.Dns);
|
||||
|
||||
// Recovery accepts only exact before/intended values. Foreign additions and changes stop it.
|
||||
public static bool BelongsToTransaction(SwitchJournal journal, NetworkSnapshot current)
|
||||
{
|
||||
if (!SameNetwork(journal.Before, current)) return false;
|
||||
if (!SameDns(current.Dns, journal.Before.Dns) && !SameDns(current.Dns, journal.Intended.Dns)) return false;
|
||||
if (current.Routes.Any(r => !journal.Before.Routes.Concat(journal.Intended.Routes).Any(x => SameRoute(x, r)))) return false;
|
||||
return journal.Before.Routes.Where(r => journal.Intended.Routes.Any(x => Key(x) == Key(r))).All(r => current.Routes.Any(x => Key(x) == Key(r)));
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,269 @@
|
||||
using System.Buffers.Binary;
|
||||
using System.Globalization;
|
||||
using System.Net;
|
||||
using System.Net.Sockets;
|
||||
using System.Security.Cryptography;
|
||||
using System.Text;
|
||||
|
||||
namespace GatewayKeepalive.Core;
|
||||
|
||||
public sealed class DnsQueryException : IOException
|
||||
{
|
||||
public DnsQueryException(int responseCode, string message) : base(message) => ResponseCode = responseCode;
|
||||
|
||||
public int ResponseCode { get; }
|
||||
}
|
||||
|
||||
/// <summary>A strict IPv4 DNS A-query client that only contacts explicitly supplied servers.</summary>
|
||||
public static class DnsProbe
|
||||
{
|
||||
public const int DefaultPort = 53;
|
||||
private const int HeaderLength = 12;
|
||||
private const int MaximumMessageLength = 65535;
|
||||
|
||||
public static Task<IPAddress[]> QueryAAsync(
|
||||
IPAddress server,
|
||||
string host,
|
||||
IPAddress? sourceAddress,
|
||||
CancellationToken ct) => QueryAAsync(server, DefaultPort, host, sourceAddress, ct);
|
||||
|
||||
public static async Task<IPAddress[]> QueryAAsync(
|
||||
IPAddress server,
|
||||
int serverPort,
|
||||
string host,
|
||||
IPAddress? sourceAddress,
|
||||
CancellationToken ct)
|
||||
{
|
||||
ArgumentNullException.ThrowIfNull(server);
|
||||
if (server.AddressFamily != AddressFamily.InterNetwork)
|
||||
throw new ArgumentException("DNS 服务器必须是 IPv4 地址。", nameof(server));
|
||||
if (serverPort is < 1 or > 65535) throw new ArgumentOutOfRangeException(nameof(serverPort));
|
||||
if (sourceAddress is not null && sourceAddress.AddressFamily != AddressFamily.InterNetwork)
|
||||
throw new ArgumentException("DNS 源地址必须是 IPv4 地址。", nameof(sourceAddress));
|
||||
ct.ThrowIfCancellationRequested();
|
||||
|
||||
var query = BuildQuery(host, out var transactionId);
|
||||
var endpoint = new IPEndPoint(server, serverPort);
|
||||
byte[] response;
|
||||
using (var udp = new UdpClient(AddressFamily.InterNetwork))
|
||||
{
|
||||
if (sourceAddress is not null) udp.Client.Bind(new IPEndPoint(sourceAddress, 0));
|
||||
await udp.SendAsync(query, endpoint, ct).ConfigureAwait(false);
|
||||
var received = await udp.ReceiveAsync(ct).ConfigureAwait(false);
|
||||
if (!received.RemoteEndPoint.Equals(endpoint))
|
||||
throw new InvalidDataException(
|
||||
$"DNS 响应来源不匹配:期望 {endpoint},实际 {received.RemoteEndPoint}。");
|
||||
response = received.Buffer;
|
||||
}
|
||||
|
||||
if (response.Length < HeaderLength)
|
||||
throw new InvalidDataException("DNS 响应短于固定头。");
|
||||
if (ReadUInt16(response, 0) != transactionId)
|
||||
throw new InvalidDataException("DNS 事务 ID 不匹配。");
|
||||
if (IsTruncated(response))
|
||||
response = await QueryTcpAsync(endpoint, query, sourceAddress, ct).ConfigureAwait(false);
|
||||
if (IsTruncated(response))
|
||||
throw new InvalidDataException("DNS TCP 响应仍标记为截断。");
|
||||
|
||||
var addresses = ParseResponse(response, transactionId, host);
|
||||
if (addresses.Count == 0) throw new InvalidDataException($"DNS 未返回 {host} 的 A 记录。");
|
||||
return [.. addresses];
|
||||
}
|
||||
|
||||
/// <summary>Validates and parses a complete DNS response, including compressed names.</summary>
|
||||
public static IReadOnlyList<IPAddress> ParseResponse(ReadOnlySpan<byte> response, ushort transactionId, string host)
|
||||
{
|
||||
if (response.Length < HeaderLength) throw new InvalidDataException("DNS 响应短于固定头。");
|
||||
if (ReadUInt16(response, 0) != transactionId) throw new InvalidDataException("DNS 事务 ID 不匹配。");
|
||||
var flags = ReadUInt16(response, 2);
|
||||
if ((flags & 0x8000) == 0) throw new InvalidDataException("DNS 响应缺少 QR 标志。");
|
||||
if (((flags >> 11) & 0x0f) != 0) throw new InvalidDataException("DNS 响应操作码不匹配。");
|
||||
var responseCode = flags & 0x0f;
|
||||
if (responseCode != 0) throw new DnsQueryException(responseCode, $"DNS 返回错误码 {responseCode}。");
|
||||
if (ReadUInt16(response, 4) != 1) throw new InvalidDataException("DNS 响应问题段数量无效。");
|
||||
|
||||
var questionCount = ReadUInt16(response, 4);
|
||||
var answerCount = ReadUInt16(response, 6);
|
||||
var authorityCount = ReadUInt16(response, 8);
|
||||
var additionalCount = ReadUInt16(response, 10);
|
||||
var offset = HeaderLength;
|
||||
var actualHost = ReadName(response, ref offset);
|
||||
var expectedHost = NormalizeHost(host);
|
||||
if (!string.Equals(actualHost, expectedHost, StringComparison.OrdinalIgnoreCase))
|
||||
throw new InvalidDataException($"DNS 问题名称不匹配:{actualHost}。");
|
||||
EnsureAvailable(response, offset, 4);
|
||||
if (ReadUInt16(response, offset) != 1 || ReadUInt16(response, offset + 2) != 1)
|
||||
throw new InvalidDataException("DNS 响应问题类型或类别无效。");
|
||||
offset += 4;
|
||||
|
||||
var addresses = new List<IPAddress>();
|
||||
for (var i = 0; i < questionCount - 1; i++)
|
||||
{
|
||||
_ = ReadName(response, ref offset);
|
||||
EnsureAvailable(response, offset, 4);
|
||||
offset += 4;
|
||||
}
|
||||
ReadRecords(response, ref offset, answerCount, addresses, collectA: true);
|
||||
ReadRecords(response, ref offset, authorityCount, addresses, collectA: false);
|
||||
ReadRecords(response, ref offset, additionalCount, addresses, collectA: false);
|
||||
if (offset != response.Length)
|
||||
throw new InvalidDataException("DNS 响应在段结束前仍有未消费数据。");
|
||||
return addresses;
|
||||
}
|
||||
|
||||
private static void ReadRecords(
|
||||
ReadOnlySpan<byte> data,
|
||||
ref int offset,
|
||||
int count,
|
||||
List<IPAddress> addresses,
|
||||
bool collectA)
|
||||
{
|
||||
for (var i = 0; i < count; i++)
|
||||
{
|
||||
_ = ReadName(data, ref offset);
|
||||
EnsureAvailable(data, offset, 10);
|
||||
var type = ReadUInt16(data, offset);
|
||||
var @class = ReadUInt16(data, offset + 2);
|
||||
var length = ReadUInt16(data, offset + 8);
|
||||
offset += 10;
|
||||
EnsureAvailable(data, offset, length);
|
||||
if (collectA && type == 1 && @class == 1 && length == 4)
|
||||
addresses.Add(new IPAddress(data.Slice(offset, 4)));
|
||||
offset += length;
|
||||
}
|
||||
}
|
||||
|
||||
private static string ReadName(ReadOnlySpan<byte> data, ref int offset)
|
||||
{
|
||||
var labels = new List<string>();
|
||||
var position = offset;
|
||||
var nextOffset = -1;
|
||||
var jumps = 0;
|
||||
var totalLength = 0;
|
||||
while (true)
|
||||
{
|
||||
if ((uint)position >= (uint)data.Length) throw new InvalidDataException("DNS 名称越界。");
|
||||
var length = data[position++];
|
||||
if (length == 0)
|
||||
{
|
||||
offset = nextOffset >= 0 ? nextOffset : position;
|
||||
return string.Join('.', labels);
|
||||
}
|
||||
if ((length & 0xc0) == 0xc0)
|
||||
{
|
||||
if (position >= data.Length) throw new InvalidDataException("DNS 压缩指针不完整。");
|
||||
var pointer = ((length & 0x3f) << 8) | data[position++];
|
||||
if (nextOffset < 0) nextOffset = position;
|
||||
if (pointer < HeaderLength || pointer >= data.Length || pointer >= position || ++jumps > 32)
|
||||
throw new InvalidDataException("DNS 压缩指针无效或形成循环。");
|
||||
position = pointer;
|
||||
continue;
|
||||
}
|
||||
if ((length & 0xc0) != 0 || length > 63 || position + length > data.Length)
|
||||
throw new InvalidDataException("DNS 标签长度无效。");
|
||||
totalLength += length + 1;
|
||||
if (totalLength > 254) throw new InvalidDataException("DNS 名称过长。");
|
||||
labels.Add(Encoding.ASCII.GetString(data.Slice(position, length)));
|
||||
position += length;
|
||||
}
|
||||
}
|
||||
|
||||
private static bool IsTruncated(ReadOnlySpan<byte> response) =>
|
||||
response.Length >= HeaderLength && (ReadUInt16(response, 2) & 0x0200) != 0;
|
||||
|
||||
private static async Task<byte[]> QueryTcpAsync(
|
||||
IPEndPoint endpoint,
|
||||
byte[] query,
|
||||
IPAddress? sourceAddress,
|
||||
CancellationToken ct)
|
||||
{
|
||||
using var client = new TcpClient(AddressFamily.InterNetwork);
|
||||
if (sourceAddress is not null) client.Client.Bind(new IPEndPoint(sourceAddress, 0));
|
||||
await client.ConnectAsync(endpoint, ct).ConfigureAwait(false);
|
||||
await using var stream = client.GetStream();
|
||||
var prefix = new byte[2];
|
||||
BinaryPrimitives.WriteUInt16BigEndian(prefix, checked((ushort)query.Length));
|
||||
await stream.WriteAsync(prefix, ct).ConfigureAwait(false);
|
||||
await stream.WriteAsync(query, ct).ConfigureAwait(false);
|
||||
await stream.FlushAsync(ct).ConfigureAwait(false);
|
||||
|
||||
await ReadExactlyAsync(stream, prefix, ct).ConfigureAwait(false);
|
||||
var length = BinaryPrimitives.ReadUInt16BigEndian(prefix);
|
||||
if (length < HeaderLength) throw new InvalidDataException("DNS TCP 响应长度无效。");
|
||||
var response = new byte[length];
|
||||
await ReadExactlyAsync(stream, response, ct).ConfigureAwait(false);
|
||||
return response;
|
||||
}
|
||||
|
||||
private static async Task ReadExactlyAsync(Stream stream, Memory<byte> buffer, CancellationToken ct)
|
||||
{
|
||||
var read = 0;
|
||||
while (read < buffer.Length)
|
||||
{
|
||||
var count = await stream.ReadAsync(buffer[read..], ct).ConfigureAwait(false);
|
||||
if (count == 0) throw new EndOfStreamException("DNS TCP 响应在完整读取前结束。");
|
||||
read += count;
|
||||
}
|
||||
}
|
||||
|
||||
private static byte[] BuildQuery(string host, out ushort transactionId)
|
||||
{
|
||||
var name = EncodeName(host);
|
||||
transactionId = (ushort)RandomNumberGenerator.GetInt32(0, 65536);
|
||||
var query = new byte[HeaderLength + name.Length + 4];
|
||||
BinaryPrimitives.WriteUInt16BigEndian(query.AsSpan(0, 2), transactionId);
|
||||
BinaryPrimitives.WriteUInt16BigEndian(query.AsSpan(2, 2), 0x0100); // recursion desired
|
||||
BinaryPrimitives.WriteUInt16BigEndian(query.AsSpan(4, 2), 1);
|
||||
name.CopyTo(query, HeaderLength);
|
||||
BinaryPrimitives.WriteUInt16BigEndian(query.AsSpan(HeaderLength + name.Length, 2), 1);
|
||||
BinaryPrimitives.WriteUInt16BigEndian(query.AsSpan(HeaderLength + name.Length + 2, 2), 1);
|
||||
return query;
|
||||
}
|
||||
|
||||
private static byte[] EncodeName(string host)
|
||||
{
|
||||
if (string.IsNullOrWhiteSpace(host)) throw new ArgumentException("DNS 查询名称不能为空。", nameof(host));
|
||||
var normalized = NormalizeHost(host);
|
||||
if (IPAddress.TryParse(normalized, out _))
|
||||
throw new ArgumentException("DNS A 查询需要域名,不能使用 IP 地址。", nameof(host));
|
||||
if (normalized.Length > 253) throw new ArgumentException("DNS 查询名称过长。", nameof(host));
|
||||
var labels = normalized.Split('.');
|
||||
var length = 1;
|
||||
foreach (var label in labels)
|
||||
{
|
||||
if (label.Length is 0 or > 63) throw new ArgumentException("DNS 标签长度无效。", nameof(host));
|
||||
length += label.Length + 1;
|
||||
}
|
||||
var wire = new byte[length];
|
||||
var offset = 0;
|
||||
foreach (var label in labels)
|
||||
{
|
||||
wire[offset++] = (byte)label.Length;
|
||||
var bytes = Encoding.ASCII.GetBytes(label);
|
||||
bytes.CopyTo(wire, offset);
|
||||
offset += bytes.Length;
|
||||
}
|
||||
wire[offset] = 0;
|
||||
return wire;
|
||||
}
|
||||
|
||||
private static string NormalizeHost(string host)
|
||||
{
|
||||
if (string.IsNullOrWhiteSpace(host)) throw new ArgumentException("DNS 名称不能为空。", nameof(host));
|
||||
var ascii = new IdnMapping().GetAscii(host.Trim()).TrimEnd('.');
|
||||
if (ascii.Length == 0) throw new ArgumentException("DNS 名称不能为空。", nameof(host));
|
||||
return ascii;
|
||||
}
|
||||
|
||||
private static ushort ReadUInt16(ReadOnlySpan<byte> data, int offset)
|
||||
{
|
||||
EnsureAvailable(data, offset, 2);
|
||||
return BinaryPrimitives.ReadUInt16BigEndian(data.Slice(offset, 2));
|
||||
}
|
||||
|
||||
private static void EnsureAvailable(ReadOnlySpan<byte> data, int offset, int length)
|
||||
{
|
||||
if (offset < 0 || length < 0 || offset > data.Length - length)
|
||||
throw new InvalidDataException("DNS 响应被截断或长度字段无效。");
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,585 @@
|
||||
using System.Diagnostics;
|
||||
using System.Globalization;
|
||||
using System.Net;
|
||||
using System.Net.Security;
|
||||
using System.Net.Sockets;
|
||||
using System.Runtime.CompilerServices;
|
||||
|
||||
[assembly: InternalsVisibleTo("GatewayKeepalive.ProbeTests")]
|
||||
|
||||
namespace GatewayKeepalive.Core;
|
||||
|
||||
/// <summary>Performs bounded IPv4 checks for the selected adapter without using system DNS or ICMP.</summary>
|
||||
public sealed class NetworkHealthProbe : IHealthProbe
|
||||
{
|
||||
private const int TlsPort = 443;
|
||||
private readonly Func<IPAddress, int, string?, bool>? _routeValidator;
|
||||
|
||||
public NetworkHealthProbe(Func<IPAddress, int, string?, bool>? routeValidator = null) =>
|
||||
_routeValidator = routeValidator;
|
||||
|
||||
public async Task<HealthReport> CheckAsync(
|
||||
AppSettings settings,
|
||||
NetworkSnapshot snapshot,
|
||||
CancellationToken cancellationToken)
|
||||
{
|
||||
ArgumentNullException.ThrowIfNull(settings);
|
||||
ArgumentNullException.ThrowIfNull(snapshot);
|
||||
settings.Validate(requireConfigured: false);
|
||||
cancellationToken.ThrowIfCancellationRequested();
|
||||
var proxyTargets = ParseTargets(settings.TestHosts);
|
||||
var egressSelection = SelectEgressTargets(settings, snapshot);
|
||||
var egressTargets = ParseTargets(egressSelection.Hosts);
|
||||
var proxyDnsServers = ProxyDnsServers(settings);
|
||||
var effectiveDnsServers = EffectiveDnsServers(snapshot);
|
||||
|
||||
using var round = CancellationTokenSource.CreateLinkedTokenSource(cancellationToken);
|
||||
round.CancelAfter(settings.RoundTimeoutMs);
|
||||
var roundToken = round.Token;
|
||||
|
||||
var proxyTask = RunAsync(
|
||||
"旁路由连接",
|
||||
token => ProbeProxyTcpAsync(settings, snapshot, token),
|
||||
detail => new ProbeResult("旁路由连接", true, 0, detail),
|
||||
_ => null,
|
||||
settings,
|
||||
roundToken,
|
||||
cancellationToken);
|
||||
var proxyDnsTask = RunAsync(
|
||||
"旁路由 DNS",
|
||||
token => ResolveTargetsCoreAsync(snapshot, proxyTargets, proxyDnsServers, token),
|
||||
stage => new ProbeResult(
|
||||
"旁路由 DNS",
|
||||
MeetsHealthyTargets(stage.ResolvedCount, settings.RequiredHealthyTargets),
|
||||
0,
|
||||
BuildDnsDetail(stage, settings.RequiredHealthyTargets, proxyTargets.Length)),
|
||||
failure => new DnsStage(
|
||||
proxyTargets.Select(x => new Resolution(x, [], failure.Detail)).ToArray(),
|
||||
0),
|
||||
settings,
|
||||
roundToken,
|
||||
cancellationToken);
|
||||
Task<ProbeExecution<SocksStage>> socksTask = settings.UseSocks
|
||||
? RunAsync(
|
||||
"SOCKS5",
|
||||
token => ProbeSocksAllAsync(settings, snapshot, proxyTargets, token),
|
||||
stage => new ProbeResult(
|
||||
"SOCKS5",
|
||||
MeetsHealthyTargets(stage.HealthyCount, settings.RequiredHealthyTargets),
|
||||
0,
|
||||
BuildSocksDetail(stage, settings.RequiredHealthyTargets, proxyTargets.Length)),
|
||||
failure => new SocksStage(
|
||||
0,
|
||||
proxyTargets.Select(x => new SocksOutcome(x.Host, false, failure.Detail)).ToArray()),
|
||||
settings,
|
||||
roundToken,
|
||||
cancellationToken)
|
||||
: Task.FromResult(new ProbeExecution<SocksStage>(
|
||||
null,
|
||||
new ProbeResult("SOCKS5", true, 0, "未启用")));
|
||||
|
||||
var egressTask = RunAsync(
|
||||
"当前出口",
|
||||
token => ProbeEgressWithDnsCoreAsync(
|
||||
snapshot, egressTargets, effectiveDnsServers, token),
|
||||
stage => new ProbeResult(
|
||||
"当前出口",
|
||||
MeetsHealthyTargets(stage.HealthyCount, egressSelection.RequiredHealthyTargets),
|
||||
0,
|
||||
BuildEgressDetail(stage, egressSelection.RequiredHealthyTargets, egressTargets.Length)),
|
||||
failure => new EgressStage(
|
||||
0,
|
||||
egressTargets.Select(x => new EgressOutcome(
|
||||
x.Host,
|
||||
false,
|
||||
failure.Detail)).ToArray()),
|
||||
settings,
|
||||
roundToken,
|
||||
cancellationToken);
|
||||
|
||||
await Task.WhenAll(proxyTask, proxyDnsTask, socksTask, egressTask).ConfigureAwait(false);
|
||||
var proxy = await proxyTask.ConfigureAwait(false);
|
||||
var proxyDns = await proxyDnsTask.ConfigureAwait(false);
|
||||
var socks = await socksTask.ConfigureAwait(false);
|
||||
var egress = await egressTask.ConfigureAwait(false);
|
||||
cancellationToken.ThrowIfCancellationRequested();
|
||||
|
||||
var checks = new List<ProbeResult> { proxy.Result, proxyDns.Result };
|
||||
if (settings.UseSocks) checks.Add(socks.Result);
|
||||
checks.Add(egress.Result);
|
||||
return AssembleHealthReport(
|
||||
checks.ToArray(),
|
||||
proxy.Result.Success,
|
||||
proxyDns.Result.Success,
|
||||
settings.UseSocks ? socks.Result.Success : null,
|
||||
settings.UseSocks,
|
||||
egress.Value!.HealthyCount,
|
||||
egressSelection.RequiredHealthyTargets,
|
||||
DateTimeOffset.UtcNow);
|
||||
}
|
||||
|
||||
public async Task<bool> VerifyInternetAsync(
|
||||
AppSettings settings,
|
||||
NetworkSnapshot snapshot,
|
||||
CancellationToken cancellationToken)
|
||||
{
|
||||
ArgumentNullException.ThrowIfNull(settings);
|
||||
ArgumentNullException.ThrowIfNull(snapshot);
|
||||
settings.Validate(requireConfigured: false);
|
||||
cancellationToken.ThrowIfCancellationRequested();
|
||||
var egressSelection = SelectEgressTargets(settings, snapshot);
|
||||
var targets = ParseTargets(egressSelection.Hosts);
|
||||
var effectiveDnsServers = EffectiveDnsServers(snapshot);
|
||||
using var round = CancellationTokenSource.CreateLinkedTokenSource(cancellationToken);
|
||||
round.CancelAfter(settings.RoundTimeoutMs);
|
||||
try
|
||||
{
|
||||
var egress = await ProbeEgressWithDnsCoreAsync(
|
||||
snapshot, targets, effectiveDnsServers, round.Token)
|
||||
.ConfigureAwait(false);
|
||||
cancellationToken.ThrowIfCancellationRequested();
|
||||
return MeetsHealthyTargets(egress.HealthyCount, egressSelection.RequiredHealthyTargets);
|
||||
}
|
||||
catch (OperationCanceledException) when (cancellationToken.IsCancellationRequested)
|
||||
{
|
||||
throw;
|
||||
}
|
||||
catch (OperationCanceledException)
|
||||
{
|
||||
return false;
|
||||
}
|
||||
catch (Exception)
|
||||
{
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
internal static HealthReport AssembleHealthReport(
|
||||
ProbeResult[] checks,
|
||||
bool proxyTcpSuccess,
|
||||
bool proxyDnsSuccess,
|
||||
bool? socksSuccess,
|
||||
bool useSocks,
|
||||
int healthyTargets,
|
||||
int requiredHealthyTargets,
|
||||
DateTimeOffset checkedAt)
|
||||
{
|
||||
var internetHealthy = MeetsHealthyTargets(healthyTargets, requiredHealthyTargets);
|
||||
var proxyHealthy = useSocks
|
||||
? proxyTcpSuccess && proxyDnsSuccess && socksSuccess == true
|
||||
: proxyDnsSuccess;
|
||||
return new HealthReport(checks, proxyHealthy, internetHealthy, checkedAt);
|
||||
}
|
||||
|
||||
internal static bool MeetsHealthyTargets(int healthyCount, int requiredHealthyTargets) =>
|
||||
healthyCount >= requiredHealthyTargets;
|
||||
|
||||
internal static (string[] Hosts, int RequiredHealthyTargets, bool IsProxy) SelectEgressTargets(
|
||||
AppSettings settings,
|
||||
NetworkSnapshot snapshot) =>
|
||||
string.Equals(snapshot.Gateway, settings.ProxyGateway, StringComparison.Ordinal)
|
||||
? (settings.TestHosts, settings.RequiredHealthyTargets, true)
|
||||
: (settings.DirectTestHosts, settings.RequiredDirectHealthyTargets, false);
|
||||
|
||||
internal static IPAddress[] ProxyDnsServers(AppSettings settings) =>
|
||||
ParseDnsServers([settings.ProxyDns]);
|
||||
|
||||
internal static IPAddress[] EffectiveDnsServers(NetworkSnapshot snapshot) =>
|
||||
ParseDnsServers(snapshot.EffectiveDns);
|
||||
|
||||
internal static IPAddress[] ParseDnsServers(IEnumerable<string> values) =>
|
||||
values.Select(value => IPAddress.TryParse(value.Trim(), out var address) ? address : null)
|
||||
.Where(address => address is not null && address.AddressFamily == AddressFamily.InterNetwork)
|
||||
.Cast<IPAddress>()
|
||||
.Distinct()
|
||||
.ToArray();
|
||||
|
||||
private static async Task<ProbeExecution<T>> RunAsync<T>(
|
||||
string name,
|
||||
Func<CancellationToken, Task<T>> operation,
|
||||
Func<T, ProbeResult> toResult,
|
||||
Func<ProbeResult, T?> failureFactory,
|
||||
AppSettings settings,
|
||||
CancellationToken roundToken,
|
||||
CancellationToken callerToken) where T : class
|
||||
{
|
||||
var stopwatch = Stopwatch.StartNew();
|
||||
using var probe = CancellationTokenSource.CreateLinkedTokenSource(callerToken, roundToken);
|
||||
probe.CancelAfter(settings.ProbeTimeoutMs);
|
||||
try
|
||||
{
|
||||
var value = await operation(probe.Token).ConfigureAwait(false);
|
||||
callerToken.ThrowIfCancellationRequested();
|
||||
stopwatch.Stop();
|
||||
var result = toResult(value);
|
||||
return new ProbeExecution<T>(value, result with { ElapsedMs = stopwatch.ElapsedMilliseconds });
|
||||
}
|
||||
catch (OperationCanceledException) when (callerToken.IsCancellationRequested)
|
||||
{
|
||||
throw;
|
||||
}
|
||||
catch (OperationCanceledException)
|
||||
{
|
||||
stopwatch.Stop();
|
||||
var detail = roundToken.IsCancellationRequested
|
||||
? "整轮预算已耗尽。"
|
||||
: $"超过单探针超时 {settings.ProbeTimeoutMs} ms。";
|
||||
return Failed(name, detail, stopwatch, failureFactory);
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
stopwatch.Stop();
|
||||
return Failed(name, ex.GetBaseException().Message, stopwatch, failureFactory);
|
||||
}
|
||||
|
||||
static ProbeExecution<T> Failed(
|
||||
string name,
|
||||
string detail,
|
||||
Stopwatch stopwatch,
|
||||
Func<ProbeResult, T?> failureFactory)
|
||||
{
|
||||
var result = new ProbeResult(name, false, stopwatch.ElapsedMilliseconds, detail);
|
||||
return new ProbeExecution<T>(failureFactory(result), result);
|
||||
}
|
||||
}
|
||||
|
||||
private static async Task<string> ProbeProxyTcpAsync(
|
||||
AppSettings settings,
|
||||
NetworkSnapshot snapshot,
|
||||
CancellationToken ct)
|
||||
{
|
||||
var hostText = settings.UseSocks ? settings.ProxyGateway : settings.ProxyDns;
|
||||
var port = settings.UseSocks ? settings.SocksPort : DnsProbe.DefaultPort;
|
||||
if (!IPAddress.TryParse(hostText, out var host) || host.AddressFamily != AddressFamily.InterNetwork)
|
||||
throw new InvalidDataException("旁路由 TCP 目标不是 IPv4 地址。");
|
||||
using var client = await ConnectTcpAsync(snapshot, host, port, ct).ConfigureAwait(false);
|
||||
return settings.UseSocks
|
||||
? $"{host}:{port} TCP 可达"
|
||||
: $"{host}:{port} TCP 可达(未启用 SOCKS)";
|
||||
}
|
||||
|
||||
private static async Task<SocksStage> ProbeSocksAllAsync(
|
||||
AppSettings settings,
|
||||
NetworkSnapshot snapshot,
|
||||
TargetSpec[] targets,
|
||||
CancellationToken ct)
|
||||
{
|
||||
if (!IPAddress.TryParse(settings.ProxyGateway, out var proxy) ||
|
||||
proxy.AddressFamily != AddressFamily.InterNetwork)
|
||||
throw new InvalidDataException("SOCKS5 服务器不是 IPv4 地址。");
|
||||
var outcomes = await Task.WhenAll(targets.Select(target =>
|
||||
ProbeSocksOneAsync(settings, snapshot, proxy, target, ct))).ConfigureAwait(false);
|
||||
return new SocksStage(outcomes.Count(x => x.Success), outcomes);
|
||||
}
|
||||
|
||||
private static async Task<SocksOutcome> ProbeSocksOneAsync(
|
||||
AppSettings settings,
|
||||
NetworkSnapshot snapshot,
|
||||
IPAddress proxy,
|
||||
TargetSpec target,
|
||||
CancellationToken ct)
|
||||
{
|
||||
try
|
||||
{
|
||||
using var client = await ConnectTcpAsync(snapshot, proxy, settings.SocksPort, ct)
|
||||
.ConfigureAwait(false);
|
||||
await using var stream = client.GetStream();
|
||||
await Socks5Probe.ConnectAsync(stream, target.Host, TlsPort, ct).ConfigureAwait(false);
|
||||
return new SocksOutcome(target.Host, true, $"{target.Host}:{TlsPort} CONNECT 成功");
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
return new SocksOutcome(
|
||||
target.Host,
|
||||
false,
|
||||
$"{target.Host}: {ex.GetBaseException().Message}");
|
||||
}
|
||||
}
|
||||
|
||||
private static async Task<ResolvedTarget> ResolveTargetAsync(
|
||||
NetworkSnapshot snapshot,
|
||||
TargetSpec target,
|
||||
IPAddress[] dnsServers,
|
||||
CancellationToken ct)
|
||||
{
|
||||
if (target.Address is not null)
|
||||
return new ResolvedTarget(new Resolution(target, [target.Address], null), true);
|
||||
if (dnsServers.Length == 0)
|
||||
return new ResolvedTarget(
|
||||
new Resolution(target, [], "未提供明确的 IPv4 DNS 服务器,未使用系统 DNS。"),
|
||||
false);
|
||||
|
||||
using var targetAttempts = CancellationTokenSource.CreateLinkedTokenSource(ct);
|
||||
var pending = dnsServers
|
||||
.Select(server => ResolveFromServerAsync(snapshot, target, server, targetAttempts.Token))
|
||||
.ToList();
|
||||
var errors = new List<string>();
|
||||
while (pending.Count > 0)
|
||||
{
|
||||
var completed = await Task.WhenAny(pending).ConfigureAwait(false);
|
||||
pending.Remove(completed);
|
||||
var attempt = await completed.ConfigureAwait(false);
|
||||
if (attempt.Addresses is { } addresses)
|
||||
{
|
||||
targetAttempts.Cancel();
|
||||
await Task.WhenAll(pending).ConfigureAwait(false);
|
||||
return new ResolvedTarget(new Resolution(target, addresses, null), true);
|
||||
}
|
||||
if (!string.IsNullOrWhiteSpace(attempt.Error)) errors.Add(attempt.Error);
|
||||
}
|
||||
|
||||
return new ResolvedTarget(
|
||||
new Resolution(target, [], errors.Count == 0 ? "DNS 未返回地址。" : string.Join(";", errors)),
|
||||
false);
|
||||
}
|
||||
|
||||
private static async Task<DnsStage> ResolveTargetsCoreAsync(
|
||||
NetworkSnapshot snapshot,
|
||||
TargetSpec[] targets,
|
||||
IPAddress[] dnsServers,
|
||||
CancellationToken ct)
|
||||
{
|
||||
var results = await Task.WhenAll(targets.Select(target =>
|
||||
ResolveTargetAsync(snapshot, target, dnsServers, ct))).ConfigureAwait(false);
|
||||
return new DnsStage(
|
||||
results.Select(x => x.Resolution).ToArray(),
|
||||
results.Count(x => x.Success));
|
||||
}
|
||||
|
||||
private static async Task<DnsAttempt> ResolveFromServerAsync(
|
||||
NetworkSnapshot snapshot,
|
||||
TargetSpec target,
|
||||
IPAddress server,
|
||||
CancellationToken ct)
|
||||
{
|
||||
try
|
||||
{
|
||||
var source = SelectSourceAddress(snapshot, server)
|
||||
?? throw new InvalidOperationException("网卡没有可用的 IPv4 源地址。");
|
||||
var addresses = await DnsProbe.QueryAAsync(server, target.Host, source, ct)
|
||||
.ConfigureAwait(false);
|
||||
if (addresses.Length == 0) throw new InvalidDataException("DNS A 记录为空。");
|
||||
return new DnsAttempt(addresses, null);
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
return new DnsAttempt(null, $"{server}: {ex.GetBaseException().Message}");
|
||||
}
|
||||
}
|
||||
|
||||
private static string BuildDnsDetail(DnsStage stage, int required, int total)
|
||||
{
|
||||
var failures = stage.Resolutions
|
||||
.Where(x => x.Addresses.Length == 0)
|
||||
.Select(x => x.Error)
|
||||
.Where(x => x is not null)
|
||||
.ToArray();
|
||||
var source = stage.Resolutions.All(x => x.Target.Address is not null)
|
||||
? "目标均为 IPv4 字面量,未使用系统 DNS"
|
||||
: $"指定 DNS 解析成功 {stage.ResolvedCount}/{total},需要 {required}";
|
||||
return failures.Length == 0 ? source : source + ";" + string.Join(";", failures);
|
||||
}
|
||||
|
||||
private static string BuildSocksDetail(SocksStage stage, int required, int total)
|
||||
{
|
||||
var summary = $"SOCKS CONNECT 成功 {stage.HealthyCount}/{total},需要 {required}";
|
||||
var failures = stage.Outcomes.Where(x => !x.Success).Select(x => x.Detail).ToArray();
|
||||
return failures.Length == 0 ? summary : summary + ";" + string.Join(";", failures);
|
||||
}
|
||||
|
||||
private async Task<EgressStage> ProbeEgressWithDnsCoreAsync(
|
||||
NetworkSnapshot snapshot,
|
||||
TargetSpec[] targets,
|
||||
IPAddress[] dnsServers,
|
||||
CancellationToken ct)
|
||||
{
|
||||
var outcomes = await Task.WhenAll(targets.Select(target =>
|
||||
ProbeEgressTargetWithDnsAsync(snapshot, target, dnsServers, ct))).ConfigureAwait(false);
|
||||
return new EgressStage(outcomes.Count(x => x.Success), outcomes);
|
||||
}
|
||||
|
||||
private async Task<EgressOutcome> ProbeEgressTargetWithDnsAsync(
|
||||
NetworkSnapshot snapshot,
|
||||
TargetSpec target,
|
||||
IPAddress[] dnsServers,
|
||||
CancellationToken ct)
|
||||
{
|
||||
var resolved = await ResolveTargetAsync(snapshot, target, dnsServers, ct)
|
||||
.ConfigureAwait(false);
|
||||
if (!resolved.Success)
|
||||
return new EgressOutcome(
|
||||
target.Host,
|
||||
false,
|
||||
resolved.Resolution.Error ?? "未解析到 IPv4 地址");
|
||||
return await ProbeEgressTargetAsync(snapshot, resolved.Resolution, ct)
|
||||
.ConfigureAwait(false);
|
||||
}
|
||||
|
||||
private async Task<EgressOutcome> ProbeEgressTargetAsync(
|
||||
NetworkSnapshot snapshot,
|
||||
Resolution resolution,
|
||||
CancellationToken ct)
|
||||
{
|
||||
if (resolution.Addresses.Length == 0)
|
||||
return new EgressOutcome(
|
||||
resolution.Target.Host,
|
||||
false,
|
||||
resolution.Error ?? "未解析到 IPv4 地址");
|
||||
|
||||
var attempts = await Task.WhenAll(resolution.Addresses
|
||||
.Where(x => x.AddressFamily == AddressFamily.InterNetwork)
|
||||
.Take(4)
|
||||
.Select(address => ProbeTlsAddressAsync(snapshot, resolution.Target, address, ct)))
|
||||
.ConfigureAwait(false);
|
||||
var succeeded = attempts.FirstOrDefault(x => x.Success);
|
||||
if (succeeded is not null)
|
||||
return new EgressOutcome(resolution.Target.Host, true, succeeded.Detail);
|
||||
|
||||
var failures = attempts.Select(x => x.Detail).ToArray();
|
||||
return new EgressOutcome(
|
||||
resolution.Target.Host,
|
||||
false,
|
||||
failures.Length == 0 ? "没有可用 IPv4 地址" : string.Join(";", failures));
|
||||
}
|
||||
|
||||
private async Task<AddressOutcome> ProbeTlsAddressAsync(
|
||||
NetworkSnapshot snapshot,
|
||||
TargetSpec target,
|
||||
IPAddress address,
|
||||
CancellationToken ct)
|
||||
{
|
||||
try
|
||||
{
|
||||
if (_routeValidator is not null &&
|
||||
!_routeValidator(address, snapshot.Adapter.Index, snapshot.Gateway))
|
||||
throw new InvalidOperationException(
|
||||
$"目标 {address} 的有效路由不属于网卡 {snapshot.Adapter.Index} 或未经过预期网关 {snapshot.Gateway ?? "(空)"}。");
|
||||
using var client = await ConnectTcpAsync(snapshot, address, TlsPort, ct).ConfigureAwait(false);
|
||||
await using var tcp = client.GetStream();
|
||||
await AuthenticateTlsAsync(tcp, target.Host, ct).ConfigureAwait(false);
|
||||
return new AddressOutcome(true, $"{target.Host} ({address}): TLS 握手与证书验证成功");
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
return new AddressOutcome(false, $"{target.Host} ({address}): {ex.GetBaseException().Message}");
|
||||
}
|
||||
}
|
||||
|
||||
internal static async Task AuthenticateTlsAsync(
|
||||
Stream stream,
|
||||
string targetHost,
|
||||
CancellationToken ct)
|
||||
{
|
||||
await using var tls = new SslStream(stream, leaveInnerStreamOpen: false);
|
||||
var options = new SslClientAuthenticationOptions
|
||||
{
|
||||
TargetHost = targetHost,
|
||||
RemoteCertificateValidationCallback = static (_, _, _, errors) =>
|
||||
errors == SslPolicyErrors.None
|
||||
};
|
||||
await tls.AuthenticateAsClientAsync(options, ct).ConfigureAwait(false);
|
||||
}
|
||||
|
||||
private static string BuildEgressDetail(EgressStage stage, int required, int total)
|
||||
{
|
||||
var summary = $"TLS 出口成功 {stage.HealthyCount}/{total},需要 {required}";
|
||||
var failures = stage.Outcomes.Where(x => !x.Success).Select(x => x.Detail).ToArray();
|
||||
return failures.Length == 0 ? summary : summary + ";" + string.Join(";", failures);
|
||||
}
|
||||
|
||||
private static async Task<TcpClient> ConnectTcpAsync(
|
||||
NetworkSnapshot snapshot,
|
||||
IPAddress target,
|
||||
int port,
|
||||
CancellationToken ct)
|
||||
{
|
||||
if (target.AddressFamily != AddressFamily.InterNetwork)
|
||||
throw new ArgumentException("所有网络探测目标必须是 IPv4 地址。", nameof(target));
|
||||
var source = SelectSourceAddress(snapshot, target)
|
||||
?? throw new InvalidOperationException("网卡没有可用的 IPv4 源地址。");
|
||||
var client = new TcpClient(AddressFamily.InterNetwork) { NoDelay = true };
|
||||
try
|
||||
{
|
||||
client.Client.Bind(new IPEndPoint(source, 0));
|
||||
await client.ConnectAsync(new IPEndPoint(target, port), ct).ConfigureAwait(false);
|
||||
return client;
|
||||
}
|
||||
catch
|
||||
{
|
||||
client.Dispose();
|
||||
throw;
|
||||
}
|
||||
}
|
||||
|
||||
private static IPAddress? SelectSourceAddress(NetworkSnapshot snapshot, IPAddress destination)
|
||||
{
|
||||
var candidates = new List<(IPAddress Address, int PrefixLength)>();
|
||||
foreach (var configured in snapshot.Adapter.Addresses)
|
||||
{
|
||||
if (IPAddress.TryParse(configured.Address, out var address) &&
|
||||
address.AddressFamily == AddressFamily.InterNetwork &&
|
||||
!address.Equals(IPAddress.Any) &&
|
||||
!address.Equals(IPAddress.Broadcast))
|
||||
candidates.Add((address, configured.PrefixLength));
|
||||
}
|
||||
if (candidates.Count == 0) return null;
|
||||
foreach (var candidate in candidates)
|
||||
{
|
||||
if (IsSameSubnet(
|
||||
new Ipv4Address(candidate.Address.ToString(), candidate.PrefixLength),
|
||||
destination))
|
||||
return candidate.Address;
|
||||
}
|
||||
return candidates[0].Address;
|
||||
}
|
||||
|
||||
private static bool IsSameSubnet(Ipv4Address local, IPAddress destination)
|
||||
{
|
||||
if (local.PrefixLength is < 1 or > 32 || destination.AddressFamily != AddressFamily.InterNetwork)
|
||||
return false;
|
||||
var left = IPAddress.Parse(local.Address).GetAddressBytes();
|
||||
var right = destination.GetAddressBytes();
|
||||
for (var bit = 0; bit < local.PrefixLength; bit++)
|
||||
if ((left[bit / 8] & (1 << (7 - bit % 8))) != (right[bit / 8] & (1 << (7 - bit % 8))))
|
||||
return false;
|
||||
return true;
|
||||
}
|
||||
|
||||
private static TargetSpec[] ParseTargets(string[] hosts)
|
||||
{
|
||||
var targets = new TargetSpec[hosts.Length];
|
||||
for (var index = 0; index < hosts.Length; index++)
|
||||
{
|
||||
var raw = hosts[index].Trim();
|
||||
if (IPAddress.TryParse(raw, out var ip))
|
||||
{
|
||||
if (ip.AddressFamily != AddressFamily.InterNetwork)
|
||||
throw new ArgumentException("健康探测仅支持 IPv4 目标。", nameof(hosts));
|
||||
targets[index] = new TargetSpec(raw, ip);
|
||||
continue;
|
||||
}
|
||||
var ascii = new IdnMapping().GetAscii(raw).TrimEnd('.');
|
||||
if (ascii.Length is 0 or > 253 || Uri.CheckHostName(ascii) != UriHostNameType.Dns)
|
||||
throw new ArgumentException($"探测目标主机名无效:{hosts[index]}", nameof(hosts));
|
||||
targets[index] = new TargetSpec(ascii, null);
|
||||
}
|
||||
return targets;
|
||||
}
|
||||
|
||||
private sealed record TargetSpec(string Host, IPAddress? Address);
|
||||
private sealed record Resolution(TargetSpec Target, IPAddress[] Addresses, string? Error);
|
||||
private sealed record ResolvedTarget(Resolution Resolution, bool Success);
|
||||
private sealed record DnsAttempt(IPAddress[]? Addresses, string? Error);
|
||||
private sealed record DnsStage(Resolution[] Resolutions, int ResolvedCount);
|
||||
private sealed record SocksOutcome(string Host, bool Success, string Detail);
|
||||
private sealed record SocksStage(int HealthyCount, SocksOutcome[] Outcomes);
|
||||
private sealed record AddressOutcome(bool Success, string Detail);
|
||||
private sealed record EgressOutcome(string Host, bool Success, string Detail);
|
||||
private sealed record EgressStage(int HealthyCount, EgressOutcome[] Outcomes);
|
||||
|
||||
private sealed class ProbeExecution<T>(T? value, ProbeResult? result) where T : class
|
||||
{
|
||||
public T? Value { get; } = value;
|
||||
public ProbeResult Result { get; } = result ?? new ProbeResult(string.Empty, false, 0, string.Empty);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,137 @@
|
||||
using System.Globalization;
|
||||
using System.Net;
|
||||
using System.Net.Sockets;
|
||||
using System.Text;
|
||||
|
||||
namespace GatewayKeepalive.Core;
|
||||
|
||||
public sealed class Socks5Exception : IOException
|
||||
{
|
||||
public Socks5Exception(byte replyCode, string message) : base(message) => ReplyCode = replyCode;
|
||||
|
||||
public byte ReplyCode { get; }
|
||||
}
|
||||
|
||||
/// <summary>Minimal, strict SOCKS5 CONNECT client used by the health probe.</summary>
|
||||
public static class Socks5Probe
|
||||
{
|
||||
private const byte Version = 5;
|
||||
private const byte NoAuthentication = 0;
|
||||
|
||||
/// <summary>
|
||||
/// Negotiates a SOCKS5 CONNECT on an existing stream. The stream remains owned by the caller.
|
||||
/// The method consumes exactly one complete reply and never reads past it.
|
||||
/// </summary>
|
||||
public static async Task ConnectAsync(Stream stream, string host, int port, CancellationToken ct)
|
||||
{
|
||||
ArgumentNullException.ThrowIfNull(stream);
|
||||
ct.ThrowIfCancellationRequested();
|
||||
if (port is < 1 or > 65535) throw new ArgumentOutOfRangeException(nameof(port));
|
||||
|
||||
var address = EncodeTarget(host);
|
||||
await WriteAllAsync(stream, new byte[] { Version, 1, NoAuthentication }, ct).ConfigureAwait(false);
|
||||
await stream.FlushAsync(ct).ConfigureAwait(false);
|
||||
|
||||
var greetingReply = new byte[2];
|
||||
await ReadExactlyAsync(stream, greetingReply, ct).ConfigureAwait(false);
|
||||
if (greetingReply[0] != Version)
|
||||
throw new InvalidDataException($"SOCKS5 握手响应版本无效:{greetingReply[0]}。");
|
||||
if (greetingReply[1] != NoAuthentication)
|
||||
{
|
||||
throw greetingReply[1] switch
|
||||
{
|
||||
0x02 => new Socks5Exception(greetingReply[1], "SOCKS5 服务要求用户名密码认证;程序仅提供无认证。"),
|
||||
0xff => new Socks5Exception(greetingReply[1], "SOCKS5 服务不接受无认证方式。"),
|
||||
_ => new Socks5Exception(greetingReply[1], $"SOCKS5 服务选择了未提供的方法:0x{greetingReply[1]:X2}。")
|
||||
};
|
||||
}
|
||||
|
||||
var request = new byte[3 + address.Length + 2];
|
||||
request[0] = Version;
|
||||
request[1] = 1; // CONNECT
|
||||
request[2] = 0;
|
||||
address.CopyTo(request, 3);
|
||||
request[^2] = (byte)(port >> 8);
|
||||
request[^1] = (byte)port;
|
||||
await WriteAllAsync(stream, request, ct).ConfigureAwait(false);
|
||||
await stream.FlushAsync(ct).ConfigureAwait(false);
|
||||
|
||||
var reply = new byte[4];
|
||||
await ReadExactlyAsync(stream, reply, ct).ConfigureAwait(false);
|
||||
if (reply[0] != Version)
|
||||
throw new InvalidDataException($"SOCKS5 CONNECT 响应版本无效:{reply[0]}。");
|
||||
if (reply[2] != 0)
|
||||
throw new InvalidDataException($"SOCKS5 CONNECT 响应保留字段无效:{reply[2]}。");
|
||||
|
||||
// Consume the variable-length bound endpoint even for an error reply, so a
|
||||
// caller can safely inspect or reuse the stream after a policy failure.
|
||||
await ConsumeBoundEndpointAsync(stream, reply[3], ct).ConfigureAwait(false);
|
||||
if (reply[1] != 0)
|
||||
throw new Socks5Exception(reply[1], $"SOCKS5 CONNECT 被拒绝,REP=0x{reply[1]:X2}。");
|
||||
}
|
||||
|
||||
private static byte[] EncodeTarget(string host)
|
||||
{
|
||||
if (string.IsNullOrWhiteSpace(host)) throw new ArgumentException("SOCKS5 目标主机为空。", nameof(host));
|
||||
host = host.Trim();
|
||||
if (host.Contains('\0') || host.Contains('/') || host.Contains('\\'))
|
||||
throw new ArgumentException("SOCKS5 目标主机格式无效。", nameof(host));
|
||||
|
||||
if (IPAddress.TryParse(host, out var ip))
|
||||
{
|
||||
var bytes = ip.GetAddressBytes();
|
||||
if (ip.AddressFamily == AddressFamily.InterNetwork)
|
||||
return [1, .. bytes];
|
||||
if (ip.AddressFamily == AddressFamily.InterNetworkV6)
|
||||
return [4, .. bytes];
|
||||
throw new ArgumentException("SOCKS5 目标地址类型不受支持。", nameof(host));
|
||||
}
|
||||
|
||||
var ascii = new IdnMapping().GetAscii(host).TrimEnd('.');
|
||||
if (ascii.Length is 0 or > 255) throw new ArgumentException("SOCKS5 域名长度无效。", nameof(host));
|
||||
var encoded = Encoding.ASCII.GetBytes(ascii);
|
||||
if (encoded.Length is 0 or > 255) throw new ArgumentException("SOCKS5 域名长度无效。", nameof(host));
|
||||
return [3, (byte)encoded.Length, .. encoded];
|
||||
}
|
||||
|
||||
private static async Task ConsumeBoundEndpointAsync(Stream stream, byte addressType, CancellationToken ct)
|
||||
{
|
||||
var addressLength = addressType switch
|
||||
{
|
||||
1 => 4,
|
||||
4 => 16,
|
||||
3 => await ReadOneAsync(stream, ct).ConfigureAwait(false),
|
||||
_ => throw new InvalidDataException($"SOCKS5 响应 ATYP 无效:0x{addressType:X2}。")
|
||||
};
|
||||
if (addressType == 3 && addressLength == 0)
|
||||
throw new InvalidDataException("SOCKS5 响应中的域名为空。");
|
||||
|
||||
var remainder = new byte[addressLength + 2];
|
||||
await ReadExactlyAsync(stream, remainder, ct).ConfigureAwait(false);
|
||||
}
|
||||
|
||||
private static async Task<byte> ReadOneAsync(Stream stream, CancellationToken ct)
|
||||
{
|
||||
var one = new byte[1];
|
||||
await ReadExactlyAsync(stream, one, ct).ConfigureAwait(false);
|
||||
return one[0];
|
||||
}
|
||||
|
||||
internal static async Task ReadExactlyAsync(Stream stream, Memory<byte> buffer, CancellationToken ct)
|
||||
{
|
||||
var read = 0;
|
||||
while (read < buffer.Length)
|
||||
{
|
||||
ct.ThrowIfCancellationRequested();
|
||||
var count = await stream.ReadAsync(buffer[read..], ct).ConfigureAwait(false);
|
||||
if (count == 0) throw new EndOfStreamException("SOCKS5 响应在完整读取前结束。");
|
||||
read += count;
|
||||
}
|
||||
}
|
||||
|
||||
private static async Task WriteAllAsync(Stream stream, ReadOnlyMemory<byte> buffer, CancellationToken ct)
|
||||
{
|
||||
ct.ThrowIfCancellationRequested();
|
||||
await stream.WriteAsync(buffer, ct).ConfigureAwait(false);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,36 @@
|
||||
namespace GatewayKeepalive.Core;
|
||||
|
||||
public sealed class SwitchPolicy(TimeProvider? timeProvider = null)
|
||||
{
|
||||
private readonly TimeProvider _clock = timeProvider ?? TimeProvider.System;
|
||||
private long? _lastSwitch;
|
||||
private long? _retryAfterFailure;
|
||||
public int SuccessCount { get; private set; }
|
||||
public int FailureCount { get; private set; }
|
||||
|
||||
public void Reset() { SuccessCount = 0; FailureCount = 0; }
|
||||
public void Switched() { Reset(); _lastSwitch = _clock.GetTimestamp(); _retryAfterFailure = null; }
|
||||
public void Failed() { Reset(); _retryAfterFailure = _clock.GetTimestamp(); }
|
||||
|
||||
// null means observe only; true/false requests proxy/direct respectively.
|
||||
public bool? Evaluate(AppSettings settings, NetworkMode mode, HealthReport health)
|
||||
{
|
||||
if (settings.Policy != ControlPolicy.Auto || mode is not (NetworkMode.Direct or NetworkMode.Proxy)) { Reset(); return null; }
|
||||
if (mode == NetworkMode.Proxy)
|
||||
{
|
||||
FailureCount = health.ProxyHealthy && health.InternetHealthy ? 0 : Math.Min(FailureCount + 1, settings.FailureThreshold);
|
||||
SuccessCount = 0;
|
||||
return FailureCount >= settings.FailureThreshold && CooldownExpired(settings.FailureCooldownSeconds) ? false : null;
|
||||
}
|
||||
SuccessCount = health.ProxyHealthy ? Math.Min(SuccessCount + 1, settings.SuccessThreshold) : 0;
|
||||
FailureCount = 0;
|
||||
return SuccessCount >= settings.SuccessThreshold && CooldownExpired(settings.RecoveryCooldownSeconds) ? true : null;
|
||||
}
|
||||
|
||||
private bool CooldownExpired(int seconds)
|
||||
{
|
||||
var now = _clock.GetTimestamp();
|
||||
return (!_lastSwitch.HasValue || _clock.GetElapsedTime(_lastSwitch.Value, now).TotalSeconds >= seconds) &&
|
||||
(!_retryAfterFailure.HasValue || _clock.GetElapsedTime(_retryAfterFailure.Value, now).TotalSeconds >= Math.Max(30, seconds));
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,122 @@
|
||||
using System.Net;
|
||||
|
||||
namespace GatewayKeepalive.Core;
|
||||
|
||||
public sealed class RecoveryRequiredException(string message, Exception? inner = null) : Exception(message, inner);
|
||||
|
||||
public sealed class SwitchService(INetworkBackend backend, IHealthProbe probe, JsonStore store, Action<string> log)
|
||||
{
|
||||
public const string JournalFile = "switch-journal.json";
|
||||
|
||||
public NetworkSnapshot Plan(AppSettings settings, NetworkSnapshot before, bool proxy, DnsConfiguration? directDns)
|
||||
{
|
||||
settings.Validate();
|
||||
if (!before.Adapter.Up) throw new InvalidOperationException("目标网卡未连接。");
|
||||
if (string.IsNullOrWhiteSpace(before.Adapter.NetworkFingerprint)) throw new InvalidOperationException("无法识别 Windows 网络,暂不接管。");
|
||||
if (before.Dns.ProfileServers.Length > 0) throw new InvalidOperationException("该网卡使用网络配置文件 DNS,请先在系统中处理,程序不会覆盖。");
|
||||
if (before.Routes.Length == 0) throw new InvalidOperationException("没有可接管的 IPv4 默认路由。");
|
||||
if (before.Routes.Any(x => x.Gateway != settings.DirectGateway && x.Gateway != settings.ProxyGateway)) throw new InvalidOperationException("检测到配置以外的默认网关,请核对网络后重新接管。");
|
||||
var target = proxy ? settings.ProxyGateway : settings.DirectGateway;
|
||||
if (!before.Adapter.Addresses.Any(x => IsSameSubnet(x, target))) throw new InvalidOperationException("目标网关不在所选网卡的直连 IPv4 子网内。");
|
||||
if (before.Adapter.Addresses.Any(x => x.Address == target)) throw new InvalidOperationException("网关不能是本机地址。");
|
||||
var existing = before.Routes.FirstOrDefault(x => x.Gateway == target);
|
||||
var targetMetric = existing is not null && before.Gateway == target ? existing.Metric : 1u;
|
||||
var route = (existing ?? before.Routes[0] with { Gateway = target, Protocol = 3, ValidLifetime = uint.MaxValue, PreferredLifetime = uint.MaxValue, Publish = false, Immortal = false }) with { Metric = targetMetric };
|
||||
var routes = before.Routes.Where(x => x.Gateway != target).Select(x => x with { Metric = Math.Max(x.Metric, checked(targetMetric + 100)) }).Append(route).ToArray();
|
||||
var dns = proxy ? new DnsConfiguration(false, [settings.ProxyDns], []) : directDns ?? (settings.DirectDnsAutomatic ? DnsConfiguration.Auto : new(false, settings.DirectDnsServers, []));
|
||||
return before with { Routes = routes, Dns = dns };
|
||||
}
|
||||
|
||||
public async Task<NetworkSnapshot> SwitchAsync(AppSettings settings, NetworkSnapshot expected, bool proxy, DnsConfiguration? directDns, CancellationToken cancellationToken)
|
||||
{
|
||||
if (!backend.IsAdministrator) throw new UnauthorizedAccessException("修改默认网关需要管理员权限。");
|
||||
if (store.Read<SwitchJournal>(JournalFile) is not null) throw new RecoveryRequiredException("存在未完成切换,请先恢复。");
|
||||
var before = await backend.CaptureAsync(settings.AdapterId, cancellationToken);
|
||||
if (!NetworkComparison.SameConfiguration(expected, before)) throw new InvalidOperationException("网络配置已被外部修改,请重新保存配置以接管。");
|
||||
var desired = Plan(settings, before, proxy, directDns);
|
||||
var journal = new SwitchJournal(before, desired, "Prepared", DateTimeOffset.UtcNow);
|
||||
store.Write(JournalFile, journal);
|
||||
try
|
||||
{
|
||||
log($"开始切换到 {(proxy ? "旁路由" : "直连")}:{desired.Gateway}");
|
||||
store.Write(JournalFile, journal with { Stage = "ApplyingRoutes" });
|
||||
if (!NetworkComparison.SameRoutes(before.Routes, desired.Routes)) await backend.WriteRoutesGuardedAsync(desired, before, cancellationToken);
|
||||
store.Write(JournalFile, journal with { Stage = "ApplyingDns" });
|
||||
if (!NetworkComparison.SameDns(before.Dns, desired.Dns)) await backend.WriteDnsGuardedAsync(settings.AdapterId, desired.Dns, before with { Routes = desired.Routes }, cancellationToken);
|
||||
var actual = await backend.CaptureAsync(settings.AdapterId, cancellationToken);
|
||||
if (!NetworkComparison.SameConfiguration(desired, actual)) throw new IOException("路由或 DNS 写入后读回不一致。");
|
||||
using var budget = CancellationTokenSource.CreateLinkedTokenSource(cancellationToken);
|
||||
budget.CancelAfter(settings.RoundTimeoutMs);
|
||||
if (!await probe.VerifyInternetAsync(settings, actual, budget.Token)) throw new IOException("切换后 IPv4 出口验证未通过。");
|
||||
// Keep the completed journal until controller session state is durable.
|
||||
store.Write(JournalFile, journal with { Stage = "Verified" });
|
||||
log("路由、DNS 与实际出口验证通过。");
|
||||
return actual;
|
||||
}
|
||||
catch (Exception failure)
|
||||
{
|
||||
log("切换失败,开始恢复:" + failure.Message);
|
||||
try
|
||||
{
|
||||
using var rollbackBudget = new CancellationTokenSource(TimeSpan.FromSeconds(15));
|
||||
await RollbackAsync(journal, rollbackBudget.Token);
|
||||
}
|
||||
catch (Exception rollback)
|
||||
{
|
||||
throw new RecoveryRequiredException("切换失败且恢复未完成,自动控制已停止:" + rollback.Message, new AggregateException(failure, rollback));
|
||||
}
|
||||
throw new IOException("切换未成功,已恢复切换前配置:" + failure.Message, failure);
|
||||
}
|
||||
}
|
||||
|
||||
public async Task<NetworkSnapshot> RollbackAsync(SwitchJournal journal, CancellationToken cancellationToken, bool retainJournal = false)
|
||||
{
|
||||
var actual = await backend.CaptureAsync(journal.Before.Adapter.Id, cancellationToken);
|
||||
if (!NetworkComparison.BelongsToTransaction(journal, actual)) throw new RecoveryRequiredException("现场已不属于本次切换,停止恢复以保护外部配置。");
|
||||
store.Write(JournalFile, journal with { Stage = "RollingBack" });
|
||||
if (!NetworkComparison.SameRoutes(actual.Routes, journal.Before.Routes)) await backend.WriteRoutesGuardedAsync(journal.Before, actual, cancellationToken);
|
||||
if (!NetworkComparison.SameDns(actual.Dns, journal.Before.Dns)) await backend.WriteDnsGuardedAsync(journal.Before.Adapter.Id, journal.Before.Dns, actual with { Routes = journal.Before.Routes }, cancellationToken);
|
||||
var restored = await backend.CaptureAsync(journal.Before.Adapter.Id, cancellationToken);
|
||||
if (!NetworkComparison.SameConfiguration(journal.Before, restored)) throw new RecoveryRequiredException("恢复后读回不一致。");
|
||||
if (retainJournal) store.Write(JournalFile, journal with { Stage = "RolledBack" });
|
||||
else store.Delete(JournalFile);
|
||||
log("切换前配置已恢复并读回验证。");
|
||||
return restored;
|
||||
}
|
||||
|
||||
public async Task<NetworkSnapshot> RestoreBaselineAsync(NetworkSnapshot expected, NetworkSnapshot baseline, CancellationToken cancellationToken)
|
||||
{
|
||||
if (!backend.IsAdministrator) throw new UnauthorizedAccessException("恢复网络需要管理员权限。");
|
||||
if (!NetworkComparison.SameNetwork(expected, baseline)) throw new RecoveryRequiredException("原始快照不属于当前网络。");
|
||||
if (store.Read<SwitchJournal>(JournalFile) is not null) throw new RecoveryRequiredException("请先处理未完成的切换日志。");
|
||||
var actual = await backend.CaptureAsync(expected.Adapter.Id, cancellationToken);
|
||||
if (!NetworkComparison.SameConfiguration(expected, actual)) throw new RecoveryRequiredException("现场已被外部修改,不能覆盖。");
|
||||
var journal = new SwitchJournal(actual, baseline, "RestoringBaseline", DateTimeOffset.UtcNow);
|
||||
store.Write(JournalFile, journal);
|
||||
try
|
||||
{
|
||||
if (!NetworkComparison.SameRoutes(actual.Routes, baseline.Routes)) await backend.WriteRoutesGuardedAsync(baseline, actual, cancellationToken);
|
||||
if (!NetworkComparison.SameDns(actual.Dns, baseline.Dns)) await backend.WriteDnsGuardedAsync(baseline.Adapter.Id, baseline.Dns, actual with { Routes = baseline.Routes }, cancellationToken);
|
||||
actual = await backend.CaptureAsync(baseline.Adapter.Id, cancellationToken);
|
||||
if (!NetworkComparison.SameConfiguration(baseline, actual)) throw new IOException("原始配置恢复后读回不一致。");
|
||||
store.Write(JournalFile, journal with { Stage = "Verified" });
|
||||
return actual;
|
||||
}
|
||||
catch (Exception failure)
|
||||
{
|
||||
try { using var budget = new CancellationTokenSource(TimeSpan.FromSeconds(15)); await RollbackAsync(journal, budget.Token); }
|
||||
catch (Exception rollback) { throw new RecoveryRequiredException("恢复失败且补偿未完成:" + rollback.Message, failure); }
|
||||
throw new IOException("恢复失败,已退回恢复操作前的配置。", failure);
|
||||
}
|
||||
}
|
||||
|
||||
private static bool IsSameSubnet(Ipv4Address local, string address)
|
||||
{
|
||||
if (local.PrefixLength is < 1 or > 32) return false;
|
||||
var left = IPAddress.Parse(local.Address).GetAddressBytes();
|
||||
var right = IPAddress.Parse(address).GetAddressBytes();
|
||||
for (var bit = 0; bit < local.PrefixLength; bit++)
|
||||
if ((left[bit / 8] & (1 << (7 - bit % 8))) != (right[bit / 8] & (1 << (7 - bit % 8)))) return false;
|
||||
return true;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,13 @@
|
||||
<Project Sdk="Microsoft.NET.Sdk">
|
||||
<PropertyGroup>
|
||||
<OutputType>WinExe</OutputType>
|
||||
<UseWindowsForms>true</UseWindowsForms>
|
||||
<ApplicationManifest>app.manifest</ApplicationManifest>
|
||||
<AssemblyName>GatewayKeepalive</AssemblyName>
|
||||
<Version>0.1.0</Version>
|
||||
</PropertyGroup>
|
||||
<ItemGroup>
|
||||
<ProjectReference Include="../GatewayKeepalive.Core/GatewayKeepalive.Core.csproj" />
|
||||
<ProjectReference Include="../GatewayKeepalive.Windows/GatewayKeepalive.Windows.csproj" />
|
||||
</ItemGroup>
|
||||
</Project>
|
||||
@@ -0,0 +1,98 @@
|
||||
using System.ComponentModel;
|
||||
using System.Diagnostics;
|
||||
using System.Security.Principal;
|
||||
using GatewayKeepalive.Core;
|
||||
using GatewayKeepalive.Windows;
|
||||
|
||||
namespace GatewayKeepalive.Tray;
|
||||
|
||||
internal static class Program
|
||||
{
|
||||
[STAThread]
|
||||
private static int Main(string[] args)
|
||||
{
|
||||
TryInitializeWinForms();
|
||||
if (args.Contains("--preview"))
|
||||
{
|
||||
var index = Array.IndexOf(args, "--preview");
|
||||
var path = index + 1 < args.Length ? args[index + 1] : Path.Combine(AppContext.BaseDirectory, "preview");
|
||||
TrayApplicationContext.RenderPreview(path);
|
||||
return 0;
|
||||
}
|
||||
if (args.Any(x => x is "--diagnose" or "--dry-run" or "--probe")) return DiagnosticCommands.RunAsync(args).GetAwaiter().GetResult();
|
||||
if (args.Contains("--help"))
|
||||
{
|
||||
MessageBox.Show("网关保活\n直接运行:托盘程序\n--minimized:仅驻留托盘\n--diagnose --output report.json:只读诊断\n--dry-run --adapter GUID:只生成切换计划\n--preview 目录:离屏界面预览\n控制台详细输出请使用 GatewayKeepalive.Cli。", "网关保活");
|
||||
return 0;
|
||||
}
|
||||
var backend = new WindowsNetworkBackend();
|
||||
if (!backend.IsAdministrator)
|
||||
{
|
||||
try
|
||||
{
|
||||
var start = new ProcessStartInfo(Environment.ProcessPath!) { UseShellExecute = true, Verb = "runas" };
|
||||
if (string.Equals(Path.GetFileNameWithoutExtension(Environment.ProcessPath), "dotnet", StringComparison.OrdinalIgnoreCase))
|
||||
start.ArgumentList.Add(Path.Combine(AppContext.BaseDirectory, "GatewayKeepalive.dll"));
|
||||
foreach (var arg in args) start.ArgumentList.Add(arg);
|
||||
Process.Start(start);
|
||||
return 0;
|
||||
}
|
||||
catch (Win32Exception e) when (e.NativeErrorCode == 1223) { return 1; }
|
||||
}
|
||||
|
||||
using var identity = WindowsIdentity.GetCurrent();
|
||||
var suffix = identity.User!.Value;
|
||||
using var mutex = new Mutex(true, "Local\\GatewayKeepalive-" + suffix, out var created);
|
||||
using var show = new EventWaitHandle(false, EventResetMode.AutoReset, "Local\\GatewayKeepalive-Show-" + suffix);
|
||||
if (!created) { show.Set(); return 0; }
|
||||
var directory = Path.Combine(Environment.GetFolderPath(Environment.SpecialFolder.LocalApplicationData), "GatewayKeepalive");
|
||||
var logger = new FileLog(Path.Combine(directory, "logs"));
|
||||
try
|
||||
{
|
||||
var store = new JsonStore(directory);
|
||||
var startup = new LoginStartup();
|
||||
var probe = new NetworkHealthProbe(backend.ValidateRoute);
|
||||
var controller = new GatewayController(backend, probe, store, logger.Write, () => startup.IsEnabled, startup.SetEnabled);
|
||||
using var app = new TrayApplicationContext(controller, !controller.Settings.Configured || !args.Contains("--minimized"));
|
||||
using var dispatcher = new Control();
|
||||
_ = dispatcher.Handle;
|
||||
var wait = ThreadPool.RegisterWaitForSingleObject(show, (_, _) =>
|
||||
{
|
||||
try { if (!dispatcher.IsDisposed) dispatcher.BeginInvoke(() => ShowExisting(app)); }
|
||||
catch (InvalidOperationException) when (dispatcher.IsDisposed || dispatcher.Disposing) { }
|
||||
}, null, Timeout.Infinite, false);
|
||||
Application.ThreadException += (_, e) =>
|
||||
{
|
||||
logger.Write("UI异常:" + e.Exception);
|
||||
MessageBox.Show(e.Exception.Message, "网关保活", MessageBoxButtons.OK, MessageBoxIcon.Error);
|
||||
};
|
||||
controller.Start();
|
||||
try { Application.Run(app); }
|
||||
finally { wait.Unregister(null); controller.StopAsync().GetAwaiter().GetResult(); }
|
||||
return 0;
|
||||
}
|
||||
catch (Exception e)
|
||||
{
|
||||
try { logger.Write("启动/退出失败:" + e); } catch (Exception) { }
|
||||
MessageBox.Show("程序无法继续:" + e.Message + "\n配置与恢复记录保留在:\n" + directory, "网关保活", MessageBoxButtons.OK, MessageBoxIcon.Error);
|
||||
return 1;
|
||||
}
|
||||
}
|
||||
|
||||
private static void ShowExisting(TrayApplicationContext app) => app.ShowPanel();
|
||||
|
||||
/// <summary>
|
||||
/// Single-file apps extract the WinForms theming manifest to the temp directory. If that
|
||||
/// location is restricted, visual styles are decorative only for this fully custom-drawn UI;
|
||||
/// the tray must still start.
|
||||
/// </summary>
|
||||
private static void TryInitializeWinForms()
|
||||
{
|
||||
try { Application.SetHighDpiMode(HighDpiMode.PerMonitorV2); }
|
||||
catch (Exception e) when (e is InvalidOperationException or PlatformNotSupportedException) { }
|
||||
try { Application.EnableVisualStyles(); }
|
||||
catch (Exception e) when (e is UnauthorizedAccessException or IOException or InvalidOperationException or TypeInitializationException) { }
|
||||
try { Application.SetCompatibleTextRenderingDefault(false); }
|
||||
catch (InvalidOperationException) { }
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,794 @@
|
||||
using System.Diagnostics;
|
||||
using System.Drawing;
|
||||
using System.Drawing.Imaging;
|
||||
using System.Runtime.ExceptionServices;
|
||||
using System.Windows.Forms;
|
||||
using GatewayKeepalive.Core;
|
||||
using GatewayKeepalive.Tray.Ui;
|
||||
|
||||
namespace GatewayKeepalive.Tray;
|
||||
|
||||
public sealed class TrayApplicationContext : ApplicationContext
|
||||
{
|
||||
private readonly ITrayController _controller;
|
||||
private readonly SynchronizationContext? _initialContext;
|
||||
private readonly int _uiThreadId;
|
||||
private readonly Control _dispatcher;
|
||||
private readonly TrayIconFactory _icons = new();
|
||||
private readonly NotifyIcon _tray;
|
||||
private readonly ContextMenuStrip _menu;
|
||||
private readonly StatusPanelForm _panel;
|
||||
private readonly System.Windows.Forms.Timer _clockTimer;
|
||||
private ToolStripMenuItem _autoPolicyItem = null!;
|
||||
private ToolStripMenuItem _manualMenu = null!;
|
||||
private ToolStripMenuItem _manualDirectItem = null!;
|
||||
private ToolStripMenuItem _manualProxyItem = null!;
|
||||
private ToolStripMenuItem _refreshItem = null!;
|
||||
private ToolStripMenuItem _restoreItem = null!;
|
||||
private ToolStripMenuItem _autostartItem = null!;
|
||||
private ToolStripMenuItem _logsItem = null!;
|
||||
private ToolStripMenuItem _exitItem = null!;
|
||||
private readonly StatusNotificationTracker _statusNotifications = new();
|
||||
|
||||
private AppStatus _status = AppStatus.Initial;
|
||||
private SettingsForm? _settingsForm;
|
||||
private bool _busy;
|
||||
private bool _exitInProgress;
|
||||
private bool _disposed;
|
||||
|
||||
public TrayApplicationContext(ITrayController controller, bool showPanel = false)
|
||||
{
|
||||
ArgumentNullException.ThrowIfNull(controller);
|
||||
TryEnablePerMonitorV2();
|
||||
|
||||
_controller = controller;
|
||||
_uiThreadId = Environment.CurrentManagedThreadId;
|
||||
_initialContext = SynchronizationContext.Current;
|
||||
_dispatcher = new Control { Visible = false, Size = Size.Empty };
|
||||
_ = _dispatcher.Handle;
|
||||
|
||||
_panel = new StatusPanelForm();
|
||||
_panel.RefreshRequested += OnPanelRefresh;
|
||||
_panel.SettingsRequested += OnPanelSettings;
|
||||
_panel.LogsRequested += OnPanelLogs;
|
||||
_panel.PolicyRequested += OnPanelPolicy;
|
||||
|
||||
_menu = BuildMenu();
|
||||
_tray = new NotifyIcon
|
||||
{
|
||||
Visible = true,
|
||||
ContextMenuStrip = _menu,
|
||||
Icon = _icons.Get(AppStatus.Initial),
|
||||
Text = "网关保活"
|
||||
};
|
||||
_tray.MouseClick += OnTrayMouseClick;
|
||||
|
||||
_clockTimer = new System.Windows.Forms.Timer { Interval = 30_000 };
|
||||
_clockTimer.Tick += OnClockTick;
|
||||
_clockTimer.Start();
|
||||
|
||||
_controller.StatusChanged += OnStatusChanged;
|
||||
ApplyStatus(ReadStatus(AppStatus.Initial));
|
||||
|
||||
if (showPanel) ShowPanel();
|
||||
}
|
||||
|
||||
private ContextMenuStrip BuildMenu()
|
||||
{
|
||||
var menu = new ContextMenuStrip
|
||||
{
|
||||
Renderer = new ToolStripProfessionalRenderer(new DarkMenuColors()),
|
||||
BackColor = UiTheme.Card,
|
||||
ForeColor = UiTheme.Text,
|
||||
Font = UiTheme.Body,
|
||||
ShowImageMargin = false,
|
||||
ShowCheckMargin = true,
|
||||
Padding = new Padding(3)
|
||||
};
|
||||
menu.Opening += OnMenuOpening;
|
||||
|
||||
var open = new ToolStripMenuItem("打开面板") { Font = UiTheme.BodyBold };
|
||||
open.Click += (_, _) => ShowPanel();
|
||||
menu.Items.Add(open);
|
||||
|
||||
_refreshItem = new ToolStripMenuItem("立即检测");
|
||||
_refreshItem.Click += (_, _) => StartAction("检测失败", _controller.RefreshAsync);
|
||||
menu.Items.Add(_refreshItem);
|
||||
menu.Items.Add(new ToolStripSeparator());
|
||||
|
||||
_autoPolicyItem = new ToolStripMenuItem("自动保活");
|
||||
_autoPolicyItem.Click += (_, _) =>
|
||||
{
|
||||
var target = _status.Policy == ControlPolicy.Auto ? ControlPolicy.Paused : ControlPolicy.Auto;
|
||||
StartAction("自动保活设置失败", () => _controller.SetPolicyAsync(target));
|
||||
};
|
||||
menu.Items.Add(_autoPolicyItem);
|
||||
|
||||
_manualMenu = new ToolStripMenuItem("手动切换");
|
||||
_manualDirectItem = new ToolStripMenuItem("使用主路由(固定)");
|
||||
_manualDirectItem.ToolTipText = "明确进入固定模式,继续监测但不自动切换";
|
||||
_manualDirectItem.Click += (_, _) => StartAction("切换到主路由失败",
|
||||
() => _controller.SetPolicyAsync(ControlPolicy.ForceDirect));
|
||||
_manualMenu.DropDownItems.Add(_manualDirectItem);
|
||||
_manualProxyItem = new ToolStripMenuItem("使用旁路由(固定)");
|
||||
_manualProxyItem.ToolTipText = "明确进入固定模式,继续监测但不自动切换";
|
||||
_manualProxyItem.Click += (_, _) => StartAction("切换到旁路由失败",
|
||||
() => _controller.SetPolicyAsync(ControlPolicy.ForceProxy));
|
||||
_manualMenu.DropDownItems.Add(_manualProxyItem);
|
||||
menu.Items.Add(_manualMenu);
|
||||
menu.Items.Add(new ToolStripSeparator());
|
||||
|
||||
_restoreItem = new ToolStripMenuItem("恢复接管前配置…");
|
||||
_restoreItem.Click += (_, _) => RequestRestore();
|
||||
menu.Items.Add(_restoreItem);
|
||||
|
||||
_autostartItem = new ToolStripMenuItem("登录后自动启动");
|
||||
_autostartItem.Click += (_, _) => ToggleAutostart();
|
||||
menu.Items.Add(_autostartItem);
|
||||
|
||||
_logsItem = new ToolStripMenuItem("查看日志");
|
||||
_logsItem.Click += (_, _) => OpenLogs();
|
||||
menu.Items.Add(_logsItem);
|
||||
menu.Items.Add(new ToolStripSeparator());
|
||||
|
||||
_exitItem = new ToolStripMenuItem("退出");
|
||||
_exitItem.Click += (_, _) => _ = ExitAsync();
|
||||
menu.Items.Add(_exitItem);
|
||||
|
||||
foreach (ToolStripItem item in menu.Items) item.ForeColor = UiTheme.Text;
|
||||
foreach (ToolStripItem item in _manualMenu.DropDownItems) item.ForeColor = UiTheme.Text;
|
||||
return menu;
|
||||
}
|
||||
|
||||
private void OnTrayMouseClick(object? sender, MouseEventArgs e)
|
||||
{
|
||||
if (e.Button != MouseButtons.Left) return;
|
||||
if (_panel.Visible)
|
||||
{
|
||||
_panel.HidePanel();
|
||||
return;
|
||||
}
|
||||
|
||||
// Deactivate runs just before the tray click and has already fulfilled the close gesture.
|
||||
if (_panel.LastAutoHideAt is { } hiddenAt && DateTime.UtcNow - hiddenAt < TimeSpan.FromMilliseconds(350))
|
||||
return;
|
||||
ShowPanel();
|
||||
}
|
||||
|
||||
public void ShowPanel()
|
||||
{
|
||||
if (_disposed || _exitInProgress) return;
|
||||
_panel.ApplyStatus(_status);
|
||||
_panel.ShowNearTray();
|
||||
}
|
||||
|
||||
private void OnStatusChanged(AppStatus status)
|
||||
{
|
||||
if (status is null) return;
|
||||
Post(() =>
|
||||
{
|
||||
if (!_disposed) ApplyStatus(status);
|
||||
});
|
||||
}
|
||||
|
||||
private void ApplyStatus(AppStatus status)
|
||||
{
|
||||
if (_disposed) return;
|
||||
_status = status;
|
||||
_panel.ApplyStatus(status);
|
||||
RefreshTrayPresentation();
|
||||
var notifications = NotificationsEnabled();
|
||||
foreach (var notification in _statusNotifications.Observe(status))
|
||||
{
|
||||
if (notifications)
|
||||
ShowStatusBalloon(notification.Title, notification.Message, notification.Icon);
|
||||
}
|
||||
}
|
||||
|
||||
private bool NotificationsEnabled()
|
||||
{
|
||||
try { return _controller.Settings.Notifications; }
|
||||
catch (Exception) { return false; }
|
||||
}
|
||||
|
||||
private void ShowStatusBalloon(string title, string message, ToolTipIcon icon)
|
||||
{
|
||||
if (_disposed) return;
|
||||
try { _tray.ShowBalloonTip(6000, UiTheme.Clip(title, 60), message, icon); }
|
||||
catch (Exception balloonException) { _ = balloonException; }
|
||||
}
|
||||
|
||||
private void RefreshTrayPresentation()
|
||||
{
|
||||
if (_disposed) return;
|
||||
try
|
||||
{
|
||||
_tray.Icon = _icons.Get(_status);
|
||||
var path = _status.ActivePath != NetworkMode.Initializing ? _status.ActivePath : _status.Mode;
|
||||
var route = path switch
|
||||
{
|
||||
NetworkMode.Proxy => "旁路由",
|
||||
NetworkMode.Direct => "主路由",
|
||||
NetworkMode.ExternalConfiguration => "外部网络",
|
||||
NetworkMode.Switching => "切换中",
|
||||
NetworkMode.Disconnected => "未连接",
|
||||
_ => "等待接管"
|
||||
};
|
||||
var text = $"网关保活 · {route} · {UiTheme.PolicyName(_status.Policy)}";
|
||||
if (!string.IsNullOrWhiteSpace(_status.Network?.Gateway)) text += " · " + _status.Network.Gateway;
|
||||
_tray.Text = UiTheme.Clip(text, 60);
|
||||
}
|
||||
catch (Exception exception)
|
||||
{
|
||||
_tray.Text = "网关保活";
|
||||
_ = exception;
|
||||
}
|
||||
}
|
||||
|
||||
private void OnClockTick(object? sender, EventArgs e)
|
||||
{
|
||||
if (_disposed) return;
|
||||
_panel.RefreshClock();
|
||||
}
|
||||
|
||||
private void OnMenuOpening(object? sender, System.ComponentModel.CancelEventArgs e)
|
||||
{
|
||||
if (_disposed)
|
||||
{
|
||||
e.Cancel = true;
|
||||
return;
|
||||
}
|
||||
var actionsEnabled = !_busy && !_exitInProgress;
|
||||
_autoPolicyItem.Enabled = actionsEnabled;
|
||||
_manualMenu.Enabled = actionsEnabled;
|
||||
_refreshItem.Enabled = actionsEnabled;
|
||||
_restoreItem.Enabled = actionsEnabled;
|
||||
_autostartItem.Enabled = actionsEnabled;
|
||||
_logsItem.Enabled = !_exitInProgress;
|
||||
_exitItem.Enabled = !_exitInProgress;
|
||||
_autoPolicyItem.Checked = _status.Policy == ControlPolicy.Auto;
|
||||
_manualDirectItem.Checked = _status.Policy == ControlPolicy.ForceDirect;
|
||||
_manualProxyItem.Checked = _status.Policy == ControlPolicy.ForceProxy;
|
||||
|
||||
try
|
||||
{
|
||||
_autostartItem.Checked = _controller.AutostartEnabled;
|
||||
_autostartItem.ToolTipText = _autostartItem.Checked ? "当前已设置登录后启动" : "当前未设置登录后启动";
|
||||
}
|
||||
catch (Exception exception)
|
||||
{
|
||||
_autostartItem.Checked = false;
|
||||
_autostartItem.ToolTipText = UiTheme.ExceptionText(exception);
|
||||
}
|
||||
}
|
||||
|
||||
private void OnPanelRefresh(object? sender, EventArgs e) => StartAction("检测失败", _controller.RefreshAsync);
|
||||
|
||||
private void OnPanelPolicy(ControlPolicy policy) =>
|
||||
StartAction("切换模式失败", () => _controller.SetPolicyAsync(policy));
|
||||
|
||||
private void OnPanelSettings(object? sender, EventArgs e) => _ = OpenSettingsAsync();
|
||||
|
||||
private void OnPanelLogs(object? sender, EventArgs e) => OpenLogs();
|
||||
|
||||
private void RequestRestore()
|
||||
{
|
||||
if (_busy || _exitInProgress) return;
|
||||
var answer = ShowOwnedMessageBox(
|
||||
"恢复接管前的网关、DNS 和路由配置?\n\n恢复成功后会自动暂停保活,避免立刻再次切换。",
|
||||
"恢复网络配置", MessageBoxButtons.YesNo, MessageBoxIcon.Question, MessageBoxDefaultButton.Button2);
|
||||
if (answer == DialogResult.Yes)
|
||||
StartAction("恢复网络失败", _controller.RestoreAsync);
|
||||
}
|
||||
|
||||
private void StartAction(string errorTitle, Func<Task> action)
|
||||
{
|
||||
_ = RunActionAsync(errorTitle, action);
|
||||
}
|
||||
|
||||
private async Task RunActionAsync(string errorTitle, Func<Task> action)
|
||||
{
|
||||
if (_disposed || _exitInProgress) return;
|
||||
if (_busy)
|
||||
{
|
||||
_panel.ShowNotice("上一项操作尚未完成,请稍候。", false);
|
||||
return;
|
||||
}
|
||||
|
||||
_busy = true;
|
||||
_panel.SetBusy(true);
|
||||
try
|
||||
{
|
||||
await action();
|
||||
if (!_disposed && !_exitInProgress) ApplyStatus(ReadStatus(_status));
|
||||
}
|
||||
catch (Exception exception)
|
||||
{
|
||||
if (!_disposed)
|
||||
{
|
||||
var message = UiTheme.ExceptionText(exception);
|
||||
ApplyStatus(ReadStatus(_status));
|
||||
ReportError(errorTitle, message);
|
||||
}
|
||||
}
|
||||
finally
|
||||
{
|
||||
if (!_disposed)
|
||||
{
|
||||
_busy = false;
|
||||
_panel.SetBusy(false);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private async Task OpenSettingsAsync()
|
||||
{
|
||||
if (_disposed || _exitInProgress) return;
|
||||
if (_busy)
|
||||
{
|
||||
_panel.ShowNotice("上一项操作尚未完成,请稍候再打开设置。", false);
|
||||
return;
|
||||
}
|
||||
|
||||
_busy = true;
|
||||
_panel.SetBusy(true);
|
||||
var previousAutoHide = _panel.AllowAutoHide;
|
||||
_panel.AllowAutoHide = false;
|
||||
try
|
||||
{
|
||||
IReadOnlyList<AdapterInfo> adapters;
|
||||
AppSettings settings;
|
||||
try
|
||||
{
|
||||
adapters = await _controller.ListAdaptersAsync();
|
||||
settings = _controller.Settings;
|
||||
}
|
||||
catch (Exception exception)
|
||||
{
|
||||
ReportError("无法打开设置", UiTheme.ExceptionText(exception));
|
||||
return;
|
||||
}
|
||||
|
||||
using var form = new SettingsForm(_controller, settings, adapters);
|
||||
_settingsForm = form;
|
||||
var owner = _panel.Visible ? _panel : null;
|
||||
form.ShowDialog(owner);
|
||||
ApplyStatus(ReadStatus(_status));
|
||||
}
|
||||
catch (Exception exception)
|
||||
{
|
||||
ReportError("设置窗口失败", UiTheme.ExceptionText(exception));
|
||||
}
|
||||
finally
|
||||
{
|
||||
_settingsForm = null;
|
||||
_panel.AllowAutoHide = previousAutoHide;
|
||||
if (!_disposed)
|
||||
{
|
||||
_busy = false;
|
||||
_panel.SetBusy(false);
|
||||
if (_panel.Visible) _panel.Activate();
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private void ToggleAutostart()
|
||||
{
|
||||
if (_busy || _exitInProgress) return;
|
||||
bool previous;
|
||||
try
|
||||
{
|
||||
previous = _controller.AutostartEnabled;
|
||||
}
|
||||
catch (Exception exception)
|
||||
{
|
||||
ReportError("开机自启设置失败", UiTheme.ExceptionText(exception));
|
||||
return;
|
||||
}
|
||||
|
||||
var wanted = !previous;
|
||||
try
|
||||
{
|
||||
_controller.SetAutostart(wanted);
|
||||
var actual = _controller.AutostartEnabled;
|
||||
if (actual != wanted)
|
||||
throw new InvalidOperationException("设置调用返回后,系统状态没有变成预期值。");
|
||||
_autostartItem.Checked = actual;
|
||||
_autostartItem.ToolTipText = actual ? "当前已设置登录后启动" : "当前未设置登录后启动";
|
||||
}
|
||||
catch (Exception exception)
|
||||
{
|
||||
var rollback = string.Empty;
|
||||
try
|
||||
{
|
||||
if (_controller.AutostartEnabled != previous) _controller.SetAutostart(previous);
|
||||
if (_controller.AutostartEnabled != previous)
|
||||
rollback = " 回滚后系统状态仍未恢复到原设置,请检查任务计划程序。";
|
||||
else
|
||||
rollback = " 已回滚到原设置。";
|
||||
}
|
||||
catch (Exception rollbackException)
|
||||
{
|
||||
rollback = " 回滚失败:" + UiTheme.ExceptionText(rollbackException);
|
||||
}
|
||||
|
||||
try
|
||||
{
|
||||
_autostartItem.Checked = _controller.AutostartEnabled;
|
||||
}
|
||||
catch
|
||||
{
|
||||
_autostartItem.Checked = previous;
|
||||
}
|
||||
ReportError("开机自启设置失败", UiTheme.ExceptionText(exception) + rollback);
|
||||
}
|
||||
}
|
||||
|
||||
private void OpenLogs()
|
||||
{
|
||||
if (_disposed) return;
|
||||
try
|
||||
{
|
||||
var dataDirectory = _controller.DataDirectory;
|
||||
if (string.IsNullOrWhiteSpace(dataDirectory)) throw new InvalidOperationException("日志目录尚未配置。");
|
||||
var directory = Path.Combine(dataDirectory, "logs");
|
||||
Directory.CreateDirectory(directory);
|
||||
Process.Start(new ProcessStartInfo { FileName = directory, UseShellExecute = true });
|
||||
}
|
||||
catch (Exception exception)
|
||||
{
|
||||
ReportError("无法打开日志", UiTheme.ExceptionText(exception));
|
||||
}
|
||||
}
|
||||
|
||||
private async Task ExitAsync()
|
||||
{
|
||||
if (_disposed || _exitInProgress) return;
|
||||
if (_settingsForm is not null)
|
||||
{
|
||||
ReportError("暂时无法退出", "请先关闭设置窗口,再退出程序。");
|
||||
return;
|
||||
}
|
||||
if (_busy)
|
||||
{
|
||||
ReportError("暂时无法退出", "当前网络操作尚未完成。请稍候再退出,程序不会在操作中途强制结束。");
|
||||
return;
|
||||
}
|
||||
|
||||
_exitInProgress = true;
|
||||
_busy = true;
|
||||
_panel.SetBusy(true);
|
||||
try
|
||||
{
|
||||
await _controller.StopAsync();
|
||||
if (_disposed) return;
|
||||
_panel.HidePanel();
|
||||
Dispose();
|
||||
ExitThread();
|
||||
}
|
||||
catch (Exception exception)
|
||||
{
|
||||
if (_disposed) return;
|
||||
_exitInProgress = false;
|
||||
_busy = false;
|
||||
_panel.SetBusy(false);
|
||||
var message = "停止保活失败,程序仍在运行,没有强制退出。\n\n" + UiTheme.ExceptionText(exception);
|
||||
_panel.ShowNotice(message, true);
|
||||
if (!_panel.Visible) _panel.ShowNearTray();
|
||||
ShowOwnedMessageBox(message, "退出未完成", MessageBoxButtons.OK, MessageBoxIcon.Error);
|
||||
}
|
||||
}
|
||||
|
||||
private AppStatus ReadStatus(AppStatus fallback)
|
||||
{
|
||||
try
|
||||
{
|
||||
return _controller.Status;
|
||||
}
|
||||
catch (Exception exception)
|
||||
{
|
||||
return fallback with { Message = UiTheme.ExceptionText(exception) };
|
||||
}
|
||||
}
|
||||
|
||||
private void ReportError(string title, string message)
|
||||
{
|
||||
if (_disposed) return;
|
||||
_panel.ShowNotice(message, true);
|
||||
if (_panel.Visible)
|
||||
{
|
||||
_panel.Activate();
|
||||
return;
|
||||
}
|
||||
try { _tray.ShowBalloonTip(6000, title, message, ToolTipIcon.Error); }
|
||||
catch (Exception balloonException) { _ = balloonException; }
|
||||
}
|
||||
|
||||
private DialogResult ShowOwnedMessageBox(string text, string title, MessageBoxButtons buttons,
|
||||
MessageBoxIcon icon, MessageBoxDefaultButton defaultButton = MessageBoxDefaultButton.Button1)
|
||||
{
|
||||
var previous = _panel.AllowAutoHide;
|
||||
_panel.AllowAutoHide = false;
|
||||
try
|
||||
{
|
||||
var owner = _panel.Visible ? _panel : null;
|
||||
return MessageBox.Show(owner, text, title, buttons, icon, defaultButton);
|
||||
}
|
||||
finally
|
||||
{
|
||||
_panel.AllowAutoHide = previous;
|
||||
if (_panel.Visible && !_disposed) _panel.Activate();
|
||||
}
|
||||
}
|
||||
|
||||
private void Post(Action action)
|
||||
{
|
||||
if (_disposed) return;
|
||||
if (Environment.CurrentManagedThreadId == _uiThreadId)
|
||||
{
|
||||
action();
|
||||
return;
|
||||
}
|
||||
|
||||
void SafeInvoke()
|
||||
{
|
||||
if (!_disposed) action();
|
||||
}
|
||||
|
||||
try
|
||||
{
|
||||
if (_dispatcher.IsHandleCreated)
|
||||
_dispatcher.BeginInvoke((Action)SafeInvoke);
|
||||
else if (_initialContext is not null)
|
||||
_initialContext.Post(_ => SafeInvoke(), null);
|
||||
else
|
||||
throw new InvalidOperationException("界面消息泵尚未初始化。");
|
||||
}
|
||||
catch (ObjectDisposedException) { }
|
||||
catch (InvalidOperationException)
|
||||
{
|
||||
try { _initialContext?.Post(_ => SafeInvoke(), null); }
|
||||
catch (InvalidOperationException) { }
|
||||
}
|
||||
}
|
||||
|
||||
private void TryEnablePerMonitorV2()
|
||||
{
|
||||
try { Application.SetHighDpiMode(HighDpiMode.PerMonitorV2); }
|
||||
catch (InvalidOperationException) { }
|
||||
}
|
||||
|
||||
private void Cleanup()
|
||||
{
|
||||
if (_disposed) return;
|
||||
_disposed = true;
|
||||
|
||||
try { _controller.StatusChanged -= OnStatusChanged; }
|
||||
catch (Exception cleanupException) { _ = cleanupException; }
|
||||
|
||||
_clockTimer.Stop();
|
||||
_clockTimer.Tick -= OnClockTick;
|
||||
_clockTimer.Dispose();
|
||||
|
||||
_panel.RefreshRequested -= OnPanelRefresh;
|
||||
_panel.SettingsRequested -= OnPanelSettings;
|
||||
_panel.LogsRequested -= OnPanelLogs;
|
||||
_panel.PolicyRequested -= OnPanelPolicy;
|
||||
_panel.Dispose();
|
||||
|
||||
_tray.Visible = false;
|
||||
_tray.MouseClick -= OnTrayMouseClick;
|
||||
_tray.ContextMenuStrip = null;
|
||||
_tray.Dispose();
|
||||
_menu.Opening -= OnMenuOpening;
|
||||
_menu.Dispose();
|
||||
_icons.Dispose();
|
||||
_dispatcher.Dispose();
|
||||
}
|
||||
|
||||
protected override void Dispose(bool disposing)
|
||||
{
|
||||
if (disposing) Cleanup();
|
||||
base.Dispose(disposing);
|
||||
}
|
||||
|
||||
public static void RenderPreview(string directory)
|
||||
{
|
||||
if (string.IsNullOrWhiteSpace(directory))
|
||||
throw new ArgumentException("必须提供预览图片输出目录。", nameof(directory));
|
||||
Directory.CreateDirectory(directory);
|
||||
|
||||
Exception? failure = null;
|
||||
var render = () =>
|
||||
{
|
||||
try
|
||||
{
|
||||
try { Application.SetHighDpiMode(HighDpiMode.PerMonitorV2); }
|
||||
catch (InvalidOperationException) { }
|
||||
try { Application.EnableVisualStyles(); }
|
||||
catch (Exception e) when (e is UnauthorizedAccessException or IOException or InvalidOperationException or TypeInitializationException) { }
|
||||
|
||||
var states = new[]
|
||||
{
|
||||
("gateway-panel-healthy-proxy.png", CreateHealthyProxyPreview()),
|
||||
("gateway-panel-fault-direct.png", CreateFaultDirectPreview()),
|
||||
("gateway-panel-paused-initial.png", CreatePausedInitialPreview())
|
||||
};
|
||||
foreach (var (fileName, state) in states)
|
||||
{
|
||||
using var panel = new StatusPanelForm();
|
||||
panel.ApplyStatus(state);
|
||||
RenderForm(panel, Path.Combine(directory, fileName));
|
||||
}
|
||||
|
||||
using (var expandedPanel = new StatusPanelForm())
|
||||
{
|
||||
expandedPanel.ApplyStatus(CreateHealthyProxyPreview());
|
||||
expandedPanel.SetDiagnosticsExpanded(true);
|
||||
RenderForm(expandedPanel, Path.Combine(directory, "gateway-panel-diagnostics-expanded.png"));
|
||||
}
|
||||
|
||||
var adapter = PreviewAdapter();
|
||||
var settings = PreviewSettings(adapter);
|
||||
using var settingsForm = new SettingsForm(new PreviewController(settings, adapter), settings, [adapter]);
|
||||
RenderForm(settingsForm, Path.Combine(directory, "gateway-settings-preview.png"));
|
||||
settingsForm.SelectPageForPreview(1);
|
||||
RenderForm(settingsForm, Path.Combine(directory, "gateway-settings-rules.png"));
|
||||
settingsForm.SelectPageForPreview(2);
|
||||
RenderForm(settingsForm, Path.Combine(directory, "gateway-settings-detection.png"));
|
||||
settingsForm.ScrollPageForPreview(2, 10000);
|
||||
RenderForm(settingsForm, Path.Combine(directory, "gateway-settings-detection-scrolled.png"));
|
||||
|
||||
File.Copy(Path.Combine(directory, "gateway-panel-healthy-proxy.png"),
|
||||
Path.Combine(directory, "gateway-panel-preview.png"), overwrite: true);
|
||||
File.Copy(Path.Combine(directory, "gateway-settings-preview.png"),
|
||||
Path.Combine(directory, "settings-preview.png"), overwrite: true);
|
||||
}
|
||||
catch (Exception exception)
|
||||
{
|
||||
failure = exception;
|
||||
}
|
||||
};
|
||||
|
||||
if (Thread.CurrentThread.GetApartmentState() == ApartmentState.STA)
|
||||
{
|
||||
render();
|
||||
}
|
||||
else
|
||||
{
|
||||
var thread = new Thread(() => render()) { IsBackground = true, Name = "GatewayKeepalive.Preview" };
|
||||
thread.SetApartmentState(ApartmentState.STA);
|
||||
thread.Start();
|
||||
thread.Join();
|
||||
}
|
||||
|
||||
if (failure is not null) ExceptionDispatchInfo.Capture(failure).Throw();
|
||||
}
|
||||
|
||||
private static void RenderForm(Form form, string path)
|
||||
{
|
||||
form.StartPosition = FormStartPosition.Manual;
|
||||
form.Location = new Point(-32000, -32000);
|
||||
form.Show();
|
||||
Application.DoEvents();
|
||||
form.PerformLayout();
|
||||
using var bitmap = new Bitmap(form.Width, form.Height, PixelFormat.Format32bppArgb);
|
||||
form.DrawToBitmap(bitmap, new Rectangle(Point.Empty, form.Size));
|
||||
form.Hide();
|
||||
bitmap.Save(path, ImageFormat.Png);
|
||||
}
|
||||
|
||||
private static AppStatus CreateHealthyProxyPreview()
|
||||
{
|
||||
var now = DateTimeOffset.Now;
|
||||
var adapter = PreviewAdapter();
|
||||
var routes = new[]
|
||||
{
|
||||
new RouteEntry("192.168.6.2", 1, 12, 12),
|
||||
new RouteEntry("192.168.6.1", 101, 12, 12)
|
||||
};
|
||||
var dns = new DnsConfiguration(false, ["192.168.6.2"], []);
|
||||
var network = new NetworkSnapshot(adapter, routes, dns, ["192.168.6.2"], now);
|
||||
var checks = new[]
|
||||
{
|
||||
new ProbeResult("旁路由连接", true, 2, "192.168.6.2:1070 TCP 可达"),
|
||||
new ProbeResult("旁路由 DNS", true, 12, "指定 DNS 查询成功"),
|
||||
new ProbeResult("SOCKS5", true, 45, "握手与 CONNECT 通过"),
|
||||
new ProbeResult("当前出口", true, 220, "TLS 验证通过")
|
||||
};
|
||||
var health = new HealthReport(checks, true, true, now);
|
||||
return new AppStatus(NetworkMode.Proxy, ControlPolicy.Auto, network, health, 3, 0,
|
||||
"检测正常", now.AddMinutes(-2))
|
||||
{
|
||||
ActivePath = NetworkMode.Proxy,
|
||||
LastEvent = "旁路由已恢复,自动切回",
|
||||
LastEventAt = now.AddMinutes(-2)
|
||||
};
|
||||
}
|
||||
|
||||
private static AppStatus CreateFaultDirectPreview()
|
||||
{
|
||||
var now = DateTimeOffset.Now;
|
||||
var adapter = PreviewAdapter();
|
||||
var network = new NetworkSnapshot(adapter,
|
||||
[new RouteEntry("192.168.6.1", 1, 12, 12)],
|
||||
new DnsConfiguration(true, [], []), ["192.168.6.1"], now);
|
||||
var checks = new[]
|
||||
{
|
||||
new ProbeResult("旁路由连接", false, 2500, "192.168.6.2 无法建立探测连接"),
|
||||
new ProbeResult("旁路由 DNS", false, 1200, "未收到有效的 DNS 响应"),
|
||||
new ProbeResult("SOCKS5", false, 2500, "192.168.6.2:1070 连接超时"),
|
||||
new ProbeResult("当前出口", true, 180, "TLS 验证通过 · 当前走主路由")
|
||||
};
|
||||
var health = new HealthReport(checks, false, true, now);
|
||||
return new AppStatus(NetworkMode.Degraded, ControlPolicy.Auto, network, health, 0, 3,
|
||||
"旁路由候选不可用", now.AddSeconds(-45))
|
||||
{
|
||||
ActivePath = NetworkMode.Direct,
|
||||
LastEvent = "旁路由连续失败,已切回主路由",
|
||||
LastEventAt = now.AddSeconds(-45)
|
||||
};
|
||||
}
|
||||
|
||||
private static AppStatus CreatePausedInitialPreview() => new(
|
||||
NetworkMode.Initializing, ControlPolicy.Paused, null, null, 0, 0,
|
||||
"请选择网卡并保存配置")
|
||||
{
|
||||
ActivePath = NetworkMode.Initializing,
|
||||
LastEvent = null,
|
||||
LastEventAt = null
|
||||
};
|
||||
|
||||
private static AdapterInfo PreviewAdapter() => new(
|
||||
Guid.Parse("4c0d9b61-6f43-4f6f-8d07-d8c2867d7f10"),
|
||||
12, "Ethernet", true, [new Ipv4Address("192.168.6.100", 24)], 25, "preview-network");
|
||||
|
||||
private static AppSettings PreviewSettings(AdapterInfo adapter) => new()
|
||||
{
|
||||
Configured = true,
|
||||
AdapterId = adapter.Id,
|
||||
DirectGateway = "192.168.6.1",
|
||||
ProxyGateway = "192.168.6.2",
|
||||
ProxyDns = "192.168.6.2",
|
||||
PreserveOriginalDirectDns = true,
|
||||
TestHosts = ["www.google.com", "cloudflare.com"],
|
||||
RequiredHealthyTargets = 1,
|
||||
DirectTestHosts = ["www.baidu.com", "www.microsoft.com"],
|
||||
RequiredDirectHealthyTargets = 1,
|
||||
Policy = ControlPolicy.Auto
|
||||
};
|
||||
|
||||
private sealed class PreviewController(AppSettings settings, AdapterInfo adapter) : ITrayController
|
||||
{
|
||||
public AppSettings Settings => settings;
|
||||
public AppStatus Status => CreatePausedInitialPreview();
|
||||
public string DataDirectory => Path.GetTempPath();
|
||||
public bool AutostartEnabled => false;
|
||||
public event Action<AppStatus>? StatusChanged { add { } remove { } }
|
||||
public Task<IReadOnlyList<AdapterInfo>> ListAdaptersAsync() => Task.FromResult<IReadOnlyList<AdapterInfo>>([adapter]);
|
||||
public Task RefreshAsync() => Task.CompletedTask;
|
||||
public Task SetPolicyAsync(ControlPolicy policy) => Task.CompletedTask;
|
||||
public Task SaveSettingsAsync(AppSettings value) => Task.CompletedTask;
|
||||
public Task RestoreAsync() => Task.CompletedTask;
|
||||
public Task StopAsync() => Task.CompletedTask;
|
||||
public void SetAutostart(bool enabled) { }
|
||||
}
|
||||
|
||||
private sealed class DarkMenuColors : ProfessionalColorTable
|
||||
{
|
||||
public override Color ToolStripDropDownBackground => UiTheme.Card;
|
||||
public override Color ImageMarginGradientBegin => UiTheme.Card;
|
||||
public override Color ImageMarginGradientMiddle => UiTheme.Card;
|
||||
public override Color ImageMarginGradientEnd => UiTheme.Card;
|
||||
public override Color MenuBorder => UiTheme.Border;
|
||||
public override Color MenuItemBorder => UiTheme.Accent;
|
||||
public override Color MenuItemSelected => UiTheme.CardHover;
|
||||
public override Color MenuItemSelectedGradientBegin => UiTheme.CardHover;
|
||||
public override Color MenuItemSelectedGradientEnd => UiTheme.CardHover;
|
||||
public override Color SeparatorDark => UiTheme.Border;
|
||||
public override Color SeparatorLight => UiTheme.BorderSoft;
|
||||
public override Color CheckBackground => UiTheme.CardHover;
|
||||
public override Color CheckSelectedBackground => UiTheme.CardPressed;
|
||||
public override Color CheckPressedBackground => UiTheme.CardPressed;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,945 @@
|
||||
using System.ComponentModel;
|
||||
using System.Drawing;
|
||||
using System.Drawing.Drawing2D;
|
||||
using System.Runtime.InteropServices;
|
||||
using System.Windows.Forms;
|
||||
|
||||
namespace GatewayKeepalive.Tray.Ui;
|
||||
|
||||
internal sealed class PanelChip : Control
|
||||
{
|
||||
private bool _hovered;
|
||||
private bool _pressed;
|
||||
private Color _accent = UiTheme.Accent;
|
||||
|
||||
public PanelChip()
|
||||
{
|
||||
SetStyle(ControlStyles.UserPaint | ControlStyles.AllPaintingInWmPaint |
|
||||
ControlStyles.OptimizedDoubleBuffer | ControlStyles.ResizeRedraw |
|
||||
ControlStyles.Selectable, true);
|
||||
TabStop = true;
|
||||
Cursor = Cursors.Hand;
|
||||
AccessibleRole = AccessibleRole.PushButton;
|
||||
Font = UiTheme.Body;
|
||||
}
|
||||
|
||||
[Browsable(false)]
|
||||
[DesignerSerializationVisibility(DesignerSerializationVisibility.Hidden)]
|
||||
public bool Selected { get; set; }
|
||||
|
||||
[Browsable(false)]
|
||||
[DesignerSerializationVisibility(DesignerSerializationVisibility.Hidden)]
|
||||
public bool Emphasized { get; set; }
|
||||
|
||||
[Browsable(false)]
|
||||
[DesignerSerializationVisibility(DesignerSerializationVisibility.Hidden)]
|
||||
public bool Ghost { get; set; }
|
||||
|
||||
[Browsable(false)]
|
||||
[DesignerSerializationVisibility(DesignerSerializationVisibility.Hidden)]
|
||||
public int CornerRadiusDip { get; set; } = 8;
|
||||
|
||||
[Browsable(false)]
|
||||
[DesignerSerializationVisibility(DesignerSerializationVisibility.Hidden)]
|
||||
public Color AccentColor
|
||||
{
|
||||
get => _accent;
|
||||
set { _accent = value; Invalidate(); }
|
||||
}
|
||||
|
||||
public void PerformClick()
|
||||
{
|
||||
if (!Enabled) return;
|
||||
Focus();
|
||||
OnClick(EventArgs.Empty);
|
||||
}
|
||||
|
||||
protected override void OnPaint(PaintEventArgs e)
|
||||
{
|
||||
base.OnPaint(e);
|
||||
var graphics = e.Graphics;
|
||||
UiTheme.Configure(graphics);
|
||||
|
||||
var bounds = new Rectangle(0, 0, Math.Max(1, Width - 1), Math.Max(1, Height - 1));
|
||||
var radius = UiTheme.S(this, CornerRadiusDip);
|
||||
var fill = Ghost && !_hovered && !_pressed && !Selected && !Emphasized
|
||||
? UiTheme.Background
|
||||
: Emphasized
|
||||
? UiTheme.WithAlpha(AccentColor, 54)
|
||||
: Selected
|
||||
? UiTheme.WithAlpha(AccentColor, 38)
|
||||
: _pressed ? UiTheme.CardPressed : _hovered ? UiTheme.CardHover : UiTheme.Card;
|
||||
var border = Ghost && !_hovered && !Selected && !Emphasized
|
||||
? UiTheme.Background
|
||||
: Selected || Emphasized
|
||||
? UiTheme.WithAlpha(AccentColor, 165)
|
||||
: _hovered ? UiTheme.Border : UiTheme.BorderSoft;
|
||||
|
||||
UiTheme.FillRounded(graphics, bounds, radius, fill);
|
||||
UiTheme.StrokeRounded(graphics, bounds, radius, border, UiTheme.SF(this, 1f));
|
||||
|
||||
var foreground = !Enabled ? UiTheme.Faint
|
||||
: Selected || Emphasized ? AccentColor
|
||||
: Ghost ? UiTheme.Muted : UiTheme.Text;
|
||||
UiTheme.DrawText(graphics, Text, Font ?? UiTheme.Body, foreground,
|
||||
Rectangle.Inflate(bounds, -UiTheme.S(this, 5), 0),
|
||||
TextFormatFlags.HorizontalCenter | TextFormatFlags.VerticalCenter |
|
||||
TextFormatFlags.SingleLine | TextFormatFlags.EndEllipsis);
|
||||
|
||||
if (Focused && ShowFocusCues)
|
||||
{
|
||||
var focus = Rectangle.Inflate(bounds, -UiTheme.S(this, 3), -UiTheme.S(this, 3));
|
||||
ControlPaint.DrawFocusRectangle(graphics, focus, foreground, Color.Transparent);
|
||||
}
|
||||
}
|
||||
|
||||
protected override void OnMouseEnter(EventArgs e)
|
||||
{
|
||||
_hovered = true;
|
||||
Invalidate();
|
||||
base.OnMouseEnter(e);
|
||||
}
|
||||
|
||||
protected override void OnMouseLeave(EventArgs e)
|
||||
{
|
||||
_hovered = false;
|
||||
if (_pressed)
|
||||
{
|
||||
_pressed = false;
|
||||
Capture = false;
|
||||
}
|
||||
Invalidate();
|
||||
base.OnMouseLeave(e);
|
||||
}
|
||||
|
||||
protected override void OnMouseDown(MouseEventArgs e)
|
||||
{
|
||||
base.OnMouseDown(e);
|
||||
if (e.Button != MouseButtons.Left || !Enabled) return;
|
||||
_pressed = true;
|
||||
Capture = true;
|
||||
Focus();
|
||||
Invalidate();
|
||||
}
|
||||
|
||||
protected override void OnMouseUp(MouseEventArgs e)
|
||||
{
|
||||
base.OnMouseUp(e);
|
||||
if (e.Button != MouseButtons.Left || !_pressed) return;
|
||||
_pressed = false;
|
||||
Capture = false;
|
||||
Invalidate();
|
||||
if (ClientRectangle.Contains(e.Location) && Enabled) OnClick(EventArgs.Empty);
|
||||
}
|
||||
|
||||
protected override bool IsInputKey(Keys keyData) =>
|
||||
keyData is Keys.Enter or Keys.Space || base.IsInputKey(keyData);
|
||||
|
||||
protected override void OnKeyDown(KeyEventArgs e)
|
||||
{
|
||||
if (e.KeyCode is Keys.Enter or Keys.Space)
|
||||
{
|
||||
PerformClick();
|
||||
e.Handled = true;
|
||||
return;
|
||||
}
|
||||
base.OnKeyDown(e);
|
||||
}
|
||||
|
||||
protected override void OnEnabledChanged(EventArgs e)
|
||||
{
|
||||
Cursor = Enabled ? Cursors.Hand : Cursors.Default;
|
||||
Invalidate();
|
||||
base.OnEnabledChanged(e);
|
||||
}
|
||||
}
|
||||
|
||||
internal sealed class ThemeButton : Button
|
||||
{
|
||||
private readonly bool _primary;
|
||||
|
||||
public ThemeButton(bool primary)
|
||||
{
|
||||
_primary = primary;
|
||||
AutoSize = true;
|
||||
AutoSizeMode = AutoSizeMode.GrowAndShrink;
|
||||
MinimumSize = new Size(82, 36);
|
||||
Padding = new Padding(14, 4, 14, 4);
|
||||
Margin = new Padding(6, 4, 0, 4);
|
||||
FlatStyle = FlatStyle.Flat;
|
||||
FlatAppearance.BorderSize = 1;
|
||||
FlatAppearance.BorderColor = primary ? UiTheme.Accent : UiTheme.Border;
|
||||
FlatAppearance.MouseOverBackColor = primary ? Color.FromArgb(0x5D, 0xE2, 0xBD) : UiTheme.CardHover;
|
||||
FlatAppearance.MouseDownBackColor = primary ? Color.FromArgb(0x3D, 0xBE, 0x9A) : UiTheme.CardPressed;
|
||||
BackColor = primary ? UiTheme.Accent : UiTheme.Card;
|
||||
ForeColor = primary ? UiTheme.Background : UiTheme.Text;
|
||||
Font = UiTheme.BodyBold;
|
||||
UseVisualStyleBackColor = false;
|
||||
UseMnemonic = false;
|
||||
TextAlign = ContentAlignment.MiddleCenter;
|
||||
Cursor = Cursors.Hand;
|
||||
}
|
||||
|
||||
public bool Primary => _primary;
|
||||
}
|
||||
|
||||
internal sealed class WrapTextLabel : Label
|
||||
{
|
||||
public WrapTextLabel()
|
||||
{
|
||||
SetStyle(ControlStyles.UserPaint | ControlStyles.AllPaintingInWmPaint |
|
||||
ControlStyles.OptimizedDoubleBuffer | ControlStyles.ResizeRedraw, true);
|
||||
AutoSize = false;
|
||||
UseMnemonic = false;
|
||||
AccessibleRole = AccessibleRole.StaticText;
|
||||
}
|
||||
|
||||
protected override void OnPaint(PaintEventArgs e)
|
||||
{
|
||||
e.Graphics.Clear(BackColor);
|
||||
UiTheme.Configure(e.Graphics);
|
||||
var bounds = ClientRectangle;
|
||||
bounds = new Rectangle(bounds.X + Padding.Left, bounds.Y + Padding.Top,
|
||||
Math.Max(0, bounds.Width - Padding.Horizontal),
|
||||
Math.Max(0, bounds.Height - Padding.Vertical));
|
||||
e.Graphics.TextRenderingHint = System.Drawing.Text.TextRenderingHint.ClearTypeGridFit;
|
||||
using var brush = new SolidBrush(ForeColor);
|
||||
using var format = new StringFormat(StringFormat.GenericTypographic)
|
||||
{
|
||||
FormatFlags = StringFormatFlags.LineLimit,
|
||||
Trimming = StringTrimming.EllipsisCharacter,
|
||||
HotkeyPrefix = System.Drawing.Text.HotkeyPrefix.None,
|
||||
Alignment = StringAlignment.Near,
|
||||
LineAlignment = StringAlignment.Near
|
||||
};
|
||||
e.Graphics.DrawString(Text, Font, brush,
|
||||
new RectangleF(bounds.X, bounds.Y, bounds.Width, bounds.Height), format);
|
||||
}
|
||||
|
||||
protected override void OnTextChanged(EventArgs e)
|
||||
{
|
||||
Invalidate();
|
||||
base.OnTextChanged(e);
|
||||
}
|
||||
}
|
||||
|
||||
internal sealed class ToggleSwitch : Control
|
||||
{
|
||||
private bool _checked;
|
||||
private bool _hovered;
|
||||
|
||||
public ToggleSwitch()
|
||||
{
|
||||
SetStyle(ControlStyles.UserPaint | ControlStyles.AllPaintingInWmPaint |
|
||||
ControlStyles.OptimizedDoubleBuffer | ControlStyles.ResizeRedraw |
|
||||
ControlStyles.Selectable, true);
|
||||
TabStop = true;
|
||||
Cursor = Cursors.Hand;
|
||||
AccessibleRole = AccessibleRole.CheckButton;
|
||||
Size = new Size(42, 22);
|
||||
}
|
||||
|
||||
[Browsable(false)]
|
||||
[DesignerSerializationVisibility(DesignerSerializationVisibility.Hidden)]
|
||||
public bool Checked
|
||||
{
|
||||
get => _checked;
|
||||
set
|
||||
{
|
||||
if (_checked == value) return;
|
||||
_checked = value;
|
||||
Invalidate();
|
||||
}
|
||||
}
|
||||
|
||||
public event EventHandler? UserToggled;
|
||||
|
||||
protected override void OnPaint(PaintEventArgs e)
|
||||
{
|
||||
base.OnPaint(e);
|
||||
UiTheme.Configure(e.Graphics);
|
||||
var scale = UiTheme.Scale(this);
|
||||
var track = new RectangleF(
|
||||
(Width - 38 * scale) / 2f,
|
||||
(Height - 20 * scale) / 2f,
|
||||
38 * scale,
|
||||
20 * scale);
|
||||
var radius = track.Height / 2f;
|
||||
var trackColor = !Enabled ? UiTheme.BorderSoft : _checked ? UiTheme.Accent : _hovered ? UiTheme.CardPressed : UiTheme.Border;
|
||||
using (var path = new GraphicsPath())
|
||||
{
|
||||
path.AddArc(track.Left, track.Top, track.Height, track.Height, 90, 180);
|
||||
path.AddArc(track.Right - track.Height, track.Top, track.Height, track.Height, 270, 180);
|
||||
path.CloseFigure();
|
||||
using var fill = new SolidBrush(trackColor);
|
||||
e.Graphics.FillPath(fill, path);
|
||||
}
|
||||
|
||||
var knobSize = track.Height - 4 * scale;
|
||||
var knobX = _checked ? track.Right - knobSize - 2 * scale : track.Left + 2 * scale;
|
||||
using var knob = new SolidBrush(!Enabled ? UiTheme.Paused : UiTheme.Text);
|
||||
e.Graphics.FillEllipse(knob, knobX, track.Top + 2 * scale, knobSize, knobSize);
|
||||
if (Focused && ShowFocusCues)
|
||||
ControlPaint.DrawFocusRectangle(e.Graphics, ClientRectangle, UiTheme.Accent, Color.Transparent);
|
||||
}
|
||||
|
||||
protected override void OnMouseEnter(EventArgs e)
|
||||
{
|
||||
_hovered = true;
|
||||
Invalidate();
|
||||
base.OnMouseEnter(e);
|
||||
}
|
||||
|
||||
protected override void OnMouseLeave(EventArgs e)
|
||||
{
|
||||
_hovered = false;
|
||||
Invalidate();
|
||||
base.OnMouseLeave(e);
|
||||
}
|
||||
|
||||
protected override void OnMouseUp(MouseEventArgs e)
|
||||
{
|
||||
base.OnMouseUp(e);
|
||||
if (e.Button != MouseButtons.Left || !ClientRectangle.Contains(e.Location)) return;
|
||||
Toggle();
|
||||
}
|
||||
|
||||
protected override bool IsInputKey(Keys keyData) =>
|
||||
keyData is Keys.Space or Keys.Enter || base.IsInputKey(keyData);
|
||||
|
||||
protected override void OnKeyDown(KeyEventArgs e)
|
||||
{
|
||||
if (e.KeyCode is Keys.Space or Keys.Enter)
|
||||
{
|
||||
Toggle();
|
||||
e.Handled = true;
|
||||
return;
|
||||
}
|
||||
base.OnKeyDown(e);
|
||||
}
|
||||
|
||||
private void Toggle()
|
||||
{
|
||||
if (!Enabled) return;
|
||||
Focus();
|
||||
Checked = !Checked;
|
||||
UserToggled?.Invoke(this, EventArgs.Empty);
|
||||
}
|
||||
}
|
||||
|
||||
internal sealed class DisclosureButton : Control
|
||||
{
|
||||
private bool _hovered;
|
||||
private bool _expanded;
|
||||
private string _summary = string.Empty;
|
||||
|
||||
public DisclosureButton()
|
||||
{
|
||||
SetStyle(ControlStyles.UserPaint | ControlStyles.AllPaintingInWmPaint |
|
||||
ControlStyles.OptimizedDoubleBuffer | ControlStyles.ResizeRedraw |
|
||||
ControlStyles.Selectable, true);
|
||||
TabStop = true;
|
||||
Cursor = Cursors.Hand;
|
||||
AccessibleRole = AccessibleRole.ButtonDropDown;
|
||||
Font = UiTheme.Small;
|
||||
}
|
||||
|
||||
[Browsable(false)]
|
||||
[DesignerSerializationVisibility(DesignerSerializationVisibility.Hidden)]
|
||||
public bool Expanded
|
||||
{
|
||||
get => _expanded;
|
||||
set { _expanded = value; Invalidate(); }
|
||||
}
|
||||
|
||||
[Browsable(false)]
|
||||
[DesignerSerializationVisibility(DesignerSerializationVisibility.Hidden)]
|
||||
public string Summary
|
||||
{
|
||||
get => _summary;
|
||||
set { _summary = value ?? string.Empty; Invalidate(); }
|
||||
}
|
||||
|
||||
public event EventHandler? Toggled;
|
||||
|
||||
protected override void OnPaint(PaintEventArgs e)
|
||||
{
|
||||
base.OnPaint(e);
|
||||
e.Graphics.Clear(UiTheme.Background);
|
||||
UiTheme.Configure(e.Graphics);
|
||||
if (_hovered)
|
||||
UiTheme.FillRounded(e.Graphics, new Rectangle(0, 0, Width, Height), UiTheme.S(this, 6), UiTheme.BackgroundRaised);
|
||||
|
||||
UiTheme.DrawText(e.Graphics, Text, UiTheme.SmallBold, Enabled ? UiTheme.Text : UiTheme.Paused,
|
||||
new Rectangle(UiTheme.S(this, 2), 0, Width / 2, Height),
|
||||
TextFormatFlags.Left | TextFormatFlags.VerticalCenter | TextFormatFlags.SingleLine);
|
||||
UiTheme.DrawText(e.Graphics, Summary, UiTheme.Small, Enabled ? UiTheme.Muted : UiTheme.Faint,
|
||||
new Rectangle(Width / 2, 0, Width / 2 - UiTheme.S(this, 19), Height),
|
||||
TextFormatFlags.Right | TextFormatFlags.VerticalCenter | TextFormatFlags.SingleLine | TextFormatFlags.EndEllipsis);
|
||||
|
||||
var centerY = Height / 2f;
|
||||
var x = Width - UiTheme.S(this, 12);
|
||||
var d = UiTheme.S(this, 4);
|
||||
using var pen = new Pen(UiTheme.Muted, UiTheme.SF(this, 1.3f))
|
||||
{
|
||||
StartCap = LineCap.Round,
|
||||
EndCap = LineCap.Round
|
||||
};
|
||||
if (_expanded)
|
||||
{
|
||||
e.Graphics.DrawLine(pen, x - d, centerY + d / 2f, x, centerY - d / 2f);
|
||||
e.Graphics.DrawLine(pen, x, centerY - d / 2f, x + d, centerY + d / 2f);
|
||||
}
|
||||
else
|
||||
{
|
||||
e.Graphics.DrawLine(pen, x - d / 2f, centerY - d, x + d / 2f, centerY);
|
||||
e.Graphics.DrawLine(pen, x + d / 2f, centerY, x - d / 2f, centerY + d);
|
||||
}
|
||||
|
||||
if (Focused && ShowFocusCues)
|
||||
ControlPaint.DrawFocusRectangle(e.Graphics, ClientRectangle, UiTheme.Text, Color.Transparent);
|
||||
}
|
||||
|
||||
protected override void OnMouseEnter(EventArgs e)
|
||||
{
|
||||
_hovered = true;
|
||||
Invalidate();
|
||||
base.OnMouseEnter(e);
|
||||
}
|
||||
|
||||
protected override void OnMouseLeave(EventArgs e)
|
||||
{
|
||||
_hovered = false;
|
||||
Invalidate();
|
||||
base.OnMouseLeave(e);
|
||||
}
|
||||
|
||||
protected override void OnMouseUp(MouseEventArgs e)
|
||||
{
|
||||
base.OnMouseUp(e);
|
||||
if (Enabled && e.Button == MouseButtons.Left && ClientRectangle.Contains(e.Location)) Toggle();
|
||||
}
|
||||
|
||||
protected override bool IsInputKey(Keys keyData) =>
|
||||
keyData is Keys.Space or Keys.Enter || base.IsInputKey(keyData);
|
||||
|
||||
protected override void OnKeyDown(KeyEventArgs e)
|
||||
{
|
||||
if (Enabled && (e.KeyCode is Keys.Space or Keys.Enter))
|
||||
{
|
||||
Toggle();
|
||||
e.Handled = true;
|
||||
return;
|
||||
}
|
||||
base.OnKeyDown(e);
|
||||
}
|
||||
|
||||
private void Toggle()
|
||||
{
|
||||
Focus();
|
||||
Toggled?.Invoke(this, EventArgs.Empty);
|
||||
}
|
||||
}
|
||||
|
||||
internal sealed class CardPanel : Panel
|
||||
{
|
||||
public CardPanel()
|
||||
{
|
||||
SetStyle(ControlStyles.UserPaint | ControlStyles.AllPaintingInWmPaint |
|
||||
ControlStyles.OptimizedDoubleBuffer | ControlStyles.ResizeRedraw, true);
|
||||
BackColor = UiTheme.Card;
|
||||
ForeColor = UiTheme.Text;
|
||||
Padding = new Padding(14, 12, 14, 14);
|
||||
Margin = new Padding(0, 0, 0, 12);
|
||||
}
|
||||
|
||||
protected override void OnPaint(PaintEventArgs e)
|
||||
{
|
||||
base.OnPaint(e);
|
||||
UiTheme.Configure(e.Graphics);
|
||||
var bounds = new Rectangle(0, 0, Math.Max(1, Width - 1), Math.Max(1, Height - 1));
|
||||
UiTheme.StrokeRounded(e.Graphics, bounds, UiTheme.S(this, 10), UiTheme.BorderSoft, UiTheme.SF(this, 1f));
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Dark drop-down selector. A WinForms ComboBox delegates its arrow and popup chrome to the
|
||||
/// system theme, which cannot be recolored without visual styles; this control draws the field
|
||||
/// itself and opens a dark ContextMenuStrip, so nothing light leaks into the UI.
|
||||
/// </summary>
|
||||
internal sealed class DarkSelect : Control
|
||||
{
|
||||
private readonly List<object> _items = [];
|
||||
private readonly ToolStripDropDownMenu _menu = new();
|
||||
private int _selectedIndex = -1;
|
||||
private bool _hovered;
|
||||
private bool _open;
|
||||
|
||||
public DarkSelect()
|
||||
{
|
||||
SetStyle(ControlStyles.UserPaint | ControlStyles.AllPaintingInWmPaint |
|
||||
ControlStyles.OptimizedDoubleBuffer | ControlStyles.ResizeRedraw |
|
||||
ControlStyles.Selectable, true);
|
||||
TabStop = true;
|
||||
Cursor = Cursors.Hand;
|
||||
AccessibleRole = AccessibleRole.ComboBox;
|
||||
Font = UiTheme.Body;
|
||||
Height = 28;
|
||||
_menu.ShowImageMargin = false;
|
||||
_menu.ShowCheckMargin = false;
|
||||
_menu.BackColor = UiTheme.Card;
|
||||
_menu.ForeColor = UiTheme.Text;
|
||||
_menu.Font = UiTheme.Body;
|
||||
_menu.Padding = new Padding(3);
|
||||
_menu.Renderer = new ToolStripProfessionalRenderer(new DarkSelectColors());
|
||||
_menu.Closed += (_, _) => { _open = false; Invalidate(); };
|
||||
}
|
||||
|
||||
[System.ComponentModel.Browsable(false)]
|
||||
[System.ComponentModel.DesignerSerializationVisibility(System.ComponentModel.DesignerSerializationVisibility.Hidden)]
|
||||
public List<object> Items => _items;
|
||||
|
||||
[System.ComponentModel.Browsable(false)]
|
||||
[System.ComponentModel.DesignerSerializationVisibility(System.ComponentModel.DesignerSerializationVisibility.Hidden)]
|
||||
public int SelectedIndex
|
||||
{
|
||||
get => _selectedIndex;
|
||||
set
|
||||
{
|
||||
var next = value >= -1 && value < _items.Count ? value : -1;
|
||||
if (next == _selectedIndex) return;
|
||||
_selectedIndex = next;
|
||||
Invalidate();
|
||||
SelectedIndexChanged?.Invoke(this, EventArgs.Empty);
|
||||
}
|
||||
}
|
||||
|
||||
[System.ComponentModel.Browsable(false)]
|
||||
[System.ComponentModel.DesignerSerializationVisibility(System.ComponentModel.DesignerSerializationVisibility.Hidden)]
|
||||
public object? SelectedItem => _selectedIndex >= 0 ? _items[_selectedIndex] : null;
|
||||
|
||||
public event EventHandler? SelectedIndexChanged;
|
||||
|
||||
public void BeginUpdate() { }
|
||||
public void EndUpdate() { }
|
||||
|
||||
private string DisplayText => SelectedItem?.ToString() ?? string.Empty;
|
||||
|
||||
protected override void OnPaint(PaintEventArgs e)
|
||||
{
|
||||
base.OnPaint(e);
|
||||
UiTheme.Configure(e.Graphics);
|
||||
var bounds = new Rectangle(0, 0, Math.Max(1, Width - 1), Math.Max(1, Height - 1));
|
||||
var radius = UiTheme.S(this, 6);
|
||||
UiTheme.FillRounded(e.Graphics, bounds, radius, _open ? UiTheme.Card : UiTheme.Input);
|
||||
UiTheme.StrokeRounded(e.Graphics, bounds, radius, _open || _hovered ? UiTheme.Border : UiTheme.BorderSoft, UiTheme.SF(this, 1f));
|
||||
|
||||
var textBounds = new Rectangle(UiTheme.S(this, 8), 0, Math.Max(0, Width - UiTheme.S(this, 30)), Height);
|
||||
UiTheme.DrawText(e.Graphics, DisplayText, Font ?? UiTheme.Body, SelectedItem is null ? UiTheme.Faint : UiTheme.Text,
|
||||
textBounds, TextFormatFlags.Left | TextFormatFlags.VerticalCenter | TextFormatFlags.SingleLine | TextFormatFlags.EndEllipsis | TextFormatFlags.NoPrefix);
|
||||
|
||||
var size = UiTheme.S(this, 4);
|
||||
var centerX = Width - UiTheme.S(this, 15);
|
||||
var centerY = Height / 2f;
|
||||
using var pen = new Pen(Enabled ? UiTheme.Muted : UiTheme.Faint, UiTheme.SF(this, 1.4f))
|
||||
{
|
||||
StartCap = LineCap.Round,
|
||||
EndCap = LineCap.Round
|
||||
};
|
||||
e.Graphics.DrawLine(pen, centerX - size, centerY - size / 2f, centerX, centerY + size / 2f);
|
||||
e.Graphics.DrawLine(pen, centerX, centerY + size / 2f, centerX + size, centerY - size / 2f);
|
||||
|
||||
if (Focused && ShowFocusCues)
|
||||
ControlPaint.DrawFocusRectangle(e.Graphics, Rectangle.Inflate(bounds, -UiTheme.S(this, 3), -UiTheme.S(this, 3)), UiTheme.Text, Color.Transparent);
|
||||
}
|
||||
|
||||
protected override void OnMouseEnter(EventArgs e) { _hovered = true; Invalidate(); base.OnMouseEnter(e); }
|
||||
protected override void OnMouseLeave(EventArgs e) { _hovered = false; Invalidate(); base.OnMouseLeave(e); }
|
||||
|
||||
protected override void OnMouseUp(MouseEventArgs e)
|
||||
{
|
||||
base.OnMouseUp(e);
|
||||
if (e.Button == MouseButtons.Left && Enabled && ClientRectangle.Contains(e.Location)) ShowMenu();
|
||||
}
|
||||
|
||||
protected override bool IsInputKey(Keys keyData) =>
|
||||
keyData is Keys.Enter or Keys.Space or Keys.Down or Keys.Up || base.IsInputKey(keyData);
|
||||
|
||||
protected override void OnKeyDown(KeyEventArgs e)
|
||||
{
|
||||
if (!Enabled) { base.OnKeyDown(e); return; }
|
||||
if (e.KeyCode is Keys.Enter or Keys.Space or Keys.Down) { ShowMenu(); e.Handled = true; return; }
|
||||
if (e.KeyCode == Keys.Up && _selectedIndex > 0) { SelectedIndex--; e.Handled = true; return; }
|
||||
base.OnKeyDown(e);
|
||||
}
|
||||
|
||||
private void ShowMenu()
|
||||
{
|
||||
Focus();
|
||||
_menu.Items.Clear();
|
||||
for (var i = 0; i < _items.Count; i++)
|
||||
{
|
||||
var index = i;
|
||||
var item = new ToolStripMenuItem(_items[i].ToString() ?? string.Empty)
|
||||
{
|
||||
Font = i == _selectedIndex ? UiTheme.BodyBold : UiTheme.Body,
|
||||
ForeColor = UiTheme.Text,
|
||||
BackColor = UiTheme.Card,
|
||||
Padding = new Padding(6, 3, 6, 3)
|
||||
};
|
||||
item.Click += (_, _) => SelectedIndex = index;
|
||||
_menu.Items.Add(item);
|
||||
}
|
||||
if (_items.Count == 0)
|
||||
_menu.Items.Add(new ToolStripMenuItem("没有可用选项") { Enabled = false, ForeColor = UiTheme.Faint, BackColor = UiTheme.Card });
|
||||
|
||||
_open = true;
|
||||
Invalidate();
|
||||
_menu.MinimumSize = new Size(Width, 0);
|
||||
_menu.Show(this, new Point(0, Height + UiTheme.S(this, 2)));
|
||||
}
|
||||
|
||||
protected override void Dispose(bool disposing)
|
||||
{
|
||||
if (disposing) _menu.Dispose();
|
||||
base.Dispose(disposing);
|
||||
}
|
||||
|
||||
private sealed class DarkSelectColors : ProfessionalColorTable
|
||||
{
|
||||
public override Color ToolStripDropDownBackground => UiTheme.Card;
|
||||
public override Color ImageMarginGradientBegin => UiTheme.Card;
|
||||
public override Color ImageMarginGradientMiddle => UiTheme.Card;
|
||||
public override Color ImageMarginGradientEnd => UiTheme.Card;
|
||||
public override Color MenuBorder => UiTheme.Border;
|
||||
public override Color MenuItemBorder => UiTheme.CardHover;
|
||||
public override Color MenuItemSelected => UiTheme.CardHover;
|
||||
public override Color MenuItemSelectedGradientBegin => UiTheme.CardHover;
|
||||
public override Color MenuItemSelectedGradientEnd => UiTheme.CardHover;
|
||||
public override Color SeparatorDark => UiTheme.Border;
|
||||
public override Color SeparatorLight => UiTheme.BorderSoft;
|
||||
}
|
||||
}
|
||||
|
||||
internal sealed class DarkComboBox : ComboBox
|
||||
|
||||
{
|
||||
public DarkComboBox()
|
||||
{
|
||||
DropDownStyle = ComboBoxStyle.DropDownList;
|
||||
DrawMode = DrawMode.OwnerDrawFixed;
|
||||
ItemHeight = 24;
|
||||
BackColor = UiTheme.Input;
|
||||
ForeColor = UiTheme.Text;
|
||||
FlatStyle = FlatStyle.Flat;
|
||||
IntegralHeight = false;
|
||||
DropDownHeight = 260;
|
||||
}
|
||||
|
||||
protected override void WndProc(ref Message m)
|
||||
{
|
||||
base.WndProc(ref m);
|
||||
if (m.Msg != 0x000F) return; // WM_PAINT: FlatStyle.Flat draws a light system arrow.
|
||||
using var graphics = Graphics.FromHwnd(Handle);
|
||||
UiTheme.Configure(graphics);
|
||||
var arrowBox = new Rectangle(Width - UiTheme.S(this, 26), 1, UiTheme.S(this, 25), Math.Max(1, Height - 2));
|
||||
using (var background = new SolidBrush(UiTheme.Input)) graphics.FillRectangle(background, arrowBox);
|
||||
var size = Math.Max(3, UiTheme.S(this, 4));
|
||||
var centerX = arrowBox.X + arrowBox.Width / 2f - UiTheme.S(this, 2);
|
||||
var centerY = Height / 2f;
|
||||
using var pen = new Pen(Enabled ? UiTheme.Muted : UiTheme.Faint, UiTheme.SF(this, 1.4f))
|
||||
{
|
||||
StartCap = LineCap.Round,
|
||||
EndCap = LineCap.Round
|
||||
};
|
||||
graphics.DrawLine(pen, centerX - size, centerY - size / 2f, centerX, centerY + size / 2f);
|
||||
graphics.DrawLine(pen, centerX, centerY + size / 2f, centerX + size, centerY - size / 2f);
|
||||
}
|
||||
|
||||
protected override void OnDrawItem(DrawItemEventArgs e)
|
||||
{
|
||||
if (e.Index < 0 || e.Index >= Items.Count)
|
||||
{
|
||||
e.Graphics.FillRectangle(new SolidBrush(BackColor), e.Bounds);
|
||||
return;
|
||||
}
|
||||
|
||||
var selected = (e.State & DrawItemState.Selected) != 0;
|
||||
using var background = new SolidBrush(selected ? UiTheme.CardHover : UiTheme.Input);
|
||||
e.Graphics.FillRectangle(background, e.Bounds);
|
||||
var text = GetItemText(Items[e.Index]);
|
||||
TextRenderer.DrawText(e.Graphics, text, Font,
|
||||
Rectangle.Inflate(e.Bounds, -5, 0), Enabled ? UiTheme.Text : UiTheme.Faint,
|
||||
TextFormatFlags.Left | TextFormatFlags.VerticalCenter | TextFormatFlags.SingleLine |
|
||||
TextFormatFlags.EndEllipsis | TextFormatFlags.NoPrefix);
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Deterministic scroll area for the dark UI: content offset is owned here (no native scrollbar),
|
||||
/// so the slim thumb always paints above cards and the right gutter is reserved, never overlapped.
|
||||
/// </summary>
|
||||
internal sealed class DarkScrollPanel : Panel
|
||||
{
|
||||
private readonly Panel _content = new() { BackColor = UiTheme.Background, AutoSize = false };
|
||||
private readonly DarkScrollBar _bar = new();
|
||||
private readonly List<Control> _stacks = [];
|
||||
private int _offset;
|
||||
|
||||
public DarkScrollPanel()
|
||||
{
|
||||
SetStyle(ControlStyles.UserPaint | ControlStyles.AllPaintingInWmPaint |
|
||||
ControlStyles.OptimizedDoubleBuffer | ControlStyles.ResizeRedraw, true);
|
||||
AutoScroll = false;
|
||||
BackColor = UiTheme.Background;
|
||||
Padding = new Padding(2, 8, 2, 10);
|
||||
Controls.Add(_content);
|
||||
Controls.Add(_bar);
|
||||
_bar.Scrolled += (_, value) => SetOffset(value);
|
||||
}
|
||||
|
||||
/// <summary>Right-side room reserved for the scrollbar; content never occupies it.</summary>
|
||||
public int GutterWidth => UiTheme.S(this, DarkScrollBar.GutterWidthDip);
|
||||
|
||||
public int ContentHeight => _content.Height;
|
||||
|
||||
public int ViewportHeight => Math.Max(0, ClientSize.Height - Padding.Vertical);
|
||||
|
||||
public void ScrollByPixels(int delta) => SetOffset(_offset + delta);
|
||||
|
||||
/// <summary>Page children are re-hosted into the offset content panel, preserving order.</summary>
|
||||
protected override void OnControlAdded(ControlEventArgs e)
|
||||
{
|
||||
base.OnControlAdded(e);
|
||||
if (e.Control is null || ReferenceEquals(e.Control, _content) || ReferenceEquals(e.Control, _bar)) return;
|
||||
var stack = e.Control;
|
||||
Controls.Remove(stack);
|
||||
_stacks.Add(stack);
|
||||
stack.Dock = DockStyle.None;
|
||||
// AutoSize would snap each stack back to its minimum width; height is measured per layout instead.
|
||||
stack.AutoSize = false;
|
||||
stack.Width = ContentWidth;
|
||||
_content.Controls.Add(stack);
|
||||
}
|
||||
|
||||
private int ContentWidth => Math.Max(0, ClientSize.Width - Padding.Horizontal - GutterWidth);
|
||||
|
||||
protected override void OnLayout(LayoutEventArgs levent)
|
||||
{
|
||||
base.OnLayout(levent);
|
||||
var width = ContentWidth;
|
||||
var y = 0;
|
||||
foreach (var stack in _stacks)
|
||||
{
|
||||
stack.Width = width;
|
||||
var preferred = stack.GetPreferredSize(new Size(width, 0));
|
||||
if (stack.Height != preferred.Height) stack.Height = preferred.Height;
|
||||
stack.Location = new Point(0, y);
|
||||
y += stack.Height + stack.Margin.Bottom;
|
||||
}
|
||||
_content.Bounds = new Rectangle(Padding.Left, Padding.Top - _offset, width, Math.Max(1, y));
|
||||
_bar.Bounds = new Rectangle(Math.Max(0, ClientSize.Width - GutterWidth + UiTheme.S(this, 4)), UiTheme.S(this, 4),
|
||||
UiTheme.S(this, DarkScrollBar.ThumbWidthDip), Math.Max(0, ClientSize.Height - UiTheme.S(this, 8)));
|
||||
_bar.BringToFront();
|
||||
ClampOffset();
|
||||
SyncBar();
|
||||
}
|
||||
|
||||
private void SyncBar()
|
||||
{
|
||||
_bar.Maximum = Math.Max(0, ContentHeight - ViewportHeight);
|
||||
_bar.Viewport = ViewportHeight;
|
||||
_bar.Total = Math.Max(ContentHeight, ViewportHeight);
|
||||
_bar.Value = _offset;
|
||||
}
|
||||
|
||||
private void SetOffset(int value)
|
||||
{
|
||||
_offset = Math.Clamp(value, 0, Math.Max(0, ContentHeight - ViewportHeight));
|
||||
_content.Top = Padding.Top - _offset;
|
||||
SyncBar();
|
||||
}
|
||||
|
||||
private void ClampOffset() => SetOffset(_offset);
|
||||
|
||||
protected override void OnMouseWheel(MouseEventArgs e)
|
||||
{
|
||||
ScrollByPixels(-e.Delta / 120 * UiTheme.S(this, 76));
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>Slim overlay scrollbar whose thumb only appears while the page actually overflows.</summary>
|
||||
internal sealed class DarkScrollBar : Control
|
||||
{
|
||||
public const int GutterWidthDip = 12;
|
||||
public const int ThumbWidthDip = 5;
|
||||
|
||||
private int _value;
|
||||
private bool _hovered;
|
||||
private bool _dragging;
|
||||
private int _dragStartY;
|
||||
private int _dragStartValue;
|
||||
|
||||
public DarkScrollBar()
|
||||
{
|
||||
SetStyle(ControlStyles.UserPaint | ControlStyles.AllPaintingInWmPaint |
|
||||
ControlStyles.OptimizedDoubleBuffer | ControlStyles.ResizeRedraw, true);
|
||||
BackColor = UiTheme.Background;
|
||||
Width = UiTheme.S(this, ThumbWidthDip);
|
||||
}
|
||||
|
||||
[System.ComponentModel.Browsable(false)]
|
||||
[System.ComponentModel.DesignerSerializationVisibility(System.ComponentModel.DesignerSerializationVisibility.Hidden)]
|
||||
public int Maximum { get; set; }
|
||||
[System.ComponentModel.Browsable(false)]
|
||||
[System.ComponentModel.DesignerSerializationVisibility(System.ComponentModel.DesignerSerializationVisibility.Hidden)]
|
||||
public int Viewport { get; set; }
|
||||
[System.ComponentModel.Browsable(false)]
|
||||
[System.ComponentModel.DesignerSerializationVisibility(System.ComponentModel.DesignerSerializationVisibility.Hidden)]
|
||||
public int Total { get; set; }
|
||||
public event EventHandler<int>? Scrolled;
|
||||
|
||||
[System.ComponentModel.Browsable(false)]
|
||||
[System.ComponentModel.DesignerSerializationVisibility(System.ComponentModel.DesignerSerializationVisibility.Hidden)]
|
||||
public int Value
|
||||
{
|
||||
get => _value;
|
||||
set
|
||||
{
|
||||
if (_value == value) return;
|
||||
_value = value;
|
||||
Invalidate();
|
||||
}
|
||||
}
|
||||
|
||||
private bool Scrollable => Maximum > 0;
|
||||
|
||||
private Rectangle ThumbBounds
|
||||
{
|
||||
get
|
||||
{
|
||||
var trackHeight = Height;
|
||||
var ratio = Math.Min(1d, Viewport / (double)Math.Max(1, Total));
|
||||
var thumbHeight = Math.Clamp((int)Math.Round(trackHeight * ratio), UiTheme.S(this, 26), Math.Max(UiTheme.S(this, 26), trackHeight));
|
||||
var travel = Math.Max(0, trackHeight - thumbHeight);
|
||||
var position = Maximum <= 0 ? 0d : Math.Clamp(Value / (double)Maximum, 0d, 1d);
|
||||
return new Rectangle(0, (int)Math.Round(travel * position), Width, thumbHeight);
|
||||
}
|
||||
}
|
||||
|
||||
protected override void OnPaint(PaintEventArgs e)
|
||||
{
|
||||
base.OnPaint(e);
|
||||
if (!Scrollable) return;
|
||||
UiTheme.Configure(e.Graphics);
|
||||
var color = _dragging ? UiTheme.Muted : _hovered ? UiTheme.Faint : UiTheme.Border;
|
||||
var thumb = ThumbBounds;
|
||||
UiTheme.FillRounded(e.Graphics, new Rectangle(thumb.X, thumb.Y, Math.Max(1, thumb.Width - 1), thumb.Height), Math.Max(1, thumb.Width / 2 - 1), color);
|
||||
}
|
||||
|
||||
protected override void OnMouseEnter(EventArgs e) { _hovered = true; Invalidate(); base.OnMouseEnter(e); }
|
||||
protected override void OnMouseLeave(EventArgs e) { _hovered = false; Invalidate(); base.OnMouseLeave(e); }
|
||||
|
||||
protected override void OnMouseDown(MouseEventArgs e)
|
||||
{
|
||||
base.OnMouseDown(e);
|
||||
if (e.Button != MouseButtons.Left || !Scrollable) return;
|
||||
_dragging = true;
|
||||
_dragStartY = e.Y;
|
||||
_dragStartValue = Value;
|
||||
Capture = true;
|
||||
if (!ThumbBounds.Contains(e.Location))
|
||||
{
|
||||
var jump = e.Y < ThumbBounds.Top ? -Viewport : Viewport;
|
||||
Scrolled?.Invoke(this, Math.Clamp(Value + jump, 0, Maximum));
|
||||
}
|
||||
Invalidate();
|
||||
}
|
||||
|
||||
protected override void OnMouseMove(MouseEventArgs e)
|
||||
{
|
||||
base.OnMouseMove(e);
|
||||
if (!_dragging || Maximum <= 0) return;
|
||||
var travel = Math.Max(1, Height - ThumbBounds.Height);
|
||||
var next = _dragStartValue + (int)Math.Round((e.Y - _dragStartY) * (Maximum / (double)travel));
|
||||
Scrolled?.Invoke(this, Math.Clamp(next, 0, Maximum));
|
||||
}
|
||||
|
||||
protected override void OnMouseUp(MouseEventArgs e)
|
||||
{
|
||||
base.OnMouseUp(e);
|
||||
if (!_dragging) return;
|
||||
_dragging = false;
|
||||
Capture = false;
|
||||
Invalidate();
|
||||
}
|
||||
}
|
||||
|
||||
internal class FramelessForm : Form
|
||||
|
||||
{
|
||||
public FramelessForm()
|
||||
{
|
||||
FormBorderStyle = FormBorderStyle.None;
|
||||
ShowInTaskbar = false;
|
||||
StartPosition = FormStartPosition.Manual;
|
||||
BackColor = UiTheme.Background;
|
||||
ForeColor = UiTheme.Text;
|
||||
Font = UiTheme.Body;
|
||||
DoubleBuffered = true;
|
||||
KeyPreview = true;
|
||||
AutoScaleMode = AutoScaleMode.None;
|
||||
}
|
||||
|
||||
protected override CreateParams CreateParams
|
||||
{
|
||||
get
|
||||
{
|
||||
const int CsDropShadow = 0x00020000;
|
||||
var parameters = base.CreateParams;
|
||||
parameters.ClassStyle |= CsDropShadow;
|
||||
return parameters;
|
||||
}
|
||||
}
|
||||
|
||||
protected override void OnResize(EventArgs e)
|
||||
{
|
||||
base.OnResize(e);
|
||||
ApplyRoundedRegion();
|
||||
}
|
||||
|
||||
protected override void OnDpiChanged(DpiChangedEventArgs e)
|
||||
{
|
||||
base.OnDpiChanged(e);
|
||||
ApplyRoundedRegion();
|
||||
}
|
||||
|
||||
private void ApplyRoundedRegion()
|
||||
{
|
||||
if (Width <= 0 || Height <= 0) return;
|
||||
using var path = UiTheme.Rounded(new Rectangle(0, 0, Width, Height), UiTheme.S(this, 12));
|
||||
var previous = Region;
|
||||
Region = new Region(path);
|
||||
previous?.Dispose();
|
||||
}
|
||||
|
||||
protected override void OnPaint(PaintEventArgs e)
|
||||
{
|
||||
base.OnPaint(e);
|
||||
UiTheme.Configure(e.Graphics);
|
||||
var bounds = new Rectangle(0, 0, Math.Max(1, Width - 1), Math.Max(1, Height - 1));
|
||||
UiTheme.StrokeRounded(e.Graphics, bounds, UiTheme.S(this, 12), UiTheme.Border, UiTheme.SF(this, 1f));
|
||||
}
|
||||
|
||||
protected override bool ProcessCmdKey(ref Message msg, Keys keyData)
|
||||
{
|
||||
if (keyData == Keys.Escape)
|
||||
{
|
||||
OnEscapePressed();
|
||||
return true;
|
||||
}
|
||||
return base.ProcessCmdKey(ref msg, keyData);
|
||||
}
|
||||
|
||||
protected virtual void OnEscapePressed() => Hide();
|
||||
}
|
||||
@@ -0,0 +1,910 @@
|
||||
using System.Drawing;
|
||||
using System.Globalization;
|
||||
using System.Text.RegularExpressions;
|
||||
using System.Windows.Forms;
|
||||
using GatewayKeepalive.Core;
|
||||
|
||||
namespace GatewayKeepalive.Tray.Ui;
|
||||
|
||||
internal sealed class SettingsForm : FramelessForm
|
||||
{
|
||||
private readonly ITrayController _controller;
|
||||
private readonly AppSettings _original;
|
||||
private readonly IReadOnlyList<AdapterInfo> _adapters;
|
||||
|
||||
private readonly DarkSelect _adapterBox = new();
|
||||
private readonly TextBox _directGateway = new();
|
||||
private readonly TextBox _proxyGateway = new();
|
||||
private readonly TextBox _proxyDns = new();
|
||||
private readonly CheckBox _preserveOriginalDirectDns = new();
|
||||
private readonly CheckBox _directDnsAutomatic = new();
|
||||
private readonly TextBox _directDnsServers = new();
|
||||
private readonly CheckBox _useSocks = new();
|
||||
private readonly TextBox _socksPort = new();
|
||||
private readonly TextBox _testHosts = new();
|
||||
private readonly TextBox _requiredTargets = new();
|
||||
private readonly TextBox _directTestHosts = new();
|
||||
private readonly TextBox _requiredDirectTargets = new();
|
||||
private readonly TextBox _probeTimeout = new();
|
||||
private readonly TextBox _roundTimeout = new();
|
||||
private readonly TextBox _interval = new();
|
||||
private readonly TextBox _successThreshold = new();
|
||||
private readonly TextBox _failureThreshold = new();
|
||||
private readonly TextBox _recoveryCooldown = new();
|
||||
private readonly TextBox _failureCooldown = new();
|
||||
private readonly CheckBox _notifications = new();
|
||||
private readonly CheckBox _restoreOnExit = new();
|
||||
|
||||
private readonly ThemeButton _saveButton = new(true) { Text = "保存" };
|
||||
private readonly ThemeButton _cancelButton = new(false) { Text = "取消" };
|
||||
private readonly ThemeButton _refreshAdaptersButton = new(false) { Text = "刷新网卡" };
|
||||
private readonly ThemeButton _closeButton = new(false) { Text = "关闭" };
|
||||
private readonly Label _message = new();
|
||||
private readonly ToolTip _toolTip = new();
|
||||
private readonly PanelChip[] _tabs = new PanelChip[3];
|
||||
private readonly DarkScrollPanel[] _pages = new DarkScrollPanel[3];
|
||||
private readonly List<Control> _pageStacks = [];
|
||||
private readonly List<WrapTextLabel> _helpLabels = [];
|
||||
private TableLayoutPanel _outer = null!;
|
||||
private TableLayoutPanel _body = null!;
|
||||
private Panel _pageHost = null!;
|
||||
private Control _directDnsPolicy = null!;
|
||||
private Control _directDnsServersField = null!;
|
||||
private WrapTextLabel _preserveHint = null!;
|
||||
private Control _socksPortField = null!;
|
||||
private bool _saving;
|
||||
private bool _refreshing;
|
||||
private bool _dragging;
|
||||
private Point _dragOffset;
|
||||
private int _activeTab;
|
||||
|
||||
public SettingsForm(ITrayController controller, AppSettings settings, IReadOnlyList<AdapterInfo> adapters)
|
||||
{
|
||||
_controller = controller ?? throw new ArgumentNullException(nameof(controller));
|
||||
_original = settings ?? throw new ArgumentNullException(nameof(settings));
|
||||
_adapters = adapters ?? [];
|
||||
|
||||
Text = "设置 · 网关保活";
|
||||
AutoScaleDimensions = new SizeF(96f, 96f);
|
||||
AutoScaleMode = AutoScaleMode.Dpi;
|
||||
StartPosition = FormStartPosition.CenterParent;
|
||||
ClientSize = new Size(620, 560);
|
||||
BuildInterface();
|
||||
FillValues();
|
||||
}
|
||||
|
||||
public AppSettings? SavedSettings { get; private set; }
|
||||
|
||||
internal void SelectPageForPreview(int index) => SelectTab(index);
|
||||
|
||||
internal void ScrollPageForPreview(int index, int delta)
|
||||
{
|
||||
if (index < 0 || index >= _pages.Length) return;
|
||||
_pages[index].ScrollByPixels(delta);
|
||||
}
|
||||
|
||||
private void BuildInterface()
|
||||
{
|
||||
SuspendLayout();
|
||||
_outer = new TableLayoutPanel
|
||||
{
|
||||
Dock = DockStyle.Fill,
|
||||
ColumnCount = 1,
|
||||
RowCount = 3,
|
||||
BackColor = UiTheme.Background,
|
||||
Padding = new Padding(1),
|
||||
Margin = new Padding(0)
|
||||
};
|
||||
_outer.ColumnStyles.Add(new ColumnStyle(SizeType.Percent, 100f));
|
||||
_outer.RowStyles.Add(new RowStyle(SizeType.Absolute, 64));
|
||||
_outer.RowStyles.Add(new RowStyle(SizeType.Percent, 100f));
|
||||
_outer.RowStyles.Add(new RowStyle(SizeType.Absolute, 66));
|
||||
_outer.Controls.Add(BuildHeader(), 0, 0);
|
||||
_outer.Controls.Add(BuildBody(), 0, 1);
|
||||
_outer.Controls.Add(BuildFooter(), 0, 2);
|
||||
Controls.Add(_outer);
|
||||
|
||||
AcceptButton = _saveButton;
|
||||
CancelButton = _cancelButton;
|
||||
_saveButton.Click += async (_, _) => await SaveAsync();
|
||||
_cancelButton.Click += (_, _) => CloseWithCancel();
|
||||
_closeButton.Click += (_, _) => CloseWithCancel();
|
||||
_refreshAdaptersButton.Click += async (_, _) => await RefreshAdaptersAsync();
|
||||
_preserveOriginalDirectDns.CheckedChanged += (_, _) => UpdateConditionalFields();
|
||||
_directDnsAutomatic.CheckedChanged += (_, _) => UpdateConditionalFields();
|
||||
_useSocks.CheckedChanged += (_, _) => UpdateConditionalFields();
|
||||
SelectTab(0);
|
||||
ResumeLayout(true);
|
||||
}
|
||||
|
||||
private Control BuildHeader()
|
||||
{
|
||||
var header = new TableLayoutPanel
|
||||
{
|
||||
Dock = DockStyle.Fill,
|
||||
BackColor = UiTheme.Background,
|
||||
ColumnCount = 2,
|
||||
RowCount = 1,
|
||||
Padding = new Padding(18, 8, 16, 4)
|
||||
};
|
||||
header.ColumnStyles.Add(new ColumnStyle(SizeType.Percent, 100f));
|
||||
header.ColumnStyles.Add(new ColumnStyle(SizeType.AutoSize));
|
||||
header.RowStyles.Add(new RowStyle(SizeType.Percent, 100f));
|
||||
|
||||
var titles = new TableLayoutPanel
|
||||
{
|
||||
Dock = DockStyle.Fill,
|
||||
AutoSize = true,
|
||||
ColumnCount = 1,
|
||||
RowCount = 2,
|
||||
BackColor = UiTheme.Background
|
||||
};
|
||||
var title = new Label
|
||||
{
|
||||
Text = "网关保活设置",
|
||||
AutoSize = true,
|
||||
Font = UiTheme.Title,
|
||||
ForeColor = UiTheme.Text,
|
||||
BackColor = UiTheme.Background
|
||||
};
|
||||
var subtitle = new Label
|
||||
{
|
||||
Text = "修改只在保存后生效",
|
||||
AutoSize = true,
|
||||
Font = UiTheme.Small,
|
||||
ForeColor = UiTheme.Muted,
|
||||
BackColor = UiTheme.Background
|
||||
};
|
||||
titles.Controls.Add(title, 0, 0);
|
||||
titles.Controls.Add(subtitle, 0, 1);
|
||||
_closeButton.Anchor = AnchorStyles.Right;
|
||||
header.Controls.Add(titles, 0, 0);
|
||||
header.Controls.Add(_closeButton, 1, 0);
|
||||
|
||||
header.MouseDown += BeginDrag;
|
||||
header.MouseMove += ContinueDrag;
|
||||
header.MouseUp += EndDrag;
|
||||
foreach (Control child in titles.Controls)
|
||||
{
|
||||
child.MouseDown += BeginDrag;
|
||||
child.MouseMove += ContinueDrag;
|
||||
child.MouseUp += EndDrag;
|
||||
}
|
||||
return header;
|
||||
}
|
||||
|
||||
private Control BuildBody()
|
||||
{
|
||||
var body = new TableLayoutPanel
|
||||
{
|
||||
Dock = DockStyle.Fill,
|
||||
ColumnCount = 1,
|
||||
RowCount = 2,
|
||||
BackColor = UiTheme.Background,
|
||||
Padding = new Padding(14, 2, 14, 0)
|
||||
};
|
||||
body.ColumnStyles.Add(new ColumnStyle(SizeType.Percent, 100f));
|
||||
body.RowStyles.Add(new RowStyle(SizeType.Absolute, 42));
|
||||
body.RowStyles.Add(new RowStyle(SizeType.Percent, 100f));
|
||||
_body = body;
|
||||
|
||||
var tabRow = new TableLayoutPanel
|
||||
{
|
||||
Dock = DockStyle.Fill,
|
||||
ColumnCount = 3,
|
||||
RowCount = 1,
|
||||
BackColor = UiTheme.Background,
|
||||
Margin = new Padding(0)
|
||||
};
|
||||
for (var i = 0; i < 3; i++) tabRow.ColumnStyles.Add(new ColumnStyle(SizeType.Percent, 33.333f));
|
||||
tabRow.RowStyles.Add(new RowStyle(SizeType.Percent, 100f));
|
||||
|
||||
_pages[0] = MakePage();
|
||||
_pages[1] = MakePage();
|
||||
_pages[2] = MakePage();
|
||||
_pages[0].Controls.Add(BuildConnectionPage());
|
||||
_pages[1].Controls.Add(BuildRulesPage());
|
||||
_pages[2].Controls.Add(BuildDetectionPage());
|
||||
|
||||
var names = new[] { "连接", "保活规则", "检测与提醒" };
|
||||
for (var i = 0; i < 3; i++)
|
||||
{
|
||||
var index = i;
|
||||
var tab = new PanelChip
|
||||
{
|
||||
Text = names[i],
|
||||
Font = UiTheme.BodyBold,
|
||||
Dock = DockStyle.Fill,
|
||||
Margin = new Padding(3, 2, 3, 4),
|
||||
AccessibleName = names[i]
|
||||
};
|
||||
tab.Click += (_, _) => SelectTab(index);
|
||||
_tabs[i] = tab;
|
||||
tabRow.Controls.Add(tab, i, 0);
|
||||
}
|
||||
|
||||
_pageHost = new Panel
|
||||
{
|
||||
Dock = DockStyle.Fill,
|
||||
BackColor = UiTheme.Background,
|
||||
Margin = new Padding(0)
|
||||
};
|
||||
foreach (var page in _pages) _pageHost.Controls.Add(page);
|
||||
body.Controls.Add(tabRow, 0, 0);
|
||||
body.Controls.Add(_pageHost, 0, 1);
|
||||
return body;
|
||||
}
|
||||
|
||||
private Control BuildFooter()
|
||||
{
|
||||
var footer = new TableLayoutPanel
|
||||
{
|
||||
Dock = DockStyle.Fill,
|
||||
BackColor = UiTheme.BackgroundRaised,
|
||||
ColumnCount = 2,
|
||||
RowCount = 1,
|
||||
Padding = new Padding(16, 8, 16, 8)
|
||||
};
|
||||
footer.ColumnStyles.Add(new ColumnStyle(SizeType.Percent, 100f));
|
||||
footer.ColumnStyles.Add(new ColumnStyle(SizeType.AutoSize));
|
||||
footer.RowStyles.Add(new RowStyle(SizeType.Percent, 100f));
|
||||
|
||||
_message.Text = "尚未保存";
|
||||
_message.AutoSize = false;
|
||||
_message.Dock = DockStyle.Fill;
|
||||
_message.TextAlign = ContentAlignment.MiddleLeft;
|
||||
_message.Font = UiTheme.Small;
|
||||
_message.ForeColor = UiTheme.Muted;
|
||||
_message.BackColor = UiTheme.BackgroundRaised;
|
||||
_message.AutoEllipsis = true;
|
||||
|
||||
var actions = new FlowLayoutPanel
|
||||
{
|
||||
AutoSize = true,
|
||||
WrapContents = false,
|
||||
FlowDirection = FlowDirection.LeftToRight,
|
||||
Anchor = AnchorStyles.Right,
|
||||
BackColor = UiTheme.BackgroundRaised,
|
||||
Margin = new Padding(0)
|
||||
};
|
||||
actions.Controls.Add(_cancelButton);
|
||||
actions.Controls.Add(_saveButton);
|
||||
footer.Controls.Add(_message, 0, 0);
|
||||
footer.Controls.Add(actions, 1, 0);
|
||||
return footer;
|
||||
}
|
||||
|
||||
private DarkScrollPanel MakePage() => new()
|
||||
{
|
||||
Dock = DockStyle.Fill,
|
||||
BackColor = UiTheme.Background,
|
||||
Padding = new Padding(2, 8, 2, 10),
|
||||
Visible = false
|
||||
};
|
||||
|
||||
private Control BuildConnectionPage()
|
||||
{
|
||||
var page = NewPageStack();
|
||||
_adapterBox.Dock = DockStyle.Fill;
|
||||
_adapterBox.AccessibleName = "网络适配器";
|
||||
_adapterBox.Margin = new Padding(0, 0, 8, 0);
|
||||
_refreshAdaptersButton.MinimumSize = new Size(82, 28);
|
||||
_refreshAdaptersButton.Margin = new Padding(0);
|
||||
|
||||
var adapterRow = new TableLayoutPanel
|
||||
{
|
||||
Dock = DockStyle.Top,
|
||||
AutoSize = true,
|
||||
ColumnCount = 2,
|
||||
RowCount = 1,
|
||||
Margin = new Padding(0)
|
||||
};
|
||||
adapterRow.ColumnStyles.Add(new ColumnStyle(SizeType.Percent, 100f));
|
||||
adapterRow.ColumnStyles.Add(new ColumnStyle(SizeType.AutoSize));
|
||||
adapterRow.Controls.Add(_adapterBox, 0, 0);
|
||||
adapterRow.Controls.Add(_refreshAdaptersButton, 1, 0);
|
||||
AddRow(page, MakeCard("网卡", "选择本机要管理的网络接口", Field("网络适配器", adapterRow)));
|
||||
_toolTip.SetToolTip(_adapterBox, "只管理这里明确选择的网卡");
|
||||
|
||||
ConfigureText(_directGateway);
|
||||
ConfigureText(_proxyGateway);
|
||||
ConfigureText(_proxyDns);
|
||||
AddRow(page, MakeCard("网关", "填写主路由和旁路由的 IPv4 地址",
|
||||
Pair(Field("主路由网关", _directGateway), Field("旁路由网关", _proxyGateway)),
|
||||
Field("旁路由 DNS", _proxyDns)));
|
||||
|
||||
ConfigureCheck(_preserveOriginalDirectDns, "保留接管前的直连 DNS");
|
||||
ConfigureCheck(_directDnsAutomatic, "直连时自动获取 DNS");
|
||||
ConfigureText(_directDnsServers);
|
||||
_preserveHint = HelpLabel("恢复直连时使用接管前读取到、并在系统里生效的原始 DNS。");
|
||||
_directDnsPolicy = NewPageStack();
|
||||
AddRow((TableLayoutPanel)_directDnsPolicy, WrapControl(_directDnsAutomatic));
|
||||
_directDnsServersField = Field("直连 DNS 服务器(逗号或空格分隔)", _directDnsServers);
|
||||
AddRow((TableLayoutPanel)_directDnsPolicy, _directDnsServersField);
|
||||
var dnsCard = MakeCard("直连 DNS", "决定恢复主路由时使用的 DNS",
|
||||
WrapControl(_preserveOriginalDirectDns), _preserveHint, _directDnsPolicy);
|
||||
AddRow(page, dnsCard);
|
||||
_toolTip.SetToolTip(_preserveOriginalDirectDns,
|
||||
"勾选后,恢复直连会沿用接管前读取到的真实 DNS。若当前已经走旁路由且没有可信的原 DNS,保存时会提示你选择自动或静态 DNS。");
|
||||
_pageStacks.Add(page);
|
||||
return page;
|
||||
}
|
||||
|
||||
private Control BuildRulesPage()
|
||||
{
|
||||
var page = NewPageStack();
|
||||
ConfigureText(_interval);
|
||||
ConfigureText(_probeTimeout);
|
||||
ConfigureText(_roundTimeout);
|
||||
AddRow(page, MakeCard("检测节奏", "检测越频繁,故障发现越快",
|
||||
Field("检测间隔(秒)", _interval),
|
||||
Pair(Field("单次检查超时(毫秒)", _probeTimeout), Field("整轮检查预算(毫秒)", _roundTimeout))));
|
||||
|
||||
ConfigureText(_successThreshold);
|
||||
ConfigureText(_failureThreshold);
|
||||
ConfigureText(_recoveryCooldown);
|
||||
ConfigureText(_failureCooldown);
|
||||
AddRow(page, MakeCard("切换判定", "连续达到阈值并经过冷却后才会自动切换",
|
||||
Pair(Field("连续成功几次后恢复旁路由", _successThreshold), Field("连续失败几次后切回主路由", _failureThreshold)),
|
||||
Pair(Field("切回旁路由前的冷却(秒)", _recoveryCooldown), Field("切回主路由前的冷却(秒)", _failureCooldown))));
|
||||
|
||||
var note = HelpLabel("自动保活开启时才按这些规则切换。手动固定路径后,程序仍会继续监测,但不会自动改回另一条路径。");
|
||||
AddRow(page, MakeCard("说明", string.Empty, note));
|
||||
_pageStacks.Add(page);
|
||||
return page;
|
||||
}
|
||||
|
||||
private Control BuildDetectionPage()
|
||||
{
|
||||
var page = NewPageStack();
|
||||
ConfigureCheck(_useSocks, "同时检查 SOCKS5 可用性");
|
||||
ConfigureText(_socksPort);
|
||||
_socksPortField = Field("SOCKS5 端口", _socksPort);
|
||||
AddRow(page, MakeCard("探测方式", "用于判断旁路由是否真的可用", WrapControl(_useSocks), _socksPortField));
|
||||
|
||||
ConfigureText(_testHosts, multiline: true);
|
||||
ConfigureText(_requiredTargets);
|
||||
AddRow(page, MakeCard("旁路由探测目标", "走旁路由时验证这些网站",
|
||||
Field("目标列表(每行一个,最多 5 个)", _testHosts),
|
||||
Field("至少成功几个目标", _requiredTargets)));
|
||||
|
||||
ConfigureText(_directTestHosts, multiline: true);
|
||||
ConfigureText(_requiredDirectTargets);
|
||||
AddRow(page, MakeCard("直连探测目标", "回退到主路由后验证这些网站",
|
||||
Field("目标列表(每行一个,最多 5 个)", _directTestHosts),
|
||||
Field("至少成功几个目标", _requiredDirectTargets)));
|
||||
|
||||
ConfigureCheck(_notifications, "状态变化时发送通知");
|
||||
ConfigureCheck(_restoreOnExit, "退出时恢复接管前的网络配置");
|
||||
var hint = HelpLabel("如果恢复失败,程序会继续留在托盘并说明原因。");
|
||||
AddRow(page, MakeCard("提醒与退出", "通知只针对切换、故障和恢复事件",
|
||||
WrapControl(_notifications), WrapControl(_restoreOnExit), hint));
|
||||
_pageStacks.Add(page);
|
||||
return page;
|
||||
}
|
||||
|
||||
private TableLayoutPanel NewPageStack()
|
||||
{
|
||||
var stack = new TableLayoutPanel
|
||||
{
|
||||
Dock = DockStyle.Top,
|
||||
AutoSize = true,
|
||||
AutoSizeMode = AutoSizeMode.GrowAndShrink,
|
||||
ColumnCount = 1,
|
||||
RowCount = 0,
|
||||
BackColor = UiTheme.Background,
|
||||
Margin = new Padding(0)
|
||||
};
|
||||
stack.ColumnStyles.Add(new ColumnStyle(SizeType.Percent, 100f));
|
||||
return stack;
|
||||
}
|
||||
|
||||
private static void AddRow(TableLayoutPanel table, Control control)
|
||||
{
|
||||
var row = table.RowCount;
|
||||
table.RowCount++;
|
||||
table.RowStyles.Add(new RowStyle(SizeType.AutoSize));
|
||||
control.Dock = DockStyle.Top;
|
||||
table.Controls.Add(control, 0, row);
|
||||
}
|
||||
|
||||
private static CardPanel MakeCard(string title, string subtitle, params Control[] controls)
|
||||
{
|
||||
var card = new CardPanel
|
||||
{
|
||||
Dock = DockStyle.Top,
|
||||
AutoSize = true,
|
||||
AutoSizeMode = AutoSizeMode.GrowAndShrink,
|
||||
Margin = new Padding(0, 0, 0, 10)
|
||||
};
|
||||
var stack = new TableLayoutPanel
|
||||
{
|
||||
Dock = DockStyle.Fill,
|
||||
AutoSize = true,
|
||||
AutoSizeMode = AutoSizeMode.GrowAndShrink,
|
||||
ColumnCount = 1,
|
||||
RowCount = 0,
|
||||
BackColor = UiTheme.Card,
|
||||
Margin = new Padding(0)
|
||||
};
|
||||
stack.ColumnStyles.Add(new ColumnStyle(SizeType.Percent, 100f));
|
||||
AddRow(stack, SectionTitle(title, subtitle));
|
||||
foreach (var control in controls) AddRow(stack, control);
|
||||
card.Controls.Add(stack);
|
||||
return card;
|
||||
}
|
||||
|
||||
private static Control SectionTitle(string title, string subtitle)
|
||||
{
|
||||
var host = new TableLayoutPanel
|
||||
{
|
||||
Dock = DockStyle.Top,
|
||||
AutoSize = true,
|
||||
AutoSizeMode = AutoSizeMode.GrowAndShrink,
|
||||
ColumnCount = 1,
|
||||
RowCount = subtitle.Length == 0 ? 1 : 2,
|
||||
BackColor = UiTheme.Card,
|
||||
Margin = new Padding(0, 0, 0, 10)
|
||||
};
|
||||
host.Controls.Add(new Label
|
||||
{
|
||||
Text = title,
|
||||
AutoSize = true,
|
||||
Font = UiTheme.Heading,
|
||||
ForeColor = UiTheme.Text,
|
||||
BackColor = UiTheme.Card
|
||||
}, 0, 0);
|
||||
if (subtitle.Length > 0)
|
||||
{
|
||||
host.Controls.Add(new Label
|
||||
{
|
||||
Text = subtitle,
|
||||
AutoSize = true,
|
||||
Font = UiTheme.Small,
|
||||
ForeColor = UiTheme.Faint,
|
||||
BackColor = UiTheme.Card
|
||||
}, 0, 1);
|
||||
}
|
||||
return host;
|
||||
}
|
||||
|
||||
private static Control Field(string label, Control input)
|
||||
{
|
||||
var stack = new TableLayoutPanel
|
||||
{
|
||||
Dock = DockStyle.Fill,
|
||||
AutoSize = true,
|
||||
AutoSizeMode = AutoSizeMode.GrowAndShrink,
|
||||
ColumnCount = 1,
|
||||
RowCount = 2,
|
||||
Margin = new Padding(0, 0, 0, 8),
|
||||
BackColor = UiTheme.Card
|
||||
};
|
||||
stack.ColumnStyles.Add(new ColumnStyle(SizeType.Percent, 100f));
|
||||
stack.RowStyles.Add(new RowStyle(SizeType.AutoSize));
|
||||
stack.RowStyles.Add(new RowStyle(SizeType.AutoSize));
|
||||
stack.Controls.Add(new Label
|
||||
{
|
||||
Text = label,
|
||||
AutoSize = true,
|
||||
Font = UiTheme.Small,
|
||||
ForeColor = UiTheme.Muted,
|
||||
BackColor = UiTheme.Card,
|
||||
Margin = new Padding(0, 0, 0, 3)
|
||||
}, 0, 0);
|
||||
input.Margin = new Padding(0);
|
||||
input.Dock = DockStyle.Top;
|
||||
stack.Controls.Add(input, 0, 1);
|
||||
return stack;
|
||||
}
|
||||
|
||||
private static Control Pair(Control left, Control right)
|
||||
{
|
||||
var row = new TableLayoutPanel
|
||||
{
|
||||
Dock = DockStyle.Top,
|
||||
AutoSize = true,
|
||||
AutoSizeMode = AutoSizeMode.GrowAndShrink,
|
||||
ColumnCount = 2,
|
||||
RowCount = 1,
|
||||
Margin = new Padding(0),
|
||||
BackColor = UiTheme.Card
|
||||
};
|
||||
row.ColumnStyles.Add(new ColumnStyle(SizeType.Percent, 50f));
|
||||
row.ColumnStyles.Add(new ColumnStyle(SizeType.Percent, 50f));
|
||||
row.RowStyles.Add(new RowStyle(SizeType.AutoSize));
|
||||
left.Margin = new Padding(0, 0, 6, 0);
|
||||
right.Margin = new Padding(6, 0, 0, 0);
|
||||
left.Dock = DockStyle.Fill;
|
||||
right.Dock = DockStyle.Fill;
|
||||
row.Controls.Add(left, 0, 0);
|
||||
row.Controls.Add(right, 1, 0);
|
||||
return row;
|
||||
}
|
||||
|
||||
private static Control WrapControl(Control control)
|
||||
{
|
||||
var host = new Panel
|
||||
{
|
||||
Dock = DockStyle.Top,
|
||||
AutoSize = true,
|
||||
AutoSizeMode = AutoSizeMode.GrowAndShrink,
|
||||
BackColor = UiTheme.Card,
|
||||
Margin = new Padding(0, 2, 0, 4)
|
||||
};
|
||||
control.Dock = DockStyle.Top;
|
||||
host.Controls.Add(control);
|
||||
return host;
|
||||
}
|
||||
|
||||
private WrapTextLabel HelpLabel(string text)
|
||||
{
|
||||
var label = new WrapTextLabel
|
||||
{
|
||||
Text = text,
|
||||
Dock = DockStyle.Top,
|
||||
Height = 42,
|
||||
Font = UiTheme.Small,
|
||||
ForeColor = UiTheme.Muted,
|
||||
BackColor = UiTheme.Card,
|
||||
Padding = new Padding(0, 2, 0, 2),
|
||||
Margin = new Padding(0, 2, 0, 4)
|
||||
};
|
||||
_helpLabels.Add(label);
|
||||
return label;
|
||||
}
|
||||
|
||||
private void SelectTab(int index)
|
||||
{
|
||||
_activeTab = index;
|
||||
for (var i = 0; i < _tabs.Length; i++)
|
||||
{
|
||||
if (_tabs[i] is not null) _tabs[i].Selected = i == index;
|
||||
if (_pages[i] is not null) _pages[i].Visible = i == index;
|
||||
}
|
||||
if (index >= 0 && index < _pages.Length && _pages[index].Visible)
|
||||
_pages[index].BringToFront();
|
||||
ApplyPageGutters();
|
||||
|
||||
}
|
||||
|
||||
/// <summary>The page itself reserves the scrollbar gutter, so docked cards stay clear of it.</summary>
|
||||
private void ApplyPageGutters()
|
||||
{
|
||||
foreach (var page in _pages)
|
||||
{
|
||||
if (page is null) continue;
|
||||
var right = UiTheme.S(this, 4) + page.GutterWidth;
|
||||
if (page.Padding.Right == right) continue;
|
||||
page.Padding = new Padding(page.Padding.Left, page.Padding.Top, right, page.Padding.Bottom);
|
||||
page.PerformLayout();
|
||||
}
|
||||
}
|
||||
|
||||
private void FillValues()
|
||||
{
|
||||
FillAdapters(_adapters, _original.AdapterId);
|
||||
_directGateway.Text = _original.DirectGateway;
|
||||
_proxyGateway.Text = _original.ProxyGateway;
|
||||
_proxyDns.Text = _original.ProxyDns;
|
||||
_preserveOriginalDirectDns.Checked = _original.PreserveOriginalDirectDns;
|
||||
_directDnsAutomatic.Checked = _original.DirectDnsAutomatic;
|
||||
_directDnsServers.Text = string.Join(", ", _original.DirectDnsServers);
|
||||
_useSocks.Checked = _original.UseSocks;
|
||||
_socksPort.Text = _original.SocksPort.ToString(CultureInfo.InvariantCulture);
|
||||
_testHosts.Text = string.Join(Environment.NewLine, _original.TestHosts);
|
||||
_requiredTargets.Text = _original.RequiredHealthyTargets.ToString(CultureInfo.InvariantCulture);
|
||||
_directTestHosts.Text = string.Join(Environment.NewLine, _original.DirectTestHosts);
|
||||
_requiredDirectTargets.Text = _original.RequiredDirectHealthyTargets.ToString(CultureInfo.InvariantCulture);
|
||||
_probeTimeout.Text = _original.ProbeTimeoutMs.ToString(CultureInfo.InvariantCulture);
|
||||
_roundTimeout.Text = _original.RoundTimeoutMs.ToString(CultureInfo.InvariantCulture);
|
||||
_interval.Text = _original.IntervalSeconds.ToString(CultureInfo.InvariantCulture);
|
||||
_successThreshold.Text = _original.SuccessThreshold.ToString(CultureInfo.InvariantCulture);
|
||||
_failureThreshold.Text = _original.FailureThreshold.ToString(CultureInfo.InvariantCulture);
|
||||
_recoveryCooldown.Text = _original.RecoveryCooldownSeconds.ToString(CultureInfo.InvariantCulture);
|
||||
_failureCooldown.Text = _original.FailureCooldownSeconds.ToString(CultureInfo.InvariantCulture);
|
||||
_notifications.Checked = _original.Notifications;
|
||||
_restoreOnExit.Checked = _original.RestoreOnExit;
|
||||
UpdateConditionalFields();
|
||||
|
||||
_message.Text = _original.Configured
|
||||
? "尚未保存"
|
||||
: "首次配置。保存后仍然暂停,不会立即接管网络。";
|
||||
}
|
||||
|
||||
private void FillAdapters(IEnumerable<AdapterInfo> adapters, Guid desired)
|
||||
{
|
||||
_adapterBox.BeginUpdate();
|
||||
try
|
||||
{
|
||||
_adapterBox.Items.Clear();
|
||||
var selectedIndex = -1;
|
||||
foreach (var adapter in adapters.OrderBy(x => x.Index))
|
||||
{
|
||||
var item = new AdapterItem(adapter);
|
||||
_adapterBox.Items.Add(item);
|
||||
if (adapter.Id == desired) selectedIndex = _adapterBox.Items.Count - 1;
|
||||
}
|
||||
_adapterBox.SelectedIndex = selectedIndex;
|
||||
}
|
||||
finally
|
||||
{
|
||||
_adapterBox.EndUpdate();
|
||||
}
|
||||
}
|
||||
|
||||
private async Task RefreshAdaptersAsync()
|
||||
{
|
||||
if (_refreshing || _saving) return;
|
||||
_refreshing = true;
|
||||
_refreshAdaptersButton.Enabled = false;
|
||||
SetMessage("正在读取网卡…", false);
|
||||
var desired = SelectedAdapterId();
|
||||
try
|
||||
{
|
||||
var adapters = await _controller.ListAdaptersAsync();
|
||||
FillAdapters(adapters, desired);
|
||||
SetMessage(adapters.Count == 0 ? "没有找到可用网卡。" : "网卡列表已刷新,请确认选择。", adapters.Count == 0);
|
||||
}
|
||||
catch (Exception exception)
|
||||
{
|
||||
SetMessage(UiTheme.ExceptionText(exception), true);
|
||||
}
|
||||
finally
|
||||
{
|
||||
_refreshing = false;
|
||||
_refreshAdaptersButton.Enabled = !_saving;
|
||||
}
|
||||
}
|
||||
|
||||
private async Task SaveAsync()
|
||||
{
|
||||
if (_saving) return;
|
||||
AppSettings settings;
|
||||
try
|
||||
{
|
||||
settings = BuildSettings();
|
||||
}
|
||||
catch (Exception exception) when (exception is ArgumentException or FormatException)
|
||||
{
|
||||
SetMessage(exception.Message, true);
|
||||
return;
|
||||
}
|
||||
|
||||
_saving = true;
|
||||
SetInputsEnabled(false);
|
||||
SetMessage("正在保存…", false);
|
||||
UseWaitCursor = true;
|
||||
try
|
||||
{
|
||||
await _controller.SaveSettingsAsync(settings);
|
||||
SavedSettings = settings;
|
||||
DialogResult = DialogResult.OK;
|
||||
Close();
|
||||
}
|
||||
catch (Exception exception)
|
||||
{
|
||||
if (settings.PreserveOriginalDirectDns &&
|
||||
exception is InvalidOperationException &&
|
||||
exception.Message.Contains("原直连 DNS", StringComparison.Ordinal))
|
||||
{
|
||||
SetMessage("无法保留原始直连 DNS。请取消勾选“保留接管前的直连 DNS”,再选择自动获取或填写静态 DNS。", true);
|
||||
}
|
||||
else
|
||||
{
|
||||
SetMessage(UiTheme.ExceptionText(exception), true);
|
||||
}
|
||||
}
|
||||
finally
|
||||
{
|
||||
UseWaitCursor = false;
|
||||
if (!IsDisposed && Visible)
|
||||
{
|
||||
_saving = false;
|
||||
SetInputsEnabled(true);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private AppSettings BuildSettings()
|
||||
{
|
||||
if (_adapterBox.SelectedItem is not AdapterItem selected)
|
||||
throw new ArgumentException("请选择要管理的网卡。");
|
||||
|
||||
var hosts = SplitValues(_testHosts.Text);
|
||||
var directHosts = SplitValues(_directTestHosts.Text);
|
||||
var preserveOriginalDns = _preserveOriginalDirectDns.Checked;
|
||||
var directDns = preserveOriginalDns
|
||||
? _original.DirectDnsServers
|
||||
: _directDnsAutomatic.Checked ? [] : SplitValues(_directDnsServers.Text);
|
||||
var settings = _original with
|
||||
{
|
||||
SchemaVersion = 1,
|
||||
Configured = true,
|
||||
AdapterId = selected.Adapter.Id,
|
||||
DirectGateway = _directGateway.Text.Trim(),
|
||||
ProxyGateway = _proxyGateway.Text.Trim(),
|
||||
ProxyDns = _proxyDns.Text.Trim(),
|
||||
PreserveOriginalDirectDns = preserveOriginalDns,
|
||||
DirectDnsAutomatic = preserveOriginalDns ? _original.DirectDnsAutomatic : _directDnsAutomatic.Checked,
|
||||
DirectDnsServers = directDns,
|
||||
UseSocks = _useSocks.Checked,
|
||||
SocksPort = ParseInteger(_socksPort, "SOCKS5 端口", 1, 65535),
|
||||
TestHosts = hosts,
|
||||
RequiredHealthyTargets = ParseInteger(_requiredTargets, "旁路由至少成功目标数", 1, Math.Max(1, hosts.Length)),
|
||||
DirectTestHosts = directHosts,
|
||||
RequiredDirectHealthyTargets = ParseInteger(_requiredDirectTargets, "直连至少成功目标数", 1, Math.Max(1, directHosts.Length)),
|
||||
IntervalSeconds = ParseInteger(_interval, "检测间隔", 1, 3600),
|
||||
SuccessThreshold = ParseInteger(_successThreshold, "连续成功阈值", 1, 100),
|
||||
FailureThreshold = ParseInteger(_failureThreshold, "连续失败阈值", 1, 100),
|
||||
RecoveryCooldownSeconds = ParseInteger(_recoveryCooldown, "恢复冷却", 0, 3600),
|
||||
FailureCooldownSeconds = ParseInteger(_failureCooldown, "故障回退冷却", 0, 3600),
|
||||
ProbeTimeoutMs = ParseInteger(_probeTimeout, "单次检查超时", 100, 30000),
|
||||
RoundTimeoutMs = ParseInteger(_roundTimeout, "整轮检查预算", 100, 60000),
|
||||
Notifications = _notifications.Checked,
|
||||
RestoreOnExit = _restoreOnExit.Checked,
|
||||
Policy = _original.Configured ? _original.Policy : ControlPolicy.Paused
|
||||
};
|
||||
settings.Validate();
|
||||
return settings;
|
||||
}
|
||||
|
||||
private static string[] SplitValues(string text) =>
|
||||
Regex.Split(text ?? string.Empty, "[\\r\\n,;\\s]+")
|
||||
.Select(x => x.Trim())
|
||||
.Where(x => x.Length > 0)
|
||||
.Distinct(StringComparer.OrdinalIgnoreCase)
|
||||
.ToArray();
|
||||
|
||||
private static int ParseInteger(TextBox box, string name, int minimum, int maximum)
|
||||
{
|
||||
if (!int.TryParse(box.Text.Trim(), NumberStyles.Integer, CultureInfo.InvariantCulture, out var value))
|
||||
throw new ArgumentException($"{name}必须是整数。");
|
||||
if (value < minimum || value > maximum)
|
||||
throw new ArgumentException($"{name}必须在 {minimum}~{maximum} 之间。");
|
||||
return value;
|
||||
}
|
||||
|
||||
private Guid SelectedAdapterId() =>
|
||||
_adapterBox.SelectedItem is AdapterItem item ? item.Adapter.Id : Guid.Empty;
|
||||
|
||||
private void UpdateConditionalFields()
|
||||
{
|
||||
var preserve = _preserveOriginalDirectDns.Checked;
|
||||
_preserveHint.Visible = preserve;
|
||||
_directDnsPolicy.Visible = !preserve;
|
||||
_directDnsAutomatic.Enabled = !_saving;
|
||||
_directDnsServersField.Visible = !preserve && !_directDnsAutomatic.Checked;
|
||||
_directDnsServers.Enabled = !_saving && !preserve;
|
||||
_socksPortField.Visible = _useSocks.Checked;
|
||||
_socksPort.Enabled = !_saving;
|
||||
PerformLayout();
|
||||
}
|
||||
|
||||
private void SetInputsEnabled(bool enabled)
|
||||
{
|
||||
foreach (var control in new Control[]
|
||||
{
|
||||
_adapterBox, _directGateway, _proxyGateway, _proxyDns, _preserveOriginalDirectDns,
|
||||
_directDnsAutomatic, _directDnsServers, _useSocks, _socksPort, _testHosts, _requiredTargets,
|
||||
_directTestHosts, _requiredDirectTargets, _probeTimeout, _roundTimeout, _interval,
|
||||
_successThreshold, _failureThreshold, _recoveryCooldown, _failureCooldown,
|
||||
_notifications, _restoreOnExit, _refreshAdaptersButton, _saveButton, _cancelButton, _closeButton
|
||||
})
|
||||
control.Enabled = enabled;
|
||||
foreach (var tab in _tabs) tab.Enabled = enabled;
|
||||
if (enabled) UpdateConditionalFields();
|
||||
}
|
||||
|
||||
private void SetMessage(string text, bool error)
|
||||
{
|
||||
_message.Text = text;
|
||||
_message.ForeColor = error ? UiTheme.Danger : UiTheme.Muted;
|
||||
}
|
||||
|
||||
private void CloseWithCancel()
|
||||
{
|
||||
if (_saving) return;
|
||||
DialogResult = DialogResult.Cancel;
|
||||
Close();
|
||||
}
|
||||
|
||||
protected override void OnEscapePressed() => CloseWithCancel();
|
||||
|
||||
protected override void OnFormClosing(FormClosingEventArgs e)
|
||||
{
|
||||
if (_saving && e.CloseReason == CloseReason.UserClosing)
|
||||
{
|
||||
e.Cancel = true;
|
||||
return;
|
||||
}
|
||||
base.OnFormClosing(e);
|
||||
}
|
||||
|
||||
protected override void OnLoad(EventArgs e)
|
||||
{
|
||||
base.OnLoad(e);
|
||||
var area = Screen.FromPoint(Cursor.Position).WorkingArea;
|
||||
var margin = UiTheme.S(this, 8);
|
||||
var minimumWidth = Math.Min(UiTheme.S(this, 480), Math.Max(320, area.Width - margin * 2));
|
||||
var minimumHeight = Math.Min(UiTheme.S(this, 400), Math.Max(300, area.Height - margin * 2));
|
||||
MinimumSize = new Size(minimumWidth, minimumHeight);
|
||||
Width = Math.Min(UiTheme.S(this, 620), Math.Max(minimumWidth, area.Width - margin * 2));
|
||||
Height = Math.Min(UiTheme.S(this, 560), Math.Max(minimumHeight, area.Height - margin * 2));
|
||||
|
||||
_outer.RowStyles[0].Height = UiTheme.S(this, 64);
|
||||
_outer.RowStyles[2].Height = UiTheme.S(this, 66);
|
||||
_body.RowStyles[0].Height = UiTheme.S(this, 42);
|
||||
_testHosts.Height = UiTheme.S(this, 92);
|
||||
_directTestHosts.Height = UiTheme.S(this, 92);
|
||||
foreach (var label in _helpLabels) label.Height = UiTheme.S(this, 42);
|
||||
foreach (var page in _pages) page.Padding = new Padding(UiTheme.S(this, 2), UiTheme.S(this, 8),
|
||||
UiTheme.S(this, 2), UiTheme.S(this, 8));
|
||||
|
||||
if (Owner is null)
|
||||
Location = new Point(area.Left + (area.Width - Width) / 2, area.Top + (area.Height - Height) / 2);
|
||||
}
|
||||
|
||||
private void BeginDrag(object? sender, MouseEventArgs e)
|
||||
{
|
||||
if (e.Button != MouseButtons.Left) return;
|
||||
var screen = (sender as Control)?.PointToScreen(e.Location) ?? PointToScreen(e.Location);
|
||||
_dragOffset = new Point(screen.X - Left, screen.Y - Top);
|
||||
_dragging = true;
|
||||
}
|
||||
|
||||
private void ContinueDrag(object? sender, MouseEventArgs e)
|
||||
{
|
||||
if (!_dragging || e.Button != MouseButtons.Left) return;
|
||||
var screen = (sender as Control)?.PointToScreen(e.Location) ?? PointToScreen(e.Location);
|
||||
Location = new Point(screen.X - _dragOffset.X, screen.Y - _dragOffset.Y);
|
||||
}
|
||||
|
||||
private void EndDrag(object? sender, MouseEventArgs e) => _dragging = false;
|
||||
|
||||
private static void ConfigureText(TextBox box, bool multiline = false)
|
||||
{
|
||||
box.BorderStyle = BorderStyle.FixedSingle;
|
||||
box.BackColor = UiTheme.Input;
|
||||
box.ForeColor = UiTheme.Text;
|
||||
box.Font = UiTheme.Body;
|
||||
box.Multiline = multiline;
|
||||
box.Dock = DockStyle.Top;
|
||||
// Five short lines are the supported maximum, so no native light scrollbar is needed.
|
||||
if (multiline)
|
||||
{
|
||||
box.ScrollBars = ScrollBars.None;
|
||||
box.WordWrap = false;
|
||||
box.AcceptsReturn = true;
|
||||
}
|
||||
box.Height = multiline ? 92 : 28;
|
||||
box.Margin = new Padding(0);
|
||||
}
|
||||
|
||||
private static void ConfigureCheck(CheckBox check, string text)
|
||||
{
|
||||
check.Text = text;
|
||||
check.AutoSize = true;
|
||||
check.Font = UiTheme.Body;
|
||||
check.ForeColor = UiTheme.Text;
|
||||
check.BackColor = UiTheme.Card;
|
||||
check.Margin = new Padding(0, 3, 0, 3);
|
||||
}
|
||||
|
||||
protected override void Dispose(bool disposing)
|
||||
{
|
||||
if (disposing) _toolTip.Dispose();
|
||||
base.Dispose(disposing);
|
||||
}
|
||||
|
||||
private sealed record AdapterItem(AdapterInfo Adapter)
|
||||
{
|
||||
public override string ToString()
|
||||
{
|
||||
var address = Adapter.Addresses.Length > 0
|
||||
? string.Join(", ", Adapter.Addresses.Select(x => $"{x.Address}/{x.PrefixLength}"))
|
||||
: "无 IPv4 地址";
|
||||
return $"{Adapter.Name} [{Adapter.Index}] · {(Adapter.Up ? "已连接" : "未连接")} · {address}";
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,94 @@
|
||||
using System.Windows.Forms;
|
||||
using GatewayKeepalive.Core;
|
||||
|
||||
namespace GatewayKeepalive.Tray.Ui;
|
||||
|
||||
internal sealed record StatusNotification(string Key, string Title, string Message, ToolTipIcon Icon);
|
||||
|
||||
internal sealed class StatusNotificationTracker
|
||||
{
|
||||
private readonly HashSet<string> _seenEvents = new(StringComparer.Ordinal);
|
||||
private bool _initialized;
|
||||
private DateTimeOffset? _lastSwitchAt;
|
||||
private string? _activeFaultKey;
|
||||
|
||||
public IReadOnlyList<StatusNotification> Observe(AppStatus status)
|
||||
{
|
||||
var events = new List<StatusNotification>(2);
|
||||
var switchAt = status.LastSwitchAt;
|
||||
var isMode = status.Mode is NetworkMode.Direct or NetworkMode.Proxy;
|
||||
var isRealSwitch = isMode && switchAt is not null && switchAt != _lastSwitchAt;
|
||||
var isFault = status.Mode is NetworkMode.Degraded or NetworkMode.Disconnected or
|
||||
NetworkMode.RecoveryRequired or NetworkMode.ExternalConfiguration;
|
||||
var fingerprint = status.Network?.Adapter.NetworkFingerprint ?? "unknown";
|
||||
var faultKey = isFault ? $"fault:{status.Mode}:{fingerprint}" : null;
|
||||
|
||||
if (!_initialized)
|
||||
{
|
||||
_initialized = true;
|
||||
_lastSwitchAt = isMode ? switchAt : null;
|
||||
_activeFaultKey = faultKey;
|
||||
if (isRealSwitch && switchAt is not null) Remember(SwitchKey(status, switchAt.Value));
|
||||
if (faultKey is not null) Remember(faultKey);
|
||||
return events;
|
||||
}
|
||||
|
||||
// A switch timestamp is consumed only after the controller exposes a settled mode.
|
||||
if (isMode && switchAt is not null) _lastSwitchAt = switchAt;
|
||||
if (isRealSwitch && switchAt is not null)
|
||||
{
|
||||
var key = SwitchKey(status, switchAt.Value);
|
||||
if (Mark(key))
|
||||
{
|
||||
var title = status.Mode == NetworkMode.Proxy ? "已切换到旁路由" : "已切换到主路由直连";
|
||||
var gateway = status.Network?.Gateway ?? "网关已更新";
|
||||
events.Add(new StatusNotification(key, title,
|
||||
$"{gateway} · {Clip(status.Message, 120)}", ToolTipIcon.Info));
|
||||
}
|
||||
}
|
||||
|
||||
if (_activeFaultKey is not null && _activeFaultKey != faultKey)
|
||||
_seenEvents.Remove(_activeFaultKey);
|
||||
|
||||
if (faultKey is not null && faultKey != _activeFaultKey)
|
||||
{
|
||||
_activeFaultKey = faultKey;
|
||||
if (Mark(faultKey))
|
||||
{
|
||||
var (title, icon) = status.Mode switch
|
||||
{
|
||||
NetworkMode.RecoveryRequired => ("需要恢复网络配置", ToolTipIcon.Error),
|
||||
NetworkMode.ExternalConfiguration => ("检测到外部网络配置", ToolTipIcon.Warning),
|
||||
NetworkMode.Disconnected => ("目标网卡未连接", ToolTipIcon.Error),
|
||||
_ => ("网络持续异常", ToolTipIcon.Warning)
|
||||
};
|
||||
events.Add(new StatusNotification(faultKey, title, Clip(status.Message, 150), icon));
|
||||
}
|
||||
}
|
||||
else if (faultKey is null)
|
||||
{
|
||||
_activeFaultKey = null;
|
||||
}
|
||||
|
||||
return events;
|
||||
}
|
||||
|
||||
private static string SwitchKey(AppStatus status, DateTimeOffset at) =>
|
||||
$"switch:{at.UtcTicks}:{status.Mode}";
|
||||
|
||||
private bool Mark(string key)
|
||||
{
|
||||
if (!_seenEvents.Add(key)) return false;
|
||||
if (_seenEvents.Count > 64) _seenEvents.Clear();
|
||||
return true;
|
||||
}
|
||||
|
||||
private void Remember(string key)
|
||||
{
|
||||
_seenEvents.Add(key);
|
||||
if (_seenEvents.Count > 64) _seenEvents.Clear();
|
||||
}
|
||||
|
||||
private static string Clip(string text, int max) =>
|
||||
string.IsNullOrWhiteSpace(text) ? string.Empty : text.Length <= max ? text : text[..(max - 1)] + "…";
|
||||
}
|
||||
@@ -0,0 +1,634 @@
|
||||
using System.ComponentModel;
|
||||
using System.Drawing;
|
||||
using System.Windows.Forms;
|
||||
using GatewayKeepalive.Core;
|
||||
|
||||
namespace GatewayKeepalive.Tray.Ui;
|
||||
|
||||
internal sealed class StatusPanelForm : FramelessForm
|
||||
{
|
||||
private const int DesignWidth = 390;
|
||||
private const int DesignPad = 18;
|
||||
private const int DesignHeaderTop = 10;
|
||||
private const int DesignHeaderHeight = 22;
|
||||
private const int DesignHeroTop = 38;
|
||||
private const int DesignPolicyTop = 146;
|
||||
private const int DesignInfoTop = 202;
|
||||
private const int DesignFailureTop = 280;
|
||||
private const int DesignDiagnosticsTop = 278;
|
||||
private const int DesignDiagnosticsWithFailureTop = 318;
|
||||
private const int DesignDetailRowHeight = 30;
|
||||
private const int DesignActionHeight = 30;
|
||||
|
||||
private readonly ToggleSwitch _autoToggle;
|
||||
private readonly DisclosureButton _diagnosticsToggle;
|
||||
private readonly List<PanelChip> _actionButtons = [];
|
||||
private readonly ToolTip _toolTip = new();
|
||||
|
||||
private AppStatus _status = AppStatus.Initial;
|
||||
private Rectangle _headerDragBounds;
|
||||
private string? _notice;
|
||||
private bool _noticeIsError;
|
||||
private bool _busy;
|
||||
private bool _dragging;
|
||||
private bool _diagnosticsExpanded;
|
||||
private int _actionTop;
|
||||
private Point _dragOffset;
|
||||
|
||||
public StatusPanelForm()
|
||||
{
|
||||
Text = "网关保活";
|
||||
TopMost = true;
|
||||
KeyPreview = true;
|
||||
AutoScaleMode = AutoScaleMode.None;
|
||||
|
||||
_autoToggle = new ToggleSwitch { AccessibleName = "自动保活", TabStop = true };
|
||||
_autoToggle.UserToggled += (_, _) =>
|
||||
PolicyRequested?.Invoke(_autoToggle.Checked ? ControlPolicy.Auto : ControlPolicy.Paused);
|
||||
_toolTip.SetToolTip(_autoToggle, "启用或暂停自动切换;手动固定请在托盘右键菜单选择");
|
||||
Controls.Add(_autoToggle);
|
||||
|
||||
_diagnosticsToggle = new DisclosureButton { Text = "诊断详情" };
|
||||
_diagnosticsToggle.Toggled += (_, _) =>
|
||||
{
|
||||
_diagnosticsExpanded = !_diagnosticsExpanded;
|
||||
_diagnosticsToggle.Expanded = _diagnosticsExpanded;
|
||||
Relayout(keepBottom: Visible);
|
||||
EnsureVisibleOnScreen();
|
||||
Invalidate();
|
||||
};
|
||||
_toolTip.SetToolTip(_diagnosticsToggle, "展开或收起 TCP、DNS、SOCKS5 和出口检查详情");
|
||||
Controls.Add(_diagnosticsToggle);
|
||||
|
||||
AddAction("立即检测", true, () =>
|
||||
{
|
||||
if (IsUnconfigured()) SettingsRequested?.Invoke(this, EventArgs.Empty);
|
||||
else RefreshRequested?.Invoke(this, EventArgs.Empty);
|
||||
}, "立即执行一次网络检查");
|
||||
AddAction("设置", false, () => SettingsRequested?.Invoke(this, EventArgs.Empty), "打开连接、规则和检测设置");
|
||||
AddAction("日志", false, () => LogsRequested?.Invoke(this, EventArgs.Empty), "打开日志文件夹");
|
||||
|
||||
ApplyStatus(AppStatus.Initial);
|
||||
Relayout();
|
||||
}
|
||||
|
||||
public event EventHandler? RefreshRequested;
|
||||
public event EventHandler? SettingsRequested;
|
||||
public event EventHandler? LogsRequested;
|
||||
public event Action<ControlPolicy>? PolicyRequested;
|
||||
|
||||
public DateTime? LastAutoHideAt { get; private set; }
|
||||
|
||||
[Browsable(false)]
|
||||
[DesignerSerializationVisibility(DesignerSerializationVisibility.Hidden)]
|
||||
public bool AllowAutoHide { get; set; } = true;
|
||||
|
||||
public AppStatus Status => _status;
|
||||
|
||||
private void AddAction(string text, bool emphasized, Action click, string toolTip)
|
||||
{
|
||||
var button = new PanelChip
|
||||
{
|
||||
Text = text,
|
||||
Font = emphasized ? UiTheme.BodyBold : UiTheme.Body,
|
||||
Emphasized = emphasized,
|
||||
Ghost = !emphasized,
|
||||
AccessibleName = text
|
||||
};
|
||||
button.Click += (_, _) => click();
|
||||
_toolTip.SetToolTip(button, toolTip);
|
||||
_actionButtons.Add(button);
|
||||
Controls.Add(button);
|
||||
}
|
||||
|
||||
public void ApplyStatus(AppStatus status)
|
||||
{
|
||||
_status = status ?? AppStatus.Initial;
|
||||
_notice = null;
|
||||
_noticeIsError = false;
|
||||
_autoToggle.Checked = _status.Policy == ControlPolicy.Auto;
|
||||
_diagnosticsToggle.Summary = DiagnosticsSummary();
|
||||
UpdateConfigurationState();
|
||||
Relayout(keepBottom: Visible);
|
||||
Invalidate();
|
||||
}
|
||||
|
||||
public void ShowNotice(string message, bool isError)
|
||||
{
|
||||
_notice = string.IsNullOrWhiteSpace(message) ? null : message;
|
||||
_noticeIsError = isError;
|
||||
Relayout(keepBottom: Visible);
|
||||
EnsureVisibleOnScreen();
|
||||
Invalidate();
|
||||
}
|
||||
|
||||
public void SetBusy(bool busy)
|
||||
{
|
||||
_busy = busy;
|
||||
_autoToggle.Enabled = !busy && !IsUnconfigured();
|
||||
_diagnosticsToggle.Enabled = !IsUnconfigured();
|
||||
foreach (var button in _actionButtons) button.Enabled = !busy;
|
||||
Invalidate();
|
||||
}
|
||||
|
||||
public void RefreshClock()
|
||||
{
|
||||
if (!Visible) return;
|
||||
Invalidate(new Rectangle(0, UiTheme.S(this, DesignInfoTop), ClientSize.Width,
|
||||
UiTheme.S(this, 84)));
|
||||
}
|
||||
|
||||
public void ShowNearTray()
|
||||
{
|
||||
var area = Screen.FromPoint(Cursor.Position).WorkingArea;
|
||||
Relayout();
|
||||
var margin = UiTheme.S(this, 10);
|
||||
var x = area.Right - Width - margin;
|
||||
var y = area.Bottom - Height - margin;
|
||||
if (x < area.Left + margin) x = area.Left + margin;
|
||||
if (y < area.Top + margin) y = area.Top + margin;
|
||||
Location = new Point(x, y);
|
||||
Show();
|
||||
TopMost = false;
|
||||
TopMost = true;
|
||||
Activate();
|
||||
BringToFront();
|
||||
Invalidate();
|
||||
}
|
||||
|
||||
public void HidePanel()
|
||||
{
|
||||
if (!Visible) return;
|
||||
Hide();
|
||||
LastAutoHideAt = DateTime.UtcNow;
|
||||
}
|
||||
|
||||
protected override void OnDeactivate(EventArgs e)
|
||||
{
|
||||
base.OnDeactivate(e);
|
||||
if (AllowAutoHide && Visible && !_dragging) HidePanel();
|
||||
}
|
||||
|
||||
protected override void OnEscapePressed() => HidePanel();
|
||||
|
||||
protected override void OnFormClosing(FormClosingEventArgs e)
|
||||
{
|
||||
if (e.CloseReason == CloseReason.UserClosing)
|
||||
{
|
||||
e.Cancel = true;
|
||||
HidePanel();
|
||||
return;
|
||||
}
|
||||
base.OnFormClosing(e);
|
||||
}
|
||||
|
||||
protected override void OnHandleCreated(EventArgs e)
|
||||
{
|
||||
base.OnHandleCreated(e);
|
||||
Relayout();
|
||||
}
|
||||
|
||||
protected override void OnDpiChanged(DpiChangedEventArgs e)
|
||||
{
|
||||
base.OnDpiChanged(e);
|
||||
Relayout(keepBottom: Visible);
|
||||
}
|
||||
|
||||
private void Relayout(bool keepBottom = false)
|
||||
{
|
||||
var oldBottom = Bottom;
|
||||
var width = UiTheme.S(this, DesignWidth);
|
||||
var pad = UiTheme.S(this, DesignPad);
|
||||
var contentWidth = width - pad * 2;
|
||||
|
||||
_headerDragBounds = new Rectangle(pad, UiTheme.S(this, DesignHeaderTop), contentWidth,
|
||||
UiTheme.S(this, DesignHeaderHeight));
|
||||
_autoToggle.Bounds = new Rectangle(width - pad - UiTheme.S(this, 42),
|
||||
UiTheme.S(this, DesignPolicyTop + 9), UiTheme.S(this, 42), UiTheme.S(this, 22));
|
||||
|
||||
var failure = FailureSummary() is not null;
|
||||
var diagnosticsTop = failure
|
||||
? UiTheme.S(this, DesignDiagnosticsWithFailureTop)
|
||||
: UiTheme.S(this, DesignDiagnosticsTop);
|
||||
_diagnosticsToggle.Bounds = new Rectangle(pad, diagnosticsTop, contentWidth, UiTheme.S(this, 26));
|
||||
_diagnosticsToggle.Summary = DiagnosticsSummary();
|
||||
|
||||
var detailRows = _diagnosticsExpanded ? Math.Clamp(_status.Health?.Checks.Length ?? 1, 1, 4) : 0;
|
||||
_actionTop = diagnosticsTop + UiTheme.S(this, 32) +
|
||||
detailRows * UiTheme.S(this, DesignDetailRowHeight) +
|
||||
(_diagnosticsExpanded ? UiTheme.S(this, 6) : UiTheme.S(this, 8));
|
||||
|
||||
var actionGap = UiTheme.S(this, 8);
|
||||
var actionWidth = (contentWidth - actionGap * 2) / 3;
|
||||
for (var i = 0; i < _actionButtons.Count; i++)
|
||||
{
|
||||
_actionButtons[i].Bounds = IsUnconfigured()
|
||||
? new Rectangle(pad, _actionTop, i == 0 ? contentWidth : 0,
|
||||
UiTheme.S(this, DesignActionHeight))
|
||||
: new Rectangle(pad + i * (actionWidth + actionGap), _actionTop, actionWidth,
|
||||
UiTheme.S(this, DesignActionHeight));
|
||||
}
|
||||
|
||||
var height = _actionTop + UiTheme.S(this, DesignActionHeight) + UiTheme.S(this, 10);
|
||||
ClientSize = new Size(width, height);
|
||||
MinimumSize = new Size(width, height);
|
||||
MaximumSize = new Size(width, height);
|
||||
if (keepBottom && Visible) Top = oldBottom - Height;
|
||||
Invalidate();
|
||||
}
|
||||
|
||||
private bool IsUnconfigured() =>
|
||||
_status.Network is null && _status.Mode == NetworkMode.Initializing;
|
||||
|
||||
private void UpdateConfigurationState()
|
||||
{
|
||||
var initial = IsUnconfigured();
|
||||
_actionButtons[0].Text = initial ? "开始设置" : "立即检测";
|
||||
_actionButtons[1].Visible = !initial;
|
||||
_actionButtons[2].Visible = !initial;
|
||||
_autoToggle.Enabled = !initial && !_busy;
|
||||
_diagnosticsToggle.Enabled = !initial;
|
||||
}
|
||||
|
||||
private void EnsureVisibleOnScreen()
|
||||
{
|
||||
if (!Visible) return;
|
||||
var area = Screen.FromControl(this).WorkingArea;
|
||||
var left = Math.Clamp(Left, area.Left, Math.Max(area.Left, area.Right - Width));
|
||||
var top = Math.Clamp(Top, area.Top, Math.Max(area.Top, area.Bottom - Height));
|
||||
Location = new Point(left, top);
|
||||
}
|
||||
|
||||
protected override void OnPaint(PaintEventArgs e)
|
||||
{
|
||||
base.OnPaint(e);
|
||||
var graphics = e.Graphics;
|
||||
UiTheme.Configure(graphics);
|
||||
var pad = UiTheme.S(this, DesignPad);
|
||||
var width = ClientSize.Width;
|
||||
var checks = _status.Health?.Checks ?? [];
|
||||
|
||||
DrawHeader(graphics, pad, width);
|
||||
DrawActualPath(graphics, pad, width);
|
||||
DrawAutoPolicy(graphics, pad, width);
|
||||
DrawStatusSummary(graphics, pad, width, checks);
|
||||
DrawFailure(graphics, pad, width);
|
||||
if (_diagnosticsExpanded) DrawDiagnostics(graphics, pad, width, checks);
|
||||
}
|
||||
|
||||
private void DrawHeader(Graphics graphics, int pad, int width)
|
||||
{
|
||||
UiTheme.DrawText(graphics, "网关保活", UiTheme.PanelTitle, UiTheme.Text,
|
||||
new Rectangle(pad, UiTheme.S(this, DesignHeaderTop), UiTheme.S(this, 150), UiTheme.S(this, DesignHeaderHeight)),
|
||||
TextFormatFlags.Left | TextFormatFlags.VerticalCenter | TextFormatFlags.SingleLine);
|
||||
|
||||
var (label, color) = InternetStatus();
|
||||
var textWidth = UiTheme.S(this, 100);
|
||||
var dot = UiTheme.S(this, 6);
|
||||
var x = width - pad - textWidth;
|
||||
var centerY = UiTheme.S(this, DesignHeaderTop) + UiTheme.S(this, DesignHeaderHeight) / 2;
|
||||
using var brush = new SolidBrush(color);
|
||||
graphics.FillEllipse(brush, x, centerY - dot / 2, dot, dot);
|
||||
UiTheme.DrawText(graphics, label, UiTheme.Small, color,
|
||||
new Rectangle(x + UiTheme.S(this, 12), UiTheme.S(this, DesignHeaderTop),
|
||||
textWidth - UiTheme.S(this, 12), UiTheme.S(this, DesignHeaderHeight)),
|
||||
TextFormatFlags.Right | TextFormatFlags.VerticalCenter | TextFormatFlags.SingleLine | TextFormatFlags.EndEllipsis);
|
||||
}
|
||||
|
||||
private void DrawActualPath(Graphics graphics, int pad, int width)
|
||||
{
|
||||
var active = ActivePath();
|
||||
var network = _status.Network;
|
||||
var gateway = network?.Gateway ?? "--";
|
||||
var dns = network is null ? "--"
|
||||
: network.EffectiveDns.Length > 0 ? string.Join(", ", network.EffectiveDns)
|
||||
: network.Dns.Automatic ? "自动获取" : string.Join(", ", network.Dns.Servers);
|
||||
|
||||
UiTheme.DrawText(graphics, "当前连接", UiTheme.Small, UiTheme.Muted,
|
||||
new Rectangle(pad, UiTheme.S(this, DesignHeroTop), UiTheme.S(this, 180), UiTheme.S(this, 13)),
|
||||
TextFormatFlags.Left | TextFormatFlags.VerticalCenter | TextFormatFlags.SingleLine);
|
||||
|
||||
var title = ActivePathName(active);
|
||||
var titleColor = active switch
|
||||
{
|
||||
NetworkMode.Switching => UiTheme.Warning,
|
||||
NetworkMode.Initializing or NetworkMode.Disconnected => UiTheme.Muted,
|
||||
NetworkMode.RecoveryRequired => UiTheme.Danger,
|
||||
_ => UiTheme.Text
|
||||
};
|
||||
UiTheme.DrawText(graphics, title, UiTheme.Hero, titleColor,
|
||||
new Rectangle(pad, UiTheme.S(this, DesignHeroTop + 14), UiTheme.S(this, 210), UiTheme.S(this, 37)),
|
||||
TextFormatFlags.Left | TextFormatFlags.VerticalCenter | TextFormatFlags.SingleLine | TextFormatFlags.EndEllipsis);
|
||||
|
||||
var gatewayY = UiTheme.S(this, DesignHeroTop + 52);
|
||||
UiTheme.DrawText(graphics, "网关", UiTheme.Small, UiTheme.Faint,
|
||||
new Rectangle(pad, gatewayY, UiTheme.S(this, 30), UiTheme.S(this, 17)),
|
||||
TextFormatFlags.Left | TextFormatFlags.VerticalCenter | TextFormatFlags.SingleLine);
|
||||
UiTheme.DrawText(graphics, gateway, UiTheme.BodyBold, UiTheme.Text,
|
||||
new Rectangle(pad + UiTheme.S(this, 32), gatewayY, UiTheme.S(this, 132), UiTheme.S(this, 17)),
|
||||
TextFormatFlags.Left | TextFormatFlags.VerticalCenter | TextFormatFlags.SingleLine | TextFormatFlags.EndEllipsis);
|
||||
UiTheme.DrawText(graphics, "DNS " + UiTheme.Clip(dns, 23), UiTheme.Small, UiTheme.Muted,
|
||||
new Rectangle(width - pad - UiTheme.S(this, 155), gatewayY, UiTheme.S(this, 155), UiTheme.S(this, 17)),
|
||||
TextFormatFlags.Right | TextFormatFlags.VerticalCenter | TextFormatFlags.SingleLine | TextFormatFlags.EndEllipsis);
|
||||
|
||||
var pathY = UiTheme.S(this, DesignHeroTop + 72);
|
||||
if (IsUnconfigured())
|
||||
{
|
||||
UiTheme.DrawText(graphics, "先选择网卡和网关,再启用自动保活", UiTheme.Small, UiTheme.Muted,
|
||||
new Rectangle(pad, pathY, width - pad * 2, UiTheme.S(this, 18)),
|
||||
TextFormatFlags.Left | TextFormatFlags.VerticalCenter | TextFormatFlags.SingleLine | TextFormatFlags.EndEllipsis);
|
||||
DrawSeparator(graphics, UiTheme.S(this, DesignHeroTop + 96), pad, width - pad);
|
||||
return;
|
||||
}
|
||||
|
||||
var x = pad;
|
||||
UiTheme.DrawText(graphics, "此电脑", UiTheme.Small, UiTheme.Muted,
|
||||
new Rectangle(x, pathY, UiTheme.S(this, 48), UiTheme.S(this, 18)),
|
||||
TextFormatFlags.Left | TextFormatFlags.VerticalCenter | TextFormatFlags.SingleLine);
|
||||
x += UiTheme.S(this, 52);
|
||||
DrawChevron(graphics, x, pathY);
|
||||
x += UiTheme.S(this, 17);
|
||||
var route = active switch
|
||||
{
|
||||
NetworkMode.Proxy => "旁路由",
|
||||
NetworkMode.Direct => "主路由",
|
||||
NetworkMode.ExternalConfiguration => "外部路由",
|
||||
NetworkMode.Switching => "切换中",
|
||||
NetworkMode.Disconnected => "未连接",
|
||||
NetworkMode.Initializing => "等待接管",
|
||||
_ => "当前路由"
|
||||
};
|
||||
var routeColor = active is NetworkMode.Direct or NetworkMode.Proxy ? UiTheme.Accent : UiTheme.Muted;
|
||||
UiTheme.DrawText(graphics, route, UiTheme.SmallBold, routeColor,
|
||||
new Rectangle(x, pathY, UiTheme.S(this, 62), UiTheme.S(this, 18)),
|
||||
TextFormatFlags.Left | TextFormatFlags.VerticalCenter | TextFormatFlags.SingleLine | TextFormatFlags.EndEllipsis);
|
||||
x += UiTheme.S(this, 66);
|
||||
DrawChevron(graphics, x, pathY);
|
||||
x += UiTheme.S(this, 17);
|
||||
UiTheme.DrawText(graphics, "互联网", UiTheme.SmallBold, UiTheme.InternetColor(_status.Health),
|
||||
new Rectangle(x, pathY, width - pad - x, UiTheme.S(this, 18)),
|
||||
TextFormatFlags.Left | TextFormatFlags.VerticalCenter | TextFormatFlags.SingleLine | TextFormatFlags.EndEllipsis);
|
||||
|
||||
DrawSeparator(graphics, UiTheme.S(this, DesignHeroTop + 96), pad, width - pad);
|
||||
}
|
||||
|
||||
private void DrawAutoPolicy(Graphics graphics, int pad, int width)
|
||||
{
|
||||
var top = UiTheme.S(this, DesignPolicyTop);
|
||||
UiTheme.DrawText(graphics, "自动保活", UiTheme.BodyBold, UiTheme.Text,
|
||||
new Rectangle(pad, top, UiTheme.S(this, 86), UiTheme.S(this, 20)),
|
||||
TextFormatFlags.Left | TextFormatFlags.VerticalCenter | TextFormatFlags.SingleLine);
|
||||
UiTheme.DrawText(graphics, PolicyHint(), UiTheme.Small, UiTheme.Muted,
|
||||
new Rectangle(pad, top + UiTheme.S(this, 19), width - pad * 2 - UiTheme.S(this, 155), UiTheme.S(this, 17)),
|
||||
TextFormatFlags.Left | TextFormatFlags.VerticalCenter | TextFormatFlags.SingleLine | TextFormatFlags.EndEllipsis);
|
||||
|
||||
var state = PolicyStateText();
|
||||
var stateColor = _status.Policy == ControlPolicy.Auto ? UiTheme.Accent
|
||||
: _status.Policy == ControlPolicy.Paused ? UiTheme.Muted : UiTheme.Text;
|
||||
UiTheme.DrawText(graphics, state, UiTheme.Small, stateColor,
|
||||
new Rectangle(width - pad - UiTheme.S(this, 132), top + UiTheme.S(this, 8),
|
||||
UiTheme.S(this, 82), UiTheme.S(this, 22)),
|
||||
TextFormatFlags.Right | TextFormatFlags.VerticalCenter | TextFormatFlags.SingleLine | TextFormatFlags.EndEllipsis);
|
||||
DrawSeparator(graphics, UiTheme.S(this, DesignPolicyTop + 45), pad, width - pad);
|
||||
}
|
||||
|
||||
private void DrawStatusSummary(Graphics graphics, int pad, int width, ProbeResult[] checks)
|
||||
{
|
||||
var top = UiTheme.S(this, DesignInfoTop);
|
||||
if (IsUnconfigured())
|
||||
{
|
||||
UiTheme.DrawText(graphics, "配置完成后,这里会显示当前出口和旁路由状态。", UiTheme.Small, UiTheme.Muted,
|
||||
new Rectangle(pad, top + UiTheme.S(this, 8), width - pad * 2, UiTheme.S(this, 18)),
|
||||
TextFormatFlags.Left | TextFormatFlags.VerticalCenter | TextFormatFlags.SingleLine | TextFormatFlags.EndEllipsis);
|
||||
UiTheme.DrawText(graphics, "保存设置后才会开始检测。", UiTheme.Small, UiTheme.Faint,
|
||||
new Rectangle(pad, top + UiTheme.S(this, 28), width - pad * 2, UiTheme.S(this, 17)),
|
||||
TextFormatFlags.Left | TextFormatFlags.VerticalCenter | TextFormatFlags.SingleLine);
|
||||
return;
|
||||
}
|
||||
|
||||
var egress = checks.FirstOrDefault(x => x.Name == "当前出口");
|
||||
var egressValue = _status.Health is null ? "等待检测"
|
||||
: _status.Health.InternetHealthy ? egress is null ? "可达" : $"可达 · {egress.ElapsedMs} ms"
|
||||
: "不可达";
|
||||
var egressColor = UiTheme.InternetColor(_status.Health);
|
||||
DrawMetric(graphics, pad, top, width / 2 - pad - UiTheme.S(this, 6), "当前出口", egressValue, egressColor);
|
||||
|
||||
var candidateValue = _status.Health is null ? "等待检测" : _status.Health.ProxyHealthy ? "可用" : "不可用";
|
||||
var candidateColor = _status.Health is null ? UiTheme.Paused : _status.Health.ProxyHealthy ? UiTheme.Healthy : UiTheme.Warning;
|
||||
DrawMetric(graphics, width / 2 + UiTheme.S(this, 6), top, width / 2 - pad - UiTheme.S(this, 6),
|
||||
"旁路由状态", candidateValue, candidateColor);
|
||||
|
||||
var metadata = $"上次切换 {UiTheme.Elapsed(_status.LastSwitchAt)} · 成功 {_status.SuccessCount} · 失败 {_status.FailureCount}";
|
||||
UiTheme.DrawText(graphics, metadata, UiTheme.Small, UiTheme.Faint,
|
||||
new Rectangle(pad, UiTheme.S(this, DesignInfoTop + 35), width - pad * 2, UiTheme.S(this, 16)),
|
||||
TextFormatFlags.Left | TextFormatFlags.VerticalCenter | TextFormatFlags.SingleLine | TextFormatFlags.EndEllipsis);
|
||||
|
||||
var eventText = string.IsNullOrWhiteSpace(_status.LastEvent) ? "尚无切换或恢复事件" : _status.LastEvent;
|
||||
if (_status.LastEventAt is { } eventAt) eventText = UiTheme.LocalTime(eventAt) + " · " + eventText;
|
||||
UiTheme.DrawText(graphics, UiTheme.Clip(eventText, 58), UiTheme.Small, UiTheme.Muted,
|
||||
new Rectangle(pad, UiTheme.S(this, DesignInfoTop + 53), width - pad * 2, UiTheme.S(this, 17)),
|
||||
TextFormatFlags.Left | TextFormatFlags.VerticalCenter | TextFormatFlags.SingleLine | TextFormatFlags.EndEllipsis);
|
||||
}
|
||||
|
||||
private void DrawFailure(Graphics graphics, int pad, int width)
|
||||
{
|
||||
var failure = FailureSummary();
|
||||
if (failure is null) return;
|
||||
var y = UiTheme.S(this, DesignFailureTop);
|
||||
var color = _noticeIsError || _status.Mode is NetworkMode.RecoveryRequired or NetworkMode.Disconnected
|
||||
? UiTheme.Danger : UiTheme.Warning;
|
||||
var bounds = new Rectangle(pad, y, width - pad * 2, UiTheme.S(this, 30));
|
||||
UiTheme.FillRounded(graphics, bounds, UiTheme.S(this, 7), UiTheme.WithAlpha(color, 18));
|
||||
UiTheme.StrokeRounded(graphics, bounds, UiTheme.S(this, 7), UiTheme.WithAlpha(color, 72), UiTheme.SF(this, 1f));
|
||||
var dot = UiTheme.S(this, 5);
|
||||
using var brush = new SolidBrush(color);
|
||||
graphics.FillEllipse(brush, bounds.X + UiTheme.S(this, 9), bounds.Y + bounds.Height / 2 - dot / 2, dot, dot);
|
||||
UiTheme.DrawText(graphics, UiTheme.Clip(failure, 78), UiTheme.Small, color,
|
||||
new Rectangle(bounds.X + UiTheme.S(this, 21), bounds.Y,
|
||||
bounds.Width - UiTheme.S(this, 29), bounds.Height),
|
||||
TextFormatFlags.Left | TextFormatFlags.VerticalCenter | TextFormatFlags.SingleLine | TextFormatFlags.EndEllipsis);
|
||||
}
|
||||
|
||||
private void DrawDiagnostics(Graphics graphics, int pad, int width, ProbeResult[] checks)
|
||||
{
|
||||
var top = _diagnosticsToggle.Bottom + UiTheme.S(this, 4);
|
||||
if (checks.Length == 0)
|
||||
{
|
||||
UiTheme.DrawText(graphics, "尚未执行检测", UiTheme.Small, UiTheme.Muted,
|
||||
new Rectangle(pad, top, width - pad * 2, UiTheme.S(this, DesignDetailRowHeight)),
|
||||
TextFormatFlags.Left | TextFormatFlags.VerticalCenter | TextFormatFlags.SingleLine);
|
||||
return;
|
||||
}
|
||||
|
||||
var rows = checks.Take(4).ToArray();
|
||||
for (var i = 0; i < rows.Length; i++)
|
||||
{
|
||||
var row = new Rectangle(pad, top + i * UiTheme.S(this, DesignDetailRowHeight),
|
||||
width - pad * 2, UiTheme.S(this, DesignDetailRowHeight));
|
||||
var probe = rows[i];
|
||||
var color = UiTheme.CheckColor(probe);
|
||||
var dot = UiTheme.S(this, 5);
|
||||
using (var brush = new SolidBrush(color))
|
||||
graphics.FillEllipse(brush, row.X, row.Y + UiTheme.S(this, 8), dot, dot);
|
||||
UiTheme.DrawText(graphics, UiTheme.Clip(probe.Name, 18), UiTheme.SmallBold, UiTheme.Text,
|
||||
new Rectangle(row.X + UiTheme.S(this, 11), row.Y + UiTheme.S(this, 1),
|
||||
UiTheme.S(this, 135), UiTheme.S(this, 14)),
|
||||
TextFormatFlags.Left | TextFormatFlags.VerticalCenter | TextFormatFlags.SingleLine | TextFormatFlags.EndEllipsis);
|
||||
var status = probe.Success ? $"正常 · {probe.ElapsedMs} ms" : "异常";
|
||||
UiTheme.DrawText(graphics, status, UiTheme.Small, color,
|
||||
new Rectangle(row.Right - UiTheme.S(this, 112), row.Y + UiTheme.S(this, 1),
|
||||
UiTheme.S(this, 112), UiTheme.S(this, 14)),
|
||||
TextFormatFlags.Right | TextFormatFlags.VerticalCenter | TextFormatFlags.SingleLine);
|
||||
UiTheme.DrawText(graphics, UiTheme.Clip(probe.Detail, 58), UiTheme.Small, UiTheme.Faint,
|
||||
new Rectangle(row.X + UiTheme.S(this, 11), row.Y + UiTheme.S(this, 15),
|
||||
row.Width - UiTheme.S(this, 15), UiTheme.S(this, 13)),
|
||||
TextFormatFlags.Left | TextFormatFlags.VerticalCenter | TextFormatFlags.SingleLine | TextFormatFlags.EndEllipsis);
|
||||
if (i < rows.Length - 1)
|
||||
DrawSeparator(graphics, row.Bottom, row.Left, row.Right, alpha: 115);
|
||||
}
|
||||
}
|
||||
|
||||
private void DrawMetric(Graphics graphics, int x, int y, int width, string label, string value, Color color)
|
||||
{
|
||||
UiTheme.DrawText(graphics, label, UiTheme.Small, UiTheme.Muted,
|
||||
new Rectangle(x, y, width, UiTheme.S(this, 13)),
|
||||
TextFormatFlags.Left | TextFormatFlags.VerticalCenter | TextFormatFlags.SingleLine);
|
||||
UiTheme.DrawText(graphics, value, UiTheme.BodyBold, color,
|
||||
new Rectangle(x, y + UiTheme.S(this, 13), width, UiTheme.S(this, 20)),
|
||||
TextFormatFlags.Left | TextFormatFlags.VerticalCenter | TextFormatFlags.SingleLine | TextFormatFlags.EndEllipsis);
|
||||
}
|
||||
|
||||
private void DrawChevron(Graphics graphics, int x, int y)
|
||||
{
|
||||
using var pen = new Pen(UiTheme.Faint, UiTheme.SF(this, 1.1f));
|
||||
var mid = y + UiTheme.S(this, 9);
|
||||
graphics.DrawLine(pen, x, mid - UiTheme.S(this, 3), x + UiTheme.S(this, 4), mid);
|
||||
graphics.DrawLine(pen, x + UiTheme.S(this, 4), mid, x, mid + UiTheme.S(this, 3));
|
||||
}
|
||||
|
||||
private void DrawSeparator(Graphics graphics, int y, int left, int right, int alpha = 255)
|
||||
{
|
||||
using var pen = new Pen(UiTheme.WithAlpha(UiTheme.BorderSoft, alpha), UiTheme.SF(this, 1f));
|
||||
graphics.DrawLine(pen, left, y, right, y);
|
||||
}
|
||||
|
||||
private NetworkMode ActivePath() =>
|
||||
_status.ActivePath != NetworkMode.Initializing ? _status.ActivePath : _status.Mode;
|
||||
|
||||
private (string Label, Color Color) InternetStatus()
|
||||
{
|
||||
if (IsUnconfigured()) return ("尚未配置", UiTheme.Muted);
|
||||
if (_status.Mode is NetworkMode.Disconnected or NetworkMode.RecoveryRequired)
|
||||
return ("网络异常", UiTheme.Danger);
|
||||
if (_status.Health is null) return ("等待检测", UiTheme.Paused);
|
||||
return _status.Health.InternetHealthy
|
||||
? ("外网可达", UiTheme.Healthy)
|
||||
: ("外网不可达", UiTheme.Warning);
|
||||
}
|
||||
|
||||
private static string ActivePathName(NetworkMode mode) => mode switch
|
||||
{
|
||||
NetworkMode.Proxy => "旁路由",
|
||||
NetworkMode.Direct => "主路由",
|
||||
NetworkMode.Switching => "切换中",
|
||||
NetworkMode.Degraded => "网络异常",
|
||||
NetworkMode.Disconnected => "未连接",
|
||||
NetworkMode.ExternalConfiguration => "外部网络",
|
||||
NetworkMode.RecoveryRequired => "需要恢复",
|
||||
_ => "尚未配置"
|
||||
};
|
||||
|
||||
private string PolicyHint() => _status.Policy switch
|
||||
{
|
||||
_ when IsUnconfigured() => "完成设置后可启用",
|
||||
ControlPolicy.Auto => "按健康检查自动选择路径",
|
||||
ControlPolicy.Paused => "自动切换已暂停,仍在监测",
|
||||
ControlPolicy.ForceDirect => "手动固定主路由;自动切换未启用",
|
||||
_ => "手动固定旁路由;自动切换未启用"
|
||||
};
|
||||
|
||||
private string PolicyStateText() => _status.Policy switch
|
||||
{
|
||||
_ when IsUnconfigured() => "未启用",
|
||||
ControlPolicy.Auto => "已启用",
|
||||
ControlPolicy.Paused => "已暂停",
|
||||
ControlPolicy.ForceDirect => "固定主路由",
|
||||
_ => "固定旁路由"
|
||||
};
|
||||
|
||||
private string DiagnosticsSummary()
|
||||
{
|
||||
if (IsUnconfigured()) return "配置后可查看";
|
||||
var checks = _status.Health?.Checks ?? [];
|
||||
if (checks.Length == 0) return "尚未检测";
|
||||
var failed = checks.Count(x => !x.Success);
|
||||
return failed == 0 ? $"{checks.Length} 项 · 全部正常" : $"{checks.Length} 项 · {failed} 项异常";
|
||||
}
|
||||
|
||||
private string? FailureSummary()
|
||||
{
|
||||
if (_notice is not null) return _notice;
|
||||
if (_status.Mode is NetworkMode.RecoveryRequired or NetworkMode.Disconnected)
|
||||
return string.IsNullOrWhiteSpace(_status.Message) ? "网络需要处理" : _status.Message;
|
||||
|
||||
var checks = _status.Health?.Checks ?? [];
|
||||
if (_status.Health is { InternetHealthy: false })
|
||||
{
|
||||
var egress = checks.FirstOrDefault(x => !x.Success && x.Name.Contains("出口", StringComparison.Ordinal));
|
||||
return egress is null ? "当前出口不可达" : "当前出口不可达:" + egress.Detail;
|
||||
}
|
||||
if (_status.Health is { ProxyHealthy: false })
|
||||
{
|
||||
var proxy = checks.FirstOrDefault(x => !x.Success && x.Name.Contains("SOCKS", StringComparison.Ordinal))
|
||||
?? checks.FirstOrDefault(x => !x.Success &&
|
||||
(x.Name.Contains("旁路由", StringComparison.Ordinal) || x.Name.Contains("出口", StringComparison.Ordinal)));
|
||||
return proxy is null ? "旁路由状态不可用" : "旁路由状态不可用:" + proxy.Detail;
|
||||
}
|
||||
if (_status.Mode == NetworkMode.Degraded)
|
||||
return string.IsNullOrWhiteSpace(_status.Message) ? "网络状态异常" : _status.Message;
|
||||
if (_status.Mode == NetworkMode.ExternalConfiguration)
|
||||
return string.IsNullOrWhiteSpace(_status.Message) ? "检测到外部网络配置" : _status.Message;
|
||||
return null;
|
||||
}
|
||||
|
||||
protected override void OnMouseDown(MouseEventArgs e)
|
||||
{
|
||||
base.OnMouseDown(e);
|
||||
if (e.Button != MouseButtons.Left || !_headerDragBounds.Contains(e.Location)) return;
|
||||
_dragging = true;
|
||||
_dragOffset = PointToScreen(e.Location);
|
||||
_dragOffset = new Point(_dragOffset.X - Left, _dragOffset.Y - Top);
|
||||
Capture = true;
|
||||
}
|
||||
|
||||
internal void SetDiagnosticsExpanded(bool expanded)
|
||||
{
|
||||
if (_diagnosticsExpanded == expanded) return;
|
||||
_diagnosticsExpanded = expanded;
|
||||
_diagnosticsToggle.Expanded = expanded;
|
||||
Relayout();
|
||||
}
|
||||
|
||||
protected override void OnMouseMove(MouseEventArgs e)
|
||||
{
|
||||
base.OnMouseMove(e);
|
||||
if (!_dragging) return;
|
||||
var screen = PointToScreen(e.Location);
|
||||
Location = new Point(screen.X - _dragOffset.X, screen.Y - _dragOffset.Y);
|
||||
}
|
||||
|
||||
protected override void OnMouseUp(MouseEventArgs e)
|
||||
{
|
||||
base.OnMouseUp(e);
|
||||
if (e.Button != MouseButtons.Left || !_dragging) return;
|
||||
_dragging = false;
|
||||
Capture = false;
|
||||
EnsureVisibleOnScreen();
|
||||
}
|
||||
|
||||
protected override void Dispose(bool disposing)
|
||||
{
|
||||
if (disposing) _toolTip.Dispose();
|
||||
base.Dispose(disposing);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,206 @@
|
||||
using System.Drawing;
|
||||
using System.Drawing.Drawing2D;
|
||||
using System.Globalization;
|
||||
using System.Windows.Forms;
|
||||
using GatewayKeepalive.Core;
|
||||
|
||||
namespace GatewayKeepalive.Tray.Ui;
|
||||
|
||||
internal static class UiTheme
|
||||
{
|
||||
public static readonly Color Background = Color.FromArgb(0x18, 0x1B, 0x21);
|
||||
public static readonly Color BackgroundRaised = Color.FromArgb(0x1E, 0x23, 0x2B);
|
||||
public static readonly Color Card = Color.FromArgb(0x20, 0x25, 0x2D);
|
||||
public static readonly Color CardHover = Color.FromArgb(0x29, 0x31, 0x3B);
|
||||
public static readonly Color CardPressed = Color.FromArgb(0x33, 0x3C, 0x48);
|
||||
public static readonly Color Input = Color.FromArgb(0x1B, 0x20, 0x28);
|
||||
public static readonly Color Border = Color.FromArgb(0x35, 0x3D, 0x49);
|
||||
public static readonly Color BorderSoft = Color.FromArgb(0x29, 0x30, 0x3A);
|
||||
public static readonly Color Text = Color.FromArgb(0xE9, 0xEE, 0xF5);
|
||||
public static readonly Color Muted = Color.FromArgb(0x9C, 0xA7, 0xB5);
|
||||
public static readonly Color Faint = Color.FromArgb(0x6D, 0x78, 0x87);
|
||||
public static readonly Color Accent = Color.FromArgb(0x6E, 0xA8, 0xFE);
|
||||
public static readonly Color AccentSoft = Color.FromArgb(0x8D, 0xBB, 0xFF);
|
||||
public static readonly Color Healthy = Color.FromArgb(0x4F, 0xCB, 0x8D);
|
||||
public static readonly Color Direct = Color.FromArgb(0x6E, 0xA8, 0xFE);
|
||||
public static readonly Color Warning = Color.FromArgb(0xE0, 0xA2, 0x4A);
|
||||
public static readonly Color Danger = Color.FromArgb(0xE4, 0x69, 0x6C);
|
||||
public static readonly Color Paused = Color.FromArgb(0x78, 0x84, 0x94);
|
||||
|
||||
private static readonly FontFamily Family = PickFamily();
|
||||
|
||||
public static readonly Font Title = new(Family, 13.5f, FontStyle.Bold, GraphicsUnit.Point);
|
||||
public static readonly Font PanelTitle = new(Family, 10.5f, FontStyle.Bold, GraphicsUnit.Point);
|
||||
public static readonly Font Hero = new(Family, 23f, FontStyle.Bold, GraphicsUnit.Point);
|
||||
public static readonly Font Heading = new(Family, 10.5f, FontStyle.Bold, GraphicsUnit.Point);
|
||||
public static readonly Font Body = new(Family, 9.5f, FontStyle.Regular, GraphicsUnit.Point);
|
||||
public static readonly Font BodyBold = new(Family, 9.5f, FontStyle.Bold, GraphicsUnit.Point);
|
||||
public static readonly Font Small = new(Family, 8.25f, FontStyle.Regular, GraphicsUnit.Point);
|
||||
public static readonly Font SmallBold = new(Family, 8.25f, FontStyle.Bold, GraphicsUnit.Point);
|
||||
public static readonly Font Number = new(Family, 15f, FontStyle.Bold, GraphicsUnit.Point);
|
||||
|
||||
public static float Scale(Control control) => Math.Max(1f, control.DeviceDpi / 96f);
|
||||
|
||||
public static int S(Control control, int designPixels) =>
|
||||
(int)Math.Round(designPixels * Scale(control), MidpointRounding.AwayFromZero);
|
||||
|
||||
public static float SF(Control control, float designPixels) => designPixels * Scale(control);
|
||||
|
||||
public static Color WithAlpha(Color color, int alpha) => Color.FromArgb(alpha, color);
|
||||
|
||||
public static GraphicsPath Rounded(Rectangle bounds, int radius)
|
||||
{
|
||||
var path = new GraphicsPath();
|
||||
if (bounds.Width <= 0 || bounds.Height <= 0)
|
||||
{
|
||||
path.AddRectangle(bounds);
|
||||
return path;
|
||||
}
|
||||
|
||||
var diameter = Math.Min(radius * 2, Math.Min(bounds.Width, bounds.Height));
|
||||
if (diameter <= 0)
|
||||
{
|
||||
path.AddRectangle(bounds);
|
||||
return path;
|
||||
}
|
||||
|
||||
path.AddArc(bounds.X, bounds.Y, diameter, diameter, 180, 90);
|
||||
path.AddArc(bounds.Right - diameter, bounds.Y, diameter, diameter, 270, 90);
|
||||
path.AddArc(bounds.Right - diameter, bounds.Bottom - diameter, diameter, diameter, 0, 90);
|
||||
path.AddArc(bounds.X, bounds.Bottom - diameter, diameter, diameter, 90, 90);
|
||||
path.CloseFigure();
|
||||
return path;
|
||||
}
|
||||
|
||||
public static void FillRounded(Graphics graphics, Rectangle bounds, int radius, Color color)
|
||||
{
|
||||
using var path = Rounded(bounds, radius);
|
||||
using var brush = new SolidBrush(color);
|
||||
graphics.FillPath(brush, path);
|
||||
}
|
||||
|
||||
public static void StrokeRounded(Graphics graphics, Rectangle bounds, int radius, Color color, float width)
|
||||
{
|
||||
using var path = Rounded(bounds, radius);
|
||||
using var pen = new Pen(color, width);
|
||||
graphics.DrawPath(pen, path);
|
||||
}
|
||||
|
||||
public static void DrawText(Graphics graphics, string text, Font font, Color color, Rectangle bounds,
|
||||
TextFormatFlags flags = TextFormatFlags.Left | TextFormatFlags.VerticalCenter | TextFormatFlags.SingleLine)
|
||||
{
|
||||
TextRenderer.DrawText(graphics, text, font, bounds, color,
|
||||
flags | TextFormatFlags.NoPadding | TextFormatFlags.NoPrefix);
|
||||
}
|
||||
|
||||
public static void Configure(Graphics graphics)
|
||||
{
|
||||
graphics.SmoothingMode = SmoothingMode.AntiAlias;
|
||||
graphics.PixelOffsetMode = PixelOffsetMode.HighQuality;
|
||||
graphics.TextRenderingHint = System.Drawing.Text.TextRenderingHint.ClearTypeGridFit;
|
||||
}
|
||||
|
||||
public static string PolicyName(ControlPolicy policy) => policy switch
|
||||
{
|
||||
ControlPolicy.Auto => "自动模式",
|
||||
ControlPolicy.ForceDirect => "固定直连",
|
||||
ControlPolicy.ForceProxy => "固定旁路由",
|
||||
ControlPolicy.Paused => "已暂停",
|
||||
_ => "未知模式"
|
||||
};
|
||||
|
||||
public static string ModeName(NetworkMode mode) => mode switch
|
||||
{
|
||||
NetworkMode.Initializing => "正在初始化",
|
||||
NetworkMode.Direct => "主路由直连",
|
||||
NetworkMode.Proxy => "旁路由网关",
|
||||
NetworkMode.Switching => "正在切换",
|
||||
NetworkMode.Degraded => "网络降级",
|
||||
NetworkMode.Disconnected => "网卡未连接",
|
||||
NetworkMode.ExternalConfiguration => "外部配置",
|
||||
NetworkMode.RecoveryRequired => "需要恢复",
|
||||
_ => "状态未知"
|
||||
};
|
||||
|
||||
public static Color ModeColor(NetworkMode mode, ControlPolicy policy)
|
||||
{
|
||||
if (mode == NetworkMode.RecoveryRequired) return Danger;
|
||||
if (mode == NetworkMode.Disconnected) return Danger;
|
||||
if (mode is NetworkMode.Switching or NetworkMode.Degraded) return Warning;
|
||||
if (mode == NetworkMode.ExternalConfiguration) return Accent;
|
||||
if (policy == ControlPolicy.Paused) return Paused;
|
||||
return mode switch
|
||||
{
|
||||
NetworkMode.Proxy or NetworkMode.Direct => Accent,
|
||||
_ => Paused
|
||||
};
|
||||
}
|
||||
|
||||
public static Color CheckColor(ProbeResult? check) => check switch
|
||||
{
|
||||
null => Paused,
|
||||
{ Success: true } => Healthy,
|
||||
_ => Danger
|
||||
};
|
||||
|
||||
public static Color InternetColor(HealthReport? health) => health switch
|
||||
{
|
||||
null => Paused,
|
||||
{ InternetHealthy: true } => Healthy,
|
||||
_ => Warning
|
||||
};
|
||||
|
||||
public static string CheckState(ProbeResult? check) => check switch
|
||||
{
|
||||
null => "待检测",
|
||||
{ Success: true } => "正常",
|
||||
_ => "异常"
|
||||
};
|
||||
|
||||
public static string Elapsed(DateTimeOffset? since)
|
||||
{
|
||||
if (since is null) return "尚未切换";
|
||||
var span = DateTimeOffset.Now - since.Value;
|
||||
if (span < TimeSpan.Zero) span = TimeSpan.Zero;
|
||||
if (span.TotalSeconds < 60) return $"{(int)span.TotalSeconds} 秒前";
|
||||
if (span.TotalMinutes < 60) return $"{(int)span.TotalMinutes} 分钟前";
|
||||
if (span.TotalHours < 24) return $"{(int)span.TotalHours} 小时前";
|
||||
return $"{(int)span.TotalDays} 天前";
|
||||
}
|
||||
|
||||
public static string LocalTime(DateTimeOffset value) => value.ToLocalTime().ToString("HH:mm:ss", CultureInfo.CurrentCulture);
|
||||
|
||||
public static string Clip(string? value, int maxLength)
|
||||
{
|
||||
if (string.IsNullOrWhiteSpace(value)) return string.Empty;
|
||||
var clean = value.Replace('\r', ' ').Replace('\n', ' ').Trim();
|
||||
return clean.Length <= maxLength ? clean : clean[..Math.Max(0, maxLength - 1)] + "…";
|
||||
}
|
||||
|
||||
public static string ExceptionText(Exception exception)
|
||||
{
|
||||
return exception switch
|
||||
{
|
||||
UnauthorizedAccessException => "需要管理员权限。请以管理员身份重新启动程序后再试。",
|
||||
RecoveryRequiredException => "存在未完成或需要人工核对的网络切换:" + exception.Message,
|
||||
TimeoutException => "操作超时,网络状态尚未确认。请稍后重新检测。",
|
||||
FileNotFoundException or DirectoryNotFoundException => "找不到文件或目录:" + exception.Message,
|
||||
ArgumentException => "配置或参数无效:" + exception.Message,
|
||||
IOException => "网络读写失败:" + exception.Message,
|
||||
InvalidOperationException => "当前无法执行:" + exception.Message,
|
||||
_ => "操作失败:" + (string.IsNullOrWhiteSpace(exception.Message) ? "发生未知错误。" : exception.Message)
|
||||
};
|
||||
}
|
||||
|
||||
public static Color Blend(Color color, int alpha) => Color.FromArgb(alpha, color);
|
||||
|
||||
private static FontFamily PickFamily()
|
||||
{
|
||||
foreach (var name in new[] { "Microsoft YaHei UI", "Microsoft YaHei", "Segoe UI" })
|
||||
{
|
||||
try { return new FontFamily(name); }
|
||||
catch (ArgumentException) { }
|
||||
}
|
||||
return FontFamily.GenericSansSerif;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,127 @@
|
||||
using System.Drawing;
|
||||
using System.Drawing.Drawing2D;
|
||||
using System.Runtime.InteropServices;
|
||||
using GatewayKeepalive.Core;
|
||||
|
||||
namespace GatewayKeepalive.Tray.Ui;
|
||||
|
||||
internal sealed class TrayIconFactory : IDisposable
|
||||
{
|
||||
private readonly Dictionary<string, Icon> _cache = new(StringComparer.Ordinal);
|
||||
|
||||
public Icon Get(AppStatus status)
|
||||
{
|
||||
var color = status.Policy == ControlPolicy.Paused ? UiTheme.Paused
|
||||
: status.Mode is NetworkMode.RecoveryRequired or NetworkMode.Disconnected ? UiTheme.Danger
|
||||
: status.Health is null ? UiTheme.Paused
|
||||
: status.Health.InternetHealthy ? UiTheme.Healthy
|
||||
: UiTheme.Warning;
|
||||
var shape = IconShape(status.ActivePath != NetworkMode.Initializing ? status.ActivePath : status.Mode,
|
||||
status.Policy);
|
||||
var key = $"{shape}:{color.ToArgb():X8}";
|
||||
if (_cache.TryGetValue(key, out var cached)) return cached;
|
||||
|
||||
using var bitmap = new Bitmap(32, 32, System.Drawing.Imaging.PixelFormat.Format32bppArgb);
|
||||
using (var graphics = Graphics.FromImage(bitmap))
|
||||
{
|
||||
graphics.Clear(Color.Transparent);
|
||||
graphics.SmoothingMode = SmoothingMode.AntiAlias;
|
||||
|
||||
using var background = new SolidBrush(Color.FromArgb(255, 17, 24, 33));
|
||||
using var pen = new Pen(color, 2.4f);
|
||||
graphics.FillEllipse(background, 2.5f, 2.5f, 27f, 27f);
|
||||
graphics.DrawEllipse(pen, 3.5f, 3.5f, 25f, 25f);
|
||||
DrawGlyph(graphics, shape, color);
|
||||
}
|
||||
|
||||
var handle = bitmap.GetHicon();
|
||||
try
|
||||
{
|
||||
using var temporary = Icon.FromHandle(handle);
|
||||
var icon = (Icon)temporary.Clone();
|
||||
_cache[key] = icon;
|
||||
return icon;
|
||||
}
|
||||
finally
|
||||
{
|
||||
DestroyIcon(handle);
|
||||
}
|
||||
}
|
||||
|
||||
private static TrayShape IconShape(NetworkMode mode, ControlPolicy policy)
|
||||
{
|
||||
if (policy == ControlPolicy.Paused) return TrayShape.Paused;
|
||||
return mode switch
|
||||
{
|
||||
NetworkMode.Proxy => TrayShape.Proxy,
|
||||
NetworkMode.Direct => TrayShape.Direct,
|
||||
NetworkMode.Switching or NetworkMode.Degraded => TrayShape.Warning,
|
||||
NetworkMode.Disconnected or NetworkMode.RecoveryRequired => TrayShape.Danger,
|
||||
NetworkMode.ExternalConfiguration => TrayShape.External,
|
||||
_ => TrayShape.Initializing
|
||||
};
|
||||
}
|
||||
|
||||
private static void DrawGlyph(Graphics graphics, TrayShape shape, Color color)
|
||||
{
|
||||
using var brush = new SolidBrush(color);
|
||||
using var pen = new Pen(color, 2.2f) { StartCap = LineCap.Round, EndCap = LineCap.Round };
|
||||
switch (shape)
|
||||
{
|
||||
case TrayShape.Proxy:
|
||||
graphics.DrawLine(pen, 10f, 12f, 22f, 20f);
|
||||
graphics.DrawLine(pen, 10f, 20f, 22f, 12f);
|
||||
graphics.FillEllipse(brush, 8f, 9.5f, 5f, 5f);
|
||||
graphics.FillEllipse(brush, 19f, 9.5f, 5f, 5f);
|
||||
graphics.FillEllipse(brush, 14f, 15.5f, 4f, 4f);
|
||||
break;
|
||||
case TrayShape.Direct:
|
||||
graphics.DrawLine(pen, 9f, 16f, 22f, 16f);
|
||||
graphics.DrawLine(pen, 17f, 11f, 22f, 16f);
|
||||
graphics.DrawLine(pen, 17f, 21f, 22f, 16f);
|
||||
break;
|
||||
case TrayShape.Warning:
|
||||
graphics.FillRectangle(brush, 15f, 9f, 2.5f, 9f);
|
||||
graphics.FillEllipse(brush, 15f, 21f, 2.5f, 2.5f);
|
||||
break;
|
||||
case TrayShape.Danger:
|
||||
graphics.DrawLine(pen, 11f, 11f, 21f, 21f);
|
||||
graphics.DrawLine(pen, 21f, 11f, 11f, 21f);
|
||||
break;
|
||||
case TrayShape.Paused:
|
||||
graphics.FillRectangle(brush, 11f, 10f, 4f, 12f);
|
||||
graphics.FillRectangle(brush, 18f, 10f, 4f, 12f);
|
||||
break;
|
||||
case TrayShape.External:
|
||||
using (var ring = new Pen(color, 2f))
|
||||
graphics.DrawEllipse(ring, 10f, 10f, 12f, 12f);
|
||||
graphics.FillEllipse(brush, 14.5f, 14.5f, 3f, 3f);
|
||||
break;
|
||||
default:
|
||||
graphics.FillEllipse(brush, 10f, 15f, 3f, 3f);
|
||||
graphics.FillEllipse(brush, 15f, 15f, 3f, 3f);
|
||||
graphics.FillEllipse(brush, 20f, 15f, 3f, 3f);
|
||||
break;
|
||||
}
|
||||
}
|
||||
|
||||
public void Dispose()
|
||||
{
|
||||
foreach (var icon in _cache.Values) icon.Dispose();
|
||||
_cache.Clear();
|
||||
}
|
||||
|
||||
[DllImport("user32.dll", SetLastError = true)]
|
||||
private static extern bool DestroyIcon(IntPtr handle);
|
||||
|
||||
private enum TrayShape
|
||||
{
|
||||
Initializing,
|
||||
Proxy,
|
||||
Direct,
|
||||
Warning,
|
||||
Danger,
|
||||
Paused,
|
||||
External
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,10 @@
|
||||
<?xml version="1.0" encoding="utf-8"?>
|
||||
<assembly manifestVersion="1.0" xmlns="urn:schemas-microsoft-com:asm.v1">
|
||||
<assemblyIdentity version="1.0.0.0" name="GatewayKeepalive.app" />
|
||||
<trustInfo xmlns="urn:schemas-microsoft-com:asm.v3"><security><requestedPrivileges><requestedExecutionLevel level="asInvoker" uiAccess="false" /></requestedPrivileges></security></trustInfo>
|
||||
<compatibility xmlns="urn:schemas-microsoft-com:compatibility.v1"><application><supportedOS Id="{8e0f7a12-bfb3-4fe8-b9a5-48fd50a15a9a}" /></application></compatibility>
|
||||
<application xmlns="urn:schemas-microsoft-com:asm.v3"><windowsSettings>
|
||||
<dpiAwareness xmlns="http://schemas.microsoft.com/SMI/2016/WindowsSettings">PerMonitorV2</dpiAwareness>
|
||||
<longPathAware xmlns="http://schemas.microsoft.com/SMI/2016/WindowsSettings">true</longPathAware>
|
||||
</windowsSettings></application>
|
||||
</assembly>
|
||||
@@ -0,0 +1,51 @@
|
||||
using System.Text.Json;
|
||||
using GatewayKeepalive.Core;
|
||||
|
||||
namespace GatewayKeepalive.Windows;
|
||||
|
||||
public static class DiagnosticCommands
|
||||
{
|
||||
public static async Task<int> RunAsync(string[] args)
|
||||
{
|
||||
string? Value(string name) { var i = Array.IndexOf(args, name); return i >= 0 && i + 1 < args.Length ? args[i + 1] : null; }
|
||||
object report;
|
||||
var exitCode = 0;
|
||||
try
|
||||
{
|
||||
var backend = new WindowsNetworkBackend();
|
||||
var adapters = await backend.ListAdaptersAsync(CancellationToken.None);
|
||||
var settingsPath = Value("--config") ?? Path.Combine(Environment.GetFolderPath(Environment.SpecialFolder.LocalApplicationData), "GatewayKeepalive", "settings.json");
|
||||
var settings = File.Exists(settingsPath) ? JsonSerializer.Deserialize<AppSettings>(File.ReadAllText(settingsPath), JsonStore.Options) ?? throw new InvalidDataException("配置为空。") : new();
|
||||
if (Value("--adapter") is { } adapter) settings = settings with { AdapterId = Guid.Parse(adapter), Configured = true };
|
||||
backend.ConfigureIdentity(settings);
|
||||
var snapshots = new List<object>();
|
||||
foreach (var item in adapters.Where(x => settings.AdapterId == Guid.Empty || x.Id == settings.AdapterId))
|
||||
{
|
||||
try { snapshots.Add(await backend.CaptureAsync(item.Id, CancellationToken.None)); }
|
||||
catch (Exception e) { snapshots.Add(new { Adapter = item, Error = e.Message }); }
|
||||
}
|
||||
object? plan = null;
|
||||
HealthReport? health = null;
|
||||
if (args.Contains("--dry-run") || args.Contains("--probe"))
|
||||
{
|
||||
settings.Validate();
|
||||
var snapshot = await backend.CaptureAsync(settings.AdapterId, CancellationToken.None);
|
||||
var probe = new NetworkHealthProbe(backend.ValidateRoute);
|
||||
if (args.Contains("--probe")) health = await probe.CheckAsync(settings, snapshot, CancellationToken.None);
|
||||
if (args.Contains("--dry-run"))
|
||||
{
|
||||
var service = new SwitchService(backend, probe, new JsonStore(Path.GetDirectoryName(settingsPath)!), _ => { });
|
||||
plan = service.Plan(settings, snapshot, !args.Contains("--direct"), snapshot.Gateway == settings.DirectGateway && settings.PreserveOriginalDirectDns ? snapshot.Dns : null);
|
||||
}
|
||||
}
|
||||
object? bestRoute;
|
||||
try { bestRoute = backend.DescribeBestRoute("1.1.1.1"); } catch (Exception e) { bestRoute = new { Error = e.Message }; }
|
||||
report = new { ReadOnly = true, IsAdministrator = backend.IsAdministrator, NativeLayout = WindowsNetworkBackend.NativeLayout(), Adapters = adapters, Snapshots = snapshots, BestRoute = bestRoute, PlannedConfiguration = plan, Health = health };
|
||||
}
|
||||
catch (Exception e) { report = new { ReadOnly = true, Error = e.Message, Diagnostic = args.Contains("--verbose") ? e.ToString() : null }; exitCode = 1; }
|
||||
var json = JsonSerializer.Serialize(report, JsonStore.Options);
|
||||
if (Value("--output") is { } path) { Directory.CreateDirectory(Path.GetDirectoryName(Path.GetFullPath(path))!); await File.WriteAllTextAsync(path, json); }
|
||||
Console.WriteLine(json);
|
||||
return exitCode;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,3 @@
|
||||
<Project Sdk="Microsoft.NET.Sdk">
|
||||
<ItemGroup><ProjectReference Include="../GatewayKeepalive.Core/GatewayKeepalive.Core.csproj" /></ItemGroup>
|
||||
</Project>
|
||||
@@ -0,0 +1,72 @@
|
||||
using System.Runtime.InteropServices;
|
||||
using System.Security.Principal;
|
||||
|
||||
namespace GatewayKeepalive.Windows;
|
||||
|
||||
public sealed class LoginStartup
|
||||
{
|
||||
private static string UserSid { get { using var identity = WindowsIdentity.GetCurrent(); return identity.User!.Value; } }
|
||||
private static string TaskName => "GatewayKeepalive-" + UserSid;
|
||||
|
||||
public bool IsEnabled
|
||||
{
|
||||
get
|
||||
{
|
||||
object? service = null, root = null, task = null;
|
||||
try
|
||||
{
|
||||
service = Connect(); root = ((dynamic)service).GetFolder("\\"); task = ((dynamic)root).GetTask(TaskName);
|
||||
return (bool)((dynamic)task).Enabled;
|
||||
}
|
||||
catch (COMException e) when ((uint)e.HResult == 0x80070002 || (uint)e.HResult == 0x8004130f) { return false; }
|
||||
finally { Release(task); Release(root); Release(service); }
|
||||
}
|
||||
}
|
||||
|
||||
public void SetEnabled(bool enabled)
|
||||
{
|
||||
if (!new WindowsNetworkBackend().IsAdministrator) throw new UnauthorizedAccessException("更改最高权限登录任务需要管理员权限。");
|
||||
var executable = Environment.ProcessPath ?? throw new InvalidOperationException("无法确定程序路径。");
|
||||
if (Path.GetFileNameWithoutExtension(executable).Equals("dotnet", StringComparison.OrdinalIgnoreCase)) throw new InvalidOperationException("请从发布后的 GatewayKeepalive.exe 配置登录启动。");
|
||||
object? service = null, root = null, definition = null;
|
||||
try
|
||||
{
|
||||
service = Connect(); root = ((dynamic)service).GetFolder("\\");
|
||||
if (!enabled)
|
||||
{
|
||||
try { ((dynamic)root).DeleteTask(TaskName, 0); }
|
||||
catch (COMException e) when ((uint)e.HResult == 0x80070002) { }
|
||||
return;
|
||||
}
|
||||
definition = ((dynamic)service).NewTask(0);
|
||||
dynamic task = definition;
|
||||
task.RegistrationInfo.Description = "当前用户登录后启动网关保活托盘工具。";
|
||||
task.Principal.UserId = UserSid;
|
||||
task.Principal.LogonType = 3; // TASK_LOGON_INTERACTIVE_TOKEN: show tray in this user's session.
|
||||
task.Principal.RunLevel = 1; // TASK_RUNLEVEL_HIGHEST
|
||||
task.Settings.DisallowStartIfOnBatteries = false;
|
||||
task.Settings.StopIfGoingOnBatteries = false;
|
||||
task.Settings.StartWhenAvailable = true;
|
||||
task.Settings.ExecutionTimeLimit = "PT0S";
|
||||
task.Settings.MultipleInstances = 2;
|
||||
dynamic trigger = task.Triggers.Create(9);
|
||||
trigger.UserId = UserSid;
|
||||
dynamic action = task.Actions.Create(0);
|
||||
action.Path = executable;
|
||||
action.Arguments = "--minimized";
|
||||
action.WorkingDirectory = Path.GetDirectoryName(executable);
|
||||
object registered = ((dynamic)root).RegisterTaskDefinition(TaskName, task, 6, UserSid, null, 3, null);
|
||||
Release(registered); Release(action); Release(trigger);
|
||||
}
|
||||
finally { Release(definition); Release(root); Release(service); }
|
||||
}
|
||||
|
||||
private static object Connect()
|
||||
{
|
||||
var type = Type.GetTypeFromProgID("Schedule.Service", true)!;
|
||||
dynamic service = Activator.CreateInstance(type)!;
|
||||
service.Connect();
|
||||
return service;
|
||||
}
|
||||
private static void Release(object? value) { if (value is not null && Marshal.IsComObject(value)) Marshal.ReleaseComObject(value); }
|
||||
}
|
||||
@@ -0,0 +1,93 @@
|
||||
using System.ComponentModel;
|
||||
using System.Net;
|
||||
using System.Runtime.InteropServices;
|
||||
|
||||
namespace GatewayKeepalive.Windows;
|
||||
|
||||
internal static class NativeMethods
|
||||
{
|
||||
internal const ushort AF_INET = 2;
|
||||
[StructLayout(LayoutKind.Explicit, Size = 28)]
|
||||
internal struct SockaddrInet
|
||||
{
|
||||
[FieldOffset(0)] public ushort Family;
|
||||
[FieldOffset(4)] public uint Address;
|
||||
public static SockaddrInet From(IPAddress ip) => new() { Family = AF_INET, Address = BitConverter.ToUInt32(ip.GetAddressBytes()) };
|
||||
public readonly string Ip => new IPAddress(BitConverter.GetBytes(Address)).ToString();
|
||||
}
|
||||
|
||||
[StructLayout(LayoutKind.Explicit, Size = 104)]
|
||||
internal struct ForwardRow
|
||||
{
|
||||
[FieldOffset(0)] public ulong InterfaceLuid;
|
||||
[FieldOffset(8)] public uint InterfaceIndex;
|
||||
[FieldOffset(12)] public SockaddrInet Prefix;
|
||||
[FieldOffset(40)] public byte PrefixLength;
|
||||
[FieldOffset(44)] public SockaddrInet NextHop;
|
||||
[FieldOffset(72)] public byte SitePrefixLength;
|
||||
[FieldOffset(76)] public uint ValidLifetime;
|
||||
[FieldOffset(80)] public uint PreferredLifetime;
|
||||
[FieldOffset(84)] public uint Metric;
|
||||
[FieldOffset(88)] public uint Protocol;
|
||||
[FieldOffset(92)] public byte Loopback;
|
||||
[FieldOffset(93)] public byte AutoconfigureAddress;
|
||||
[FieldOffset(94)] public byte Publish;
|
||||
[FieldOffset(95)] public byte Immortal;
|
||||
[FieldOffset(96)] public uint Age;
|
||||
[FieldOffset(100)] public uint Origin;
|
||||
}
|
||||
|
||||
// SDK MIB_IPINTERFACE_ROW, x64 ABI. The unused bytes remain owned by native code.
|
||||
[StructLayout(LayoutKind.Explicit, Size = 168)]
|
||||
internal struct InterfaceRow
|
||||
{
|
||||
[FieldOffset(0)] public ushort Family;
|
||||
[FieldOffset(8)] public ulong InterfaceLuid;
|
||||
[FieldOffset(16)] public uint InterfaceIndex;
|
||||
[FieldOffset(148)] public uint Metric;
|
||||
}
|
||||
|
||||
[StructLayout(LayoutKind.Sequential)]
|
||||
internal struct DnsSettings
|
||||
{
|
||||
public uint Version;
|
||||
public ulong Flags;
|
||||
public nint Domain, NameServer, SearchList;
|
||||
public uint RegistrationEnabled, RegisterAdapterName, EnableLLMNR, QueryAdapterName;
|
||||
public nint ProfileNameServer;
|
||||
}
|
||||
|
||||
[DllImport("iphlpapi.dll")] internal static extern uint GetIpForwardTable2(ushort family, out nint table);
|
||||
[DllImport("iphlpapi.dll")] internal static extern void FreeMibTable(nint memory);
|
||||
[DllImport("iphlpapi.dll")] internal static extern void InitializeIpForwardEntry(out ForwardRow row);
|
||||
[DllImport("iphlpapi.dll")] internal static extern uint CreateIpForwardEntry2(in ForwardRow row);
|
||||
[DllImport("iphlpapi.dll")] internal static extern uint SetIpForwardEntry2(in ForwardRow row);
|
||||
[DllImport("iphlpapi.dll")] internal static extern uint DeleteIpForwardEntry2(in ForwardRow row);
|
||||
[DllImport("iphlpapi.dll")] internal static extern void InitializeIpInterfaceEntry(out InterfaceRow row);
|
||||
[DllImport("iphlpapi.dll")] internal static extern uint GetIpInterfaceEntry(ref InterfaceRow row);
|
||||
[DllImport("iphlpapi.dll")] internal static extern uint GetBestRoute2(nint luid, uint index, nint source, in SockaddrInet destination, uint sort, out ForwardRow route, out SockaddrInet bestSource);
|
||||
[DllImport("iphlpapi.dll")] internal static extern uint GetInterfaceDnsSettings(Guid id, ref DnsSettings settings);
|
||||
[DllImport("iphlpapi.dll")] internal static extern uint SetInterfaceDnsSettings(Guid id, in DnsSettings settings);
|
||||
[DllImport("iphlpapi.dll")] internal static extern void FreeInterfaceDnsSettings(ref DnsSettings settings);
|
||||
[DllImport("dnsapi.dll")] [return: MarshalAs(UnmanagedType.Bool)] internal static extern bool DnsFlushResolverCache();
|
||||
[DllImport("iphlpapi.dll")] internal static extern uint SendARP(uint destination, uint source, [Out] byte[] physicalAddress, ref uint physicalAddressLength);
|
||||
|
||||
internal static void Check(uint code, string action)
|
||||
{
|
||||
if (code != 0) throw new Win32Exception((int)code, $"{action}:{new Win32Exception((int)code).Message}({code})");
|
||||
}
|
||||
|
||||
internal static ForwardRow[] Routes()
|
||||
{
|
||||
Check(GetIpForwardTable2(AF_INET, out var table), "读取路由");
|
||||
try
|
||||
{
|
||||
var count = Marshal.ReadInt32(table);
|
||||
if (count is < 0 or > 100000) throw new InvalidDataException("路由表大小异常。");
|
||||
var rows = new List<ForwardRow>();
|
||||
for (var i = 0; i < count; i++) rows.Add(Marshal.PtrToStructure<ForwardRow>(table + 8 + i * Marshal.SizeOf<ForwardRow>()));
|
||||
return rows.ToArray();
|
||||
}
|
||||
finally { FreeMibTable(table); }
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,26 @@
|
||||
using System.Runtime.InteropServices;
|
||||
|
||||
namespace GatewayKeepalive.Windows;
|
||||
|
||||
// Typed vtable calls are required: GUID return values do not marshal through IDispatch.
|
||||
// Order follows the Windows SDK netlistmgr.h definitions.
|
||||
[ComImport, Guid("DCB00005-570F-4A9B-8D69-199FDBA5723B"), InterfaceType(ComInterfaceType.InterfaceIsDual)]
|
||||
internal interface INetworkConnection
|
||||
{
|
||||
INetwork GetNetwork();
|
||||
[return: MarshalAs(UnmanagedType.VariantBool)] bool GetIsConnectedToInternet();
|
||||
[return: MarshalAs(UnmanagedType.VariantBool)] bool GetIsConnected();
|
||||
int GetConnectivity();
|
||||
Guid GetConnectionId();
|
||||
Guid GetAdapterId();
|
||||
}
|
||||
|
||||
[ComImport, Guid("DCB00002-570F-4A9B-8D69-199FDBA5723B"), InterfaceType(ComInterfaceType.InterfaceIsDual)]
|
||||
internal interface INetwork
|
||||
{
|
||||
[return: MarshalAs(UnmanagedType.BStr)] string GetName();
|
||||
void SetName([MarshalAs(UnmanagedType.BStr)] string name);
|
||||
[return: MarshalAs(UnmanagedType.BStr)] string GetDescription();
|
||||
void SetDescription([MarshalAs(UnmanagedType.BStr)] string description);
|
||||
Guid GetNetworkId();
|
||||
}
|
||||
@@ -0,0 +1,208 @@
|
||||
using System.Net;
|
||||
using System.Net.NetworkInformation;
|
||||
using System.Net.Sockets;
|
||||
using System.Runtime.InteropServices;
|
||||
using System.Security.Cryptography;
|
||||
using System.Security.Principal;
|
||||
using System.Text;
|
||||
using GatewayKeepalive.Core;
|
||||
using static GatewayKeepalive.Windows.NativeMethods;
|
||||
|
||||
namespace GatewayKeepalive.Windows;
|
||||
|
||||
public sealed class WindowsNetworkBackend : INetworkBackend
|
||||
{
|
||||
private AppSettings? _identitySettings;
|
||||
public void ConfigureIdentity(AppSettings settings) => _identitySettings = settings;
|
||||
public bool IsAdministrator
|
||||
{
|
||||
get { using var identity = WindowsIdentity.GetCurrent(); return new WindowsPrincipal(identity).IsInRole(WindowsBuiltInRole.Administrator); }
|
||||
}
|
||||
|
||||
public Task<IReadOnlyList<AdapterInfo>> ListAdaptersAsync(CancellationToken cancellationToken)
|
||||
{
|
||||
cancellationToken.ThrowIfCancellationRequested();
|
||||
var networks = GetNetworkIds();
|
||||
IReadOnlyList<AdapterInfo> adapters = NetworkInterface.GetAllNetworkInterfaces()
|
||||
.Where(x => x.NetworkInterfaceType != NetworkInterfaceType.Loopback && x.Supports(NetworkInterfaceComponent.IPv4))
|
||||
.Select(x => Describe(x, networks)).OrderByDescending(x => x.Up).ThenBy(x => x.Name).ToArray();
|
||||
return Task.FromResult(adapters);
|
||||
}
|
||||
|
||||
public Task<NetworkSnapshot> CaptureAsync(Guid adapterId, CancellationToken cancellationToken)
|
||||
{
|
||||
cancellationToken.ThrowIfCancellationRequested();
|
||||
var ni = NetworkInterface.GetAllNetworkInterfaces().SingleOrDefault(x => Guid.TryParse(x.Id, out var id) && id == adapterId) ?? throw new InvalidOperationException("找不到已选择的网卡。");
|
||||
var adapter = Describe(ni, _identitySettings is { Configured: true } configured && configured.AdapterId == adapterId ? new Dictionary<Guid, string>() : GetNetworkIds());
|
||||
var defaults = Routes().Where(x => x.InterfaceIndex == adapter.Index && x.PrefixLength == 0 && x.Prefix.Family == AF_INET).Select(ToModel).OrderBy(x => x.Metric).ToArray();
|
||||
var settings = new DnsSettings { Version = 1 };
|
||||
Check(GetInterfaceDnsSettings(adapterId, ref settings), "读取网卡 DNS");
|
||||
DnsConfiguration dns;
|
||||
try
|
||||
{
|
||||
var servers = ParseServers(Marshal.PtrToStringUni(settings.NameServer));
|
||||
dns = new(servers.Length == 0, servers, ParseServers(Marshal.PtrToStringUni(settings.ProfileNameServer)));
|
||||
}
|
||||
finally { FreeInterfaceDnsSettings(ref settings); }
|
||||
var effective = ni.GetIPProperties().DnsAddresses.Where(x => x.AddressFamily == AddressFamily.InterNetwork).Select(x => x.ToString()).ToArray();
|
||||
return Task.FromResult(new NetworkSnapshot(adapter, defaults, dns, effective, DateTimeOffset.UtcNow));
|
||||
}
|
||||
|
||||
public async Task WriteRoutesAsync(NetworkSnapshot desired, CancellationToken cancellationToken)
|
||||
{
|
||||
DemandAdministrator();
|
||||
var current = await CaptureAsync(desired.Adapter.Id, cancellationToken);
|
||||
if (!NetworkComparison.SameNetwork(current, desired)) throw new InvalidOperationException("写入前网络身份已变化。");
|
||||
if (desired.Routes.Length == 0) throw new InvalidOperationException("拒绝写入空默认路由集。");
|
||||
if (desired.Routes.Any(r => r.InterfaceIndex != current.Adapter.Index || r.InterfaceLuid != current.Routes.FirstOrDefault()?.InterfaceLuid)) throw new InvalidOperationException("路由身份与当前网卡不匹配。");
|
||||
// Install the destination first. Then change only exact rows on the selected interface.
|
||||
foreach (var target in desired.Routes.OrderBy(r => r.Metric))
|
||||
{
|
||||
cancellationToken.ThrowIfCancellationRequested();
|
||||
var native = Routes().SingleOrDefault(r => r.InterfaceIndex == target.InterfaceIndex && r.PrefixLength == 0 && r.NextHop.Ip == target.Gateway);
|
||||
if (native.InterfaceIndex != 0)
|
||||
{
|
||||
if (native.Metric == target.Metric && (native.Publish != 0) == target.Publish && (native.Immortal != 0) == target.Immortal) continue;
|
||||
native.Metric = target.Metric;
|
||||
native.Publish = (byte)(target.Publish ? 1 : 0);
|
||||
native.Immortal = (byte)(target.Immortal ? 1 : 0);
|
||||
Check(SetIpForwardEntry2(in native), "更新默认路由");
|
||||
}
|
||||
else
|
||||
{
|
||||
InitializeIpForwardEntry(out native);
|
||||
native.InterfaceLuid = target.InterfaceLuid;
|
||||
native.InterfaceIndex = target.InterfaceIndex;
|
||||
native.Prefix = SockaddrInet.From(IPAddress.Any);
|
||||
native.PrefixLength = 0;
|
||||
native.NextHop = SockaddrInet.From(IPAddress.Parse(target.Gateway));
|
||||
native.Metric = target.Metric;
|
||||
native.Protocol = target.Protocol;
|
||||
native.ValidLifetime = target.ValidLifetime;
|
||||
native.PreferredLifetime = target.PreferredLifetime;
|
||||
native.Publish = (byte)(target.Publish ? 1 : 0);
|
||||
native.Immortal = (byte)(target.Immortal ? 1 : 0);
|
||||
Check(CreateIpForwardEntry2(in native), "准备默认路由");
|
||||
}
|
||||
}
|
||||
// Deletions happen only when restoring a recorded snapshot. Core verifies ownership first.
|
||||
foreach (var surplus in current.Routes.Where(r => desired.Routes.All(t => t.Gateway != r.Gateway)))
|
||||
{
|
||||
cancellationToken.ThrowIfCancellationRequested();
|
||||
var native = Routes().Single(r => r.InterfaceIndex == surplus.InterfaceIndex && r.PrefixLength == 0 && r.NextHop.Ip == surplus.Gateway);
|
||||
Check(DeleteIpForwardEntry2(in native), "移除本次接管新增的路由");
|
||||
}
|
||||
}
|
||||
|
||||
public Task WriteDnsAsync(Guid adapterId, DnsConfiguration desired, CancellationToken cancellationToken)
|
||||
{
|
||||
DemandAdministrator();
|
||||
cancellationToken.ThrowIfCancellationRequested();
|
||||
if (desired.ProfileServers.Length != 0) throw new InvalidOperationException("不支持覆盖网络配置文件 DNS。");
|
||||
var text = desired.Automatic ? "" : string.Join(",", desired.Servers);
|
||||
var pointer = Marshal.StringToHGlobalUni(text);
|
||||
try
|
||||
{
|
||||
var settings = new DnsSettings { Version = 1, Flags = 2, NameServer = pointer };
|
||||
Check(SetInterfaceDnsSettings(adapterId, in settings), "设置网卡 DNS");
|
||||
DnsFlushResolverCache();
|
||||
}
|
||||
finally { Marshal.FreeHGlobal(pointer); }
|
||||
return Task.CompletedTask;
|
||||
}
|
||||
|
||||
public bool ValidateRoute(IPAddress destination, int index, string? gateway)
|
||||
{
|
||||
var address = SockaddrInet.From(destination);
|
||||
return GetBestRoute2(0, 0, 0, in address, 0, out var route, out _) == 0 && route.InterfaceIndex == index && (gateway is null || route.NextHop.Ip == gateway);
|
||||
}
|
||||
|
||||
public object DescribeBestRoute(string destination)
|
||||
{
|
||||
var address = SockaddrInet.From(IPAddress.Parse(destination));
|
||||
Check(GetBestRoute2(0, 0, 0, in address, 0, out var row, out var source), "读取有效选路");
|
||||
return new { Destination = destination, InterfaceIndex = row.InterfaceIndex, Gateway = row.NextHop.Ip, PrefixLength = row.PrefixLength, RouteMetric = row.Metric, Source = source.Ip };
|
||||
}
|
||||
|
||||
public static object NativeLayout() => new { RouteSize = Marshal.SizeOf<ForwardRow>(), InterfaceSize = Marshal.SizeOf<InterfaceRow>(), DnsSize = Marshal.SizeOf<DnsSettings>(), RouteMetricOffset = Marshal.OffsetOf<ForwardRow>(nameof(ForwardRow.Metric)).ToInt32(), InterfaceMetricOffset = Marshal.OffsetOf<InterfaceRow>(nameof(InterfaceRow.Metric)).ToInt32() };
|
||||
|
||||
private void DemandAdministrator() { if (!IsAdministrator) throw new UnauthorizedAccessException("网络写入需要管理员权限。"); }
|
||||
private static RouteEntry ToModel(ForwardRow r) => new(r.NextHop.Ip, r.Metric, r.InterfaceLuid, r.InterfaceIndex, r.Protocol, r.ValidLifetime, r.PreferredLifetime, r.Publish != 0, r.Immortal != 0);
|
||||
private static string[] ParseServers(string? value) => (value ?? "").Split([',', ' ', ';'], StringSplitOptions.RemoveEmptyEntries | StringSplitOptions.TrimEntries).Where(x => IPAddress.TryParse(x, out var ip) && ip.AddressFamily == AddressFamily.InterNetwork).ToArray();
|
||||
|
||||
private AdapterInfo Describe(NetworkInterface ni, Dictionary<Guid, string> networks)
|
||||
{
|
||||
var props = ni.GetIPProperties();
|
||||
var index = props.GetIPv4Properties().Index;
|
||||
var id = Guid.Parse(ni.Id);
|
||||
var addresses = props.UnicastAddresses.Where(x => x.Address.AddressFamily == AddressFamily.InterNetwork).Select(x => new Ipv4Address(x.Address.ToString(), x.PrefixLength)).ToArray();
|
||||
InitializeIpInterfaceEntry(out var row);
|
||||
row.Family = AF_INET;
|
||||
row.InterfaceIndex = (uint)index;
|
||||
var metric = GetIpInterfaceEntry(ref row) == 0 ? row.Metric : uint.MaxValue;
|
||||
string fingerprint = "";
|
||||
string? networkId = networks.GetValueOrDefault(id);
|
||||
// Windows NLM profile IDs can change when we deliberately change the gateway.
|
||||
// Anchor a managed session to the configured PRIMARY router's hardware identity instead.
|
||||
if (_identitySettings is { Configured: true } settings && settings.AdapterId == id)
|
||||
{
|
||||
networkId = null;
|
||||
var router = IPAddress.Parse(settings.DirectGateway);
|
||||
foreach (var address in addresses)
|
||||
{
|
||||
var local = IPAddress.Parse(address.Address);
|
||||
var a = System.Buffers.Binary.BinaryPrimitives.ReadUInt32BigEndian(local.GetAddressBytes());
|
||||
var b = System.Buffers.Binary.BinaryPrimitives.ReadUInt32BigEndian(router.GetAddressBytes());
|
||||
var prefixMask = address.PrefixLength == 0 ? 0 : uint.MaxValue << (32 - address.PrefixLength);
|
||||
if ((a & prefixMask) != (b & prefixMask)) continue;
|
||||
var mac = new byte[8]; uint length = 8;
|
||||
if (SendARP(BitConverter.ToUInt32(router.GetAddressBytes()), BitConverter.ToUInt32(local.GetAddressBytes()), mac, ref length) == 0 && length is > 0 and <= 8)
|
||||
networkId = settings.DirectGateway + ":" + Convert.ToHexString(mac.AsSpan(0, (int)length));
|
||||
break;
|
||||
}
|
||||
}
|
||||
if (networkId is not null)
|
||||
{
|
||||
var subnets = addresses.Select(x =>
|
||||
{
|
||||
var bytes = IPAddress.Parse(x.Address).GetAddressBytes();
|
||||
for (var bit = x.PrefixLength; bit < 32; bit++) bytes[bit / 8] &= (byte)~(1 << (7 - bit % 8));
|
||||
return new IPAddress(bytes) + "/" + x.PrefixLength;
|
||||
}).Order().Distinct();
|
||||
fingerprint = Convert.ToHexString(SHA256.HashData(Encoding.UTF8.GetBytes(id + "|" + networkId + "|" + string.Join(";", subnets))));
|
||||
}
|
||||
return new(id, index, ni.Name, ni.OperationalStatus == OperationalStatus.Up, addresses, metric, fingerprint);
|
||||
}
|
||||
|
||||
private static Dictionary<Guid, string> GetNetworkIds()
|
||||
{
|
||||
var result = new Dictionary<Guid, string>();
|
||||
object? manager = null;
|
||||
try
|
||||
{
|
||||
var type = Type.GetTypeFromCLSID(new Guid("DCB00C01-570F-4A9B-8D69-199FDBA5723B"), true)!;
|
||||
manager = Activator.CreateInstance(type)!;
|
||||
dynamic nlm = manager;
|
||||
foreach (dynamic connection in nlm.GetNetworkConnections())
|
||||
{
|
||||
object? network = null;
|
||||
try
|
||||
{
|
||||
var typed = (INetworkConnection)connection;
|
||||
Guid adapter = typed.GetAdapterId();
|
||||
network = typed.GetNetwork();
|
||||
Guid id = ((INetwork)network).GetNetworkId();
|
||||
result[adapter] = id.ToString("D");
|
||||
}
|
||||
finally
|
||||
{
|
||||
if (network is not null && Marshal.IsComObject(network)) Marshal.ReleaseComObject(network);
|
||||
if (Marshal.IsComObject(connection)) Marshal.ReleaseComObject(connection);
|
||||
}
|
||||
}
|
||||
}
|
||||
catch (Exception e) when (e is COMException or Microsoft.CSharp.RuntimeBinder.RuntimeBinderException or InvalidCastException or ArgumentException) { /* Unknown identity disables writes, read-only diagnostics still work. */ }
|
||||
finally { if (manager is not null && Marshal.IsComObject(manager)) Marshal.ReleaseComObject(manager); }
|
||||
return result;
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user