实现 .NET 10 托盘版网关保活

包含分层探测、可补偿的 IPv4 路由/DNS 切换、自动策略、托盘与分组设置界面、只读诊断工具、离线测试和发布脚本。
This commit is contained in:
chuzhongzai
2026-10-23 23:35:20 +08:00
parent 59a2982b2c
commit 2d85527656
57 changed files with 8623 additions and 0 deletions
@@ -0,0 +1,4 @@
<Project Sdk="Microsoft.NET.Sdk">
<PropertyGroup><OutputType>Exe</OutputType></PropertyGroup>
<ItemGroup><ProjectReference Include="../GatewayKeepalive.Windows/GatewayKeepalive.Windows.csproj" /></ItemGroup>
</Project>
+8
View File
@@ -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" />
+120
View File
@@ -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);
}
+78
View File
@@ -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);
}
}
+36
View File
@@ -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));
}
}
+122
View File
@@ -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>
+98
View File
@@ -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;
}
}
+945
View File
@@ -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);
}
}
+206
View File
@@ -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
}
}
+10
View File
@@ -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;
}
}