实现 .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,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>