实现 .NET 10 托盘版网关保活
包含分层探测、可补偿的 IPv4 路由/DNS 切换、自动策略、托盘与分组设置界面、只读诊断工具、离线测试和发布脚本。
This commit is contained in:
@@ -0,0 +1,13 @@
|
||||
<Project Sdk="Microsoft.NET.Sdk">
|
||||
<PropertyGroup>
|
||||
<OutputType>WinExe</OutputType>
|
||||
<UseWindowsForms>true</UseWindowsForms>
|
||||
<ApplicationManifest>app.manifest</ApplicationManifest>
|
||||
<AssemblyName>GatewayKeepalive</AssemblyName>
|
||||
<Version>0.1.0</Version>
|
||||
</PropertyGroup>
|
||||
<ItemGroup>
|
||||
<ProjectReference Include="../GatewayKeepalive.Core/GatewayKeepalive.Core.csproj" />
|
||||
<ProjectReference Include="../GatewayKeepalive.Windows/GatewayKeepalive.Windows.csproj" />
|
||||
</ItemGroup>
|
||||
</Project>
|
||||
@@ -0,0 +1,98 @@
|
||||
using System.ComponentModel;
|
||||
using System.Diagnostics;
|
||||
using System.Security.Principal;
|
||||
using GatewayKeepalive.Core;
|
||||
using GatewayKeepalive.Windows;
|
||||
|
||||
namespace GatewayKeepalive.Tray;
|
||||
|
||||
internal static class Program
|
||||
{
|
||||
[STAThread]
|
||||
private static int Main(string[] args)
|
||||
{
|
||||
TryInitializeWinForms();
|
||||
if (args.Contains("--preview"))
|
||||
{
|
||||
var index = Array.IndexOf(args, "--preview");
|
||||
var path = index + 1 < args.Length ? args[index + 1] : Path.Combine(AppContext.BaseDirectory, "preview");
|
||||
TrayApplicationContext.RenderPreview(path);
|
||||
return 0;
|
||||
}
|
||||
if (args.Any(x => x is "--diagnose" or "--dry-run" or "--probe")) return DiagnosticCommands.RunAsync(args).GetAwaiter().GetResult();
|
||||
if (args.Contains("--help"))
|
||||
{
|
||||
MessageBox.Show("网关保活\n直接运行:托盘程序\n--minimized:仅驻留托盘\n--diagnose --output report.json:只读诊断\n--dry-run --adapter GUID:只生成切换计划\n--preview 目录:离屏界面预览\n控制台详细输出请使用 GatewayKeepalive.Cli。", "网关保活");
|
||||
return 0;
|
||||
}
|
||||
var backend = new WindowsNetworkBackend();
|
||||
if (!backend.IsAdministrator)
|
||||
{
|
||||
try
|
||||
{
|
||||
var start = new ProcessStartInfo(Environment.ProcessPath!) { UseShellExecute = true, Verb = "runas" };
|
||||
if (string.Equals(Path.GetFileNameWithoutExtension(Environment.ProcessPath), "dotnet", StringComparison.OrdinalIgnoreCase))
|
||||
start.ArgumentList.Add(Path.Combine(AppContext.BaseDirectory, "GatewayKeepalive.dll"));
|
||||
foreach (var arg in args) start.ArgumentList.Add(arg);
|
||||
Process.Start(start);
|
||||
return 0;
|
||||
}
|
||||
catch (Win32Exception e) when (e.NativeErrorCode == 1223) { return 1; }
|
||||
}
|
||||
|
||||
using var identity = WindowsIdentity.GetCurrent();
|
||||
var suffix = identity.User!.Value;
|
||||
using var mutex = new Mutex(true, "Local\\GatewayKeepalive-" + suffix, out var created);
|
||||
using var show = new EventWaitHandle(false, EventResetMode.AutoReset, "Local\\GatewayKeepalive-Show-" + suffix);
|
||||
if (!created) { show.Set(); return 0; }
|
||||
var directory = Path.Combine(Environment.GetFolderPath(Environment.SpecialFolder.LocalApplicationData), "GatewayKeepalive");
|
||||
var logger = new FileLog(Path.Combine(directory, "logs"));
|
||||
try
|
||||
{
|
||||
var store = new JsonStore(directory);
|
||||
var startup = new LoginStartup();
|
||||
var probe = new NetworkHealthProbe(backend.ValidateRoute);
|
||||
var controller = new GatewayController(backend, probe, store, logger.Write, () => startup.IsEnabled, startup.SetEnabled);
|
||||
using var app = new TrayApplicationContext(controller, !controller.Settings.Configured || !args.Contains("--minimized"));
|
||||
using var dispatcher = new Control();
|
||||
_ = dispatcher.Handle;
|
||||
var wait = ThreadPool.RegisterWaitForSingleObject(show, (_, _) =>
|
||||
{
|
||||
try { if (!dispatcher.IsDisposed) dispatcher.BeginInvoke(() => ShowExisting(app)); }
|
||||
catch (InvalidOperationException) when (dispatcher.IsDisposed || dispatcher.Disposing) { }
|
||||
}, null, Timeout.Infinite, false);
|
||||
Application.ThreadException += (_, e) =>
|
||||
{
|
||||
logger.Write("UI异常:" + e.Exception);
|
||||
MessageBox.Show(e.Exception.Message, "网关保活", MessageBoxButtons.OK, MessageBoxIcon.Error);
|
||||
};
|
||||
controller.Start();
|
||||
try { Application.Run(app); }
|
||||
finally { wait.Unregister(null); controller.StopAsync().GetAwaiter().GetResult(); }
|
||||
return 0;
|
||||
}
|
||||
catch (Exception e)
|
||||
{
|
||||
try { logger.Write("启动/退出失败:" + e); } catch (Exception) { }
|
||||
MessageBox.Show("程序无法继续:" + e.Message + "\n配置与恢复记录保留在:\n" + directory, "网关保活", MessageBoxButtons.OK, MessageBoxIcon.Error);
|
||||
return 1;
|
||||
}
|
||||
}
|
||||
|
||||
private static void ShowExisting(TrayApplicationContext app) => app.ShowPanel();
|
||||
|
||||
/// <summary>
|
||||
/// Single-file apps extract the WinForms theming manifest to the temp directory. If that
|
||||
/// location is restricted, visual styles are decorative only for this fully custom-drawn UI;
|
||||
/// the tray must still start.
|
||||
/// </summary>
|
||||
private static void TryInitializeWinForms()
|
||||
{
|
||||
try { Application.SetHighDpiMode(HighDpiMode.PerMonitorV2); }
|
||||
catch (Exception e) when (e is InvalidOperationException or PlatformNotSupportedException) { }
|
||||
try { Application.EnableVisualStyles(); }
|
||||
catch (Exception e) when (e is UnauthorizedAccessException or IOException or InvalidOperationException or TypeInitializationException) { }
|
||||
try { Application.SetCompatibleTextRenderingDefault(false); }
|
||||
catch (InvalidOperationException) { }
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,794 @@
|
||||
using System.Diagnostics;
|
||||
using System.Drawing;
|
||||
using System.Drawing.Imaging;
|
||||
using System.Runtime.ExceptionServices;
|
||||
using System.Windows.Forms;
|
||||
using GatewayKeepalive.Core;
|
||||
using GatewayKeepalive.Tray.Ui;
|
||||
|
||||
namespace GatewayKeepalive.Tray;
|
||||
|
||||
public sealed class TrayApplicationContext : ApplicationContext
|
||||
{
|
||||
private readonly ITrayController _controller;
|
||||
private readonly SynchronizationContext? _initialContext;
|
||||
private readonly int _uiThreadId;
|
||||
private readonly Control _dispatcher;
|
||||
private readonly TrayIconFactory _icons = new();
|
||||
private readonly NotifyIcon _tray;
|
||||
private readonly ContextMenuStrip _menu;
|
||||
private readonly StatusPanelForm _panel;
|
||||
private readonly System.Windows.Forms.Timer _clockTimer;
|
||||
private ToolStripMenuItem _autoPolicyItem = null!;
|
||||
private ToolStripMenuItem _manualMenu = null!;
|
||||
private ToolStripMenuItem _manualDirectItem = null!;
|
||||
private ToolStripMenuItem _manualProxyItem = null!;
|
||||
private ToolStripMenuItem _refreshItem = null!;
|
||||
private ToolStripMenuItem _restoreItem = null!;
|
||||
private ToolStripMenuItem _autostartItem = null!;
|
||||
private ToolStripMenuItem _logsItem = null!;
|
||||
private ToolStripMenuItem _exitItem = null!;
|
||||
private readonly StatusNotificationTracker _statusNotifications = new();
|
||||
|
||||
private AppStatus _status = AppStatus.Initial;
|
||||
private SettingsForm? _settingsForm;
|
||||
private bool _busy;
|
||||
private bool _exitInProgress;
|
||||
private bool _disposed;
|
||||
|
||||
public TrayApplicationContext(ITrayController controller, bool showPanel = false)
|
||||
{
|
||||
ArgumentNullException.ThrowIfNull(controller);
|
||||
TryEnablePerMonitorV2();
|
||||
|
||||
_controller = controller;
|
||||
_uiThreadId = Environment.CurrentManagedThreadId;
|
||||
_initialContext = SynchronizationContext.Current;
|
||||
_dispatcher = new Control { Visible = false, Size = Size.Empty };
|
||||
_ = _dispatcher.Handle;
|
||||
|
||||
_panel = new StatusPanelForm();
|
||||
_panel.RefreshRequested += OnPanelRefresh;
|
||||
_panel.SettingsRequested += OnPanelSettings;
|
||||
_panel.LogsRequested += OnPanelLogs;
|
||||
_panel.PolicyRequested += OnPanelPolicy;
|
||||
|
||||
_menu = BuildMenu();
|
||||
_tray = new NotifyIcon
|
||||
{
|
||||
Visible = true,
|
||||
ContextMenuStrip = _menu,
|
||||
Icon = _icons.Get(AppStatus.Initial),
|
||||
Text = "网关保活"
|
||||
};
|
||||
_tray.MouseClick += OnTrayMouseClick;
|
||||
|
||||
_clockTimer = new System.Windows.Forms.Timer { Interval = 30_000 };
|
||||
_clockTimer.Tick += OnClockTick;
|
||||
_clockTimer.Start();
|
||||
|
||||
_controller.StatusChanged += OnStatusChanged;
|
||||
ApplyStatus(ReadStatus(AppStatus.Initial));
|
||||
|
||||
if (showPanel) ShowPanel();
|
||||
}
|
||||
|
||||
private ContextMenuStrip BuildMenu()
|
||||
{
|
||||
var menu = new ContextMenuStrip
|
||||
{
|
||||
Renderer = new ToolStripProfessionalRenderer(new DarkMenuColors()),
|
||||
BackColor = UiTheme.Card,
|
||||
ForeColor = UiTheme.Text,
|
||||
Font = UiTheme.Body,
|
||||
ShowImageMargin = false,
|
||||
ShowCheckMargin = true,
|
||||
Padding = new Padding(3)
|
||||
};
|
||||
menu.Opening += OnMenuOpening;
|
||||
|
||||
var open = new ToolStripMenuItem("打开面板") { Font = UiTheme.BodyBold };
|
||||
open.Click += (_, _) => ShowPanel();
|
||||
menu.Items.Add(open);
|
||||
|
||||
_refreshItem = new ToolStripMenuItem("立即检测");
|
||||
_refreshItem.Click += (_, _) => StartAction("检测失败", _controller.RefreshAsync);
|
||||
menu.Items.Add(_refreshItem);
|
||||
menu.Items.Add(new ToolStripSeparator());
|
||||
|
||||
_autoPolicyItem = new ToolStripMenuItem("自动保活");
|
||||
_autoPolicyItem.Click += (_, _) =>
|
||||
{
|
||||
var target = _status.Policy == ControlPolicy.Auto ? ControlPolicy.Paused : ControlPolicy.Auto;
|
||||
StartAction("自动保活设置失败", () => _controller.SetPolicyAsync(target));
|
||||
};
|
||||
menu.Items.Add(_autoPolicyItem);
|
||||
|
||||
_manualMenu = new ToolStripMenuItem("手动切换");
|
||||
_manualDirectItem = new ToolStripMenuItem("使用主路由(固定)");
|
||||
_manualDirectItem.ToolTipText = "明确进入固定模式,继续监测但不自动切换";
|
||||
_manualDirectItem.Click += (_, _) => StartAction("切换到主路由失败",
|
||||
() => _controller.SetPolicyAsync(ControlPolicy.ForceDirect));
|
||||
_manualMenu.DropDownItems.Add(_manualDirectItem);
|
||||
_manualProxyItem = new ToolStripMenuItem("使用旁路由(固定)");
|
||||
_manualProxyItem.ToolTipText = "明确进入固定模式,继续监测但不自动切换";
|
||||
_manualProxyItem.Click += (_, _) => StartAction("切换到旁路由失败",
|
||||
() => _controller.SetPolicyAsync(ControlPolicy.ForceProxy));
|
||||
_manualMenu.DropDownItems.Add(_manualProxyItem);
|
||||
menu.Items.Add(_manualMenu);
|
||||
menu.Items.Add(new ToolStripSeparator());
|
||||
|
||||
_restoreItem = new ToolStripMenuItem("恢复接管前配置…");
|
||||
_restoreItem.Click += (_, _) => RequestRestore();
|
||||
menu.Items.Add(_restoreItem);
|
||||
|
||||
_autostartItem = new ToolStripMenuItem("登录后自动启动");
|
||||
_autostartItem.Click += (_, _) => ToggleAutostart();
|
||||
menu.Items.Add(_autostartItem);
|
||||
|
||||
_logsItem = new ToolStripMenuItem("查看日志");
|
||||
_logsItem.Click += (_, _) => OpenLogs();
|
||||
menu.Items.Add(_logsItem);
|
||||
menu.Items.Add(new ToolStripSeparator());
|
||||
|
||||
_exitItem = new ToolStripMenuItem("退出");
|
||||
_exitItem.Click += (_, _) => _ = ExitAsync();
|
||||
menu.Items.Add(_exitItem);
|
||||
|
||||
foreach (ToolStripItem item in menu.Items) item.ForeColor = UiTheme.Text;
|
||||
foreach (ToolStripItem item in _manualMenu.DropDownItems) item.ForeColor = UiTheme.Text;
|
||||
return menu;
|
||||
}
|
||||
|
||||
private void OnTrayMouseClick(object? sender, MouseEventArgs e)
|
||||
{
|
||||
if (e.Button != MouseButtons.Left) return;
|
||||
if (_panel.Visible)
|
||||
{
|
||||
_panel.HidePanel();
|
||||
return;
|
||||
}
|
||||
|
||||
// Deactivate runs just before the tray click and has already fulfilled the close gesture.
|
||||
if (_panel.LastAutoHideAt is { } hiddenAt && DateTime.UtcNow - hiddenAt < TimeSpan.FromMilliseconds(350))
|
||||
return;
|
||||
ShowPanel();
|
||||
}
|
||||
|
||||
public void ShowPanel()
|
||||
{
|
||||
if (_disposed || _exitInProgress) return;
|
||||
_panel.ApplyStatus(_status);
|
||||
_panel.ShowNearTray();
|
||||
}
|
||||
|
||||
private void OnStatusChanged(AppStatus status)
|
||||
{
|
||||
if (status is null) return;
|
||||
Post(() =>
|
||||
{
|
||||
if (!_disposed) ApplyStatus(status);
|
||||
});
|
||||
}
|
||||
|
||||
private void ApplyStatus(AppStatus status)
|
||||
{
|
||||
if (_disposed) return;
|
||||
_status = status;
|
||||
_panel.ApplyStatus(status);
|
||||
RefreshTrayPresentation();
|
||||
var notifications = NotificationsEnabled();
|
||||
foreach (var notification in _statusNotifications.Observe(status))
|
||||
{
|
||||
if (notifications)
|
||||
ShowStatusBalloon(notification.Title, notification.Message, notification.Icon);
|
||||
}
|
||||
}
|
||||
|
||||
private bool NotificationsEnabled()
|
||||
{
|
||||
try { return _controller.Settings.Notifications; }
|
||||
catch (Exception) { return false; }
|
||||
}
|
||||
|
||||
private void ShowStatusBalloon(string title, string message, ToolTipIcon icon)
|
||||
{
|
||||
if (_disposed) return;
|
||||
try { _tray.ShowBalloonTip(6000, UiTheme.Clip(title, 60), message, icon); }
|
||||
catch (Exception balloonException) { _ = balloonException; }
|
||||
}
|
||||
|
||||
private void RefreshTrayPresentation()
|
||||
{
|
||||
if (_disposed) return;
|
||||
try
|
||||
{
|
||||
_tray.Icon = _icons.Get(_status);
|
||||
var path = _status.ActivePath != NetworkMode.Initializing ? _status.ActivePath : _status.Mode;
|
||||
var route = path switch
|
||||
{
|
||||
NetworkMode.Proxy => "旁路由",
|
||||
NetworkMode.Direct => "主路由",
|
||||
NetworkMode.ExternalConfiguration => "外部网络",
|
||||
NetworkMode.Switching => "切换中",
|
||||
NetworkMode.Disconnected => "未连接",
|
||||
_ => "等待接管"
|
||||
};
|
||||
var text = $"网关保活 · {route} · {UiTheme.PolicyName(_status.Policy)}";
|
||||
if (!string.IsNullOrWhiteSpace(_status.Network?.Gateway)) text += " · " + _status.Network.Gateway;
|
||||
_tray.Text = UiTheme.Clip(text, 60);
|
||||
}
|
||||
catch (Exception exception)
|
||||
{
|
||||
_tray.Text = "网关保活";
|
||||
_ = exception;
|
||||
}
|
||||
}
|
||||
|
||||
private void OnClockTick(object? sender, EventArgs e)
|
||||
{
|
||||
if (_disposed) return;
|
||||
_panel.RefreshClock();
|
||||
}
|
||||
|
||||
private void OnMenuOpening(object? sender, System.ComponentModel.CancelEventArgs e)
|
||||
{
|
||||
if (_disposed)
|
||||
{
|
||||
e.Cancel = true;
|
||||
return;
|
||||
}
|
||||
var actionsEnabled = !_busy && !_exitInProgress;
|
||||
_autoPolicyItem.Enabled = actionsEnabled;
|
||||
_manualMenu.Enabled = actionsEnabled;
|
||||
_refreshItem.Enabled = actionsEnabled;
|
||||
_restoreItem.Enabled = actionsEnabled;
|
||||
_autostartItem.Enabled = actionsEnabled;
|
||||
_logsItem.Enabled = !_exitInProgress;
|
||||
_exitItem.Enabled = !_exitInProgress;
|
||||
_autoPolicyItem.Checked = _status.Policy == ControlPolicy.Auto;
|
||||
_manualDirectItem.Checked = _status.Policy == ControlPolicy.ForceDirect;
|
||||
_manualProxyItem.Checked = _status.Policy == ControlPolicy.ForceProxy;
|
||||
|
||||
try
|
||||
{
|
||||
_autostartItem.Checked = _controller.AutostartEnabled;
|
||||
_autostartItem.ToolTipText = _autostartItem.Checked ? "当前已设置登录后启动" : "当前未设置登录后启动";
|
||||
}
|
||||
catch (Exception exception)
|
||||
{
|
||||
_autostartItem.Checked = false;
|
||||
_autostartItem.ToolTipText = UiTheme.ExceptionText(exception);
|
||||
}
|
||||
}
|
||||
|
||||
private void OnPanelRefresh(object? sender, EventArgs e) => StartAction("检测失败", _controller.RefreshAsync);
|
||||
|
||||
private void OnPanelPolicy(ControlPolicy policy) =>
|
||||
StartAction("切换模式失败", () => _controller.SetPolicyAsync(policy));
|
||||
|
||||
private void OnPanelSettings(object? sender, EventArgs e) => _ = OpenSettingsAsync();
|
||||
|
||||
private void OnPanelLogs(object? sender, EventArgs e) => OpenLogs();
|
||||
|
||||
private void RequestRestore()
|
||||
{
|
||||
if (_busy || _exitInProgress) return;
|
||||
var answer = ShowOwnedMessageBox(
|
||||
"恢复接管前的网关、DNS 和路由配置?\n\n恢复成功后会自动暂停保活,避免立刻再次切换。",
|
||||
"恢复网络配置", MessageBoxButtons.YesNo, MessageBoxIcon.Question, MessageBoxDefaultButton.Button2);
|
||||
if (answer == DialogResult.Yes)
|
||||
StartAction("恢复网络失败", _controller.RestoreAsync);
|
||||
}
|
||||
|
||||
private void StartAction(string errorTitle, Func<Task> action)
|
||||
{
|
||||
_ = RunActionAsync(errorTitle, action);
|
||||
}
|
||||
|
||||
private async Task RunActionAsync(string errorTitle, Func<Task> action)
|
||||
{
|
||||
if (_disposed || _exitInProgress) return;
|
||||
if (_busy)
|
||||
{
|
||||
_panel.ShowNotice("上一项操作尚未完成,请稍候。", false);
|
||||
return;
|
||||
}
|
||||
|
||||
_busy = true;
|
||||
_panel.SetBusy(true);
|
||||
try
|
||||
{
|
||||
await action();
|
||||
if (!_disposed && !_exitInProgress) ApplyStatus(ReadStatus(_status));
|
||||
}
|
||||
catch (Exception exception)
|
||||
{
|
||||
if (!_disposed)
|
||||
{
|
||||
var message = UiTheme.ExceptionText(exception);
|
||||
ApplyStatus(ReadStatus(_status));
|
||||
ReportError(errorTitle, message);
|
||||
}
|
||||
}
|
||||
finally
|
||||
{
|
||||
if (!_disposed)
|
||||
{
|
||||
_busy = false;
|
||||
_panel.SetBusy(false);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private async Task OpenSettingsAsync()
|
||||
{
|
||||
if (_disposed || _exitInProgress) return;
|
||||
if (_busy)
|
||||
{
|
||||
_panel.ShowNotice("上一项操作尚未完成,请稍候再打开设置。", false);
|
||||
return;
|
||||
}
|
||||
|
||||
_busy = true;
|
||||
_panel.SetBusy(true);
|
||||
var previousAutoHide = _panel.AllowAutoHide;
|
||||
_panel.AllowAutoHide = false;
|
||||
try
|
||||
{
|
||||
IReadOnlyList<AdapterInfo> adapters;
|
||||
AppSettings settings;
|
||||
try
|
||||
{
|
||||
adapters = await _controller.ListAdaptersAsync();
|
||||
settings = _controller.Settings;
|
||||
}
|
||||
catch (Exception exception)
|
||||
{
|
||||
ReportError("无法打开设置", UiTheme.ExceptionText(exception));
|
||||
return;
|
||||
}
|
||||
|
||||
using var form = new SettingsForm(_controller, settings, adapters);
|
||||
_settingsForm = form;
|
||||
var owner = _panel.Visible ? _panel : null;
|
||||
form.ShowDialog(owner);
|
||||
ApplyStatus(ReadStatus(_status));
|
||||
}
|
||||
catch (Exception exception)
|
||||
{
|
||||
ReportError("设置窗口失败", UiTheme.ExceptionText(exception));
|
||||
}
|
||||
finally
|
||||
{
|
||||
_settingsForm = null;
|
||||
_panel.AllowAutoHide = previousAutoHide;
|
||||
if (!_disposed)
|
||||
{
|
||||
_busy = false;
|
||||
_panel.SetBusy(false);
|
||||
if (_panel.Visible) _panel.Activate();
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private void ToggleAutostart()
|
||||
{
|
||||
if (_busy || _exitInProgress) return;
|
||||
bool previous;
|
||||
try
|
||||
{
|
||||
previous = _controller.AutostartEnabled;
|
||||
}
|
||||
catch (Exception exception)
|
||||
{
|
||||
ReportError("开机自启设置失败", UiTheme.ExceptionText(exception));
|
||||
return;
|
||||
}
|
||||
|
||||
var wanted = !previous;
|
||||
try
|
||||
{
|
||||
_controller.SetAutostart(wanted);
|
||||
var actual = _controller.AutostartEnabled;
|
||||
if (actual != wanted)
|
||||
throw new InvalidOperationException("设置调用返回后,系统状态没有变成预期值。");
|
||||
_autostartItem.Checked = actual;
|
||||
_autostartItem.ToolTipText = actual ? "当前已设置登录后启动" : "当前未设置登录后启动";
|
||||
}
|
||||
catch (Exception exception)
|
||||
{
|
||||
var rollback = string.Empty;
|
||||
try
|
||||
{
|
||||
if (_controller.AutostartEnabled != previous) _controller.SetAutostart(previous);
|
||||
if (_controller.AutostartEnabled != previous)
|
||||
rollback = " 回滚后系统状态仍未恢复到原设置,请检查任务计划程序。";
|
||||
else
|
||||
rollback = " 已回滚到原设置。";
|
||||
}
|
||||
catch (Exception rollbackException)
|
||||
{
|
||||
rollback = " 回滚失败:" + UiTheme.ExceptionText(rollbackException);
|
||||
}
|
||||
|
||||
try
|
||||
{
|
||||
_autostartItem.Checked = _controller.AutostartEnabled;
|
||||
}
|
||||
catch
|
||||
{
|
||||
_autostartItem.Checked = previous;
|
||||
}
|
||||
ReportError("开机自启设置失败", UiTheme.ExceptionText(exception) + rollback);
|
||||
}
|
||||
}
|
||||
|
||||
private void OpenLogs()
|
||||
{
|
||||
if (_disposed) return;
|
||||
try
|
||||
{
|
||||
var dataDirectory = _controller.DataDirectory;
|
||||
if (string.IsNullOrWhiteSpace(dataDirectory)) throw new InvalidOperationException("日志目录尚未配置。");
|
||||
var directory = Path.Combine(dataDirectory, "logs");
|
||||
Directory.CreateDirectory(directory);
|
||||
Process.Start(new ProcessStartInfo { FileName = directory, UseShellExecute = true });
|
||||
}
|
||||
catch (Exception exception)
|
||||
{
|
||||
ReportError("无法打开日志", UiTheme.ExceptionText(exception));
|
||||
}
|
||||
}
|
||||
|
||||
private async Task ExitAsync()
|
||||
{
|
||||
if (_disposed || _exitInProgress) return;
|
||||
if (_settingsForm is not null)
|
||||
{
|
||||
ReportError("暂时无法退出", "请先关闭设置窗口,再退出程序。");
|
||||
return;
|
||||
}
|
||||
if (_busy)
|
||||
{
|
||||
ReportError("暂时无法退出", "当前网络操作尚未完成。请稍候再退出,程序不会在操作中途强制结束。");
|
||||
return;
|
||||
}
|
||||
|
||||
_exitInProgress = true;
|
||||
_busy = true;
|
||||
_panel.SetBusy(true);
|
||||
try
|
||||
{
|
||||
await _controller.StopAsync();
|
||||
if (_disposed) return;
|
||||
_panel.HidePanel();
|
||||
Dispose();
|
||||
ExitThread();
|
||||
}
|
||||
catch (Exception exception)
|
||||
{
|
||||
if (_disposed) return;
|
||||
_exitInProgress = false;
|
||||
_busy = false;
|
||||
_panel.SetBusy(false);
|
||||
var message = "停止保活失败,程序仍在运行,没有强制退出。\n\n" + UiTheme.ExceptionText(exception);
|
||||
_panel.ShowNotice(message, true);
|
||||
if (!_panel.Visible) _panel.ShowNearTray();
|
||||
ShowOwnedMessageBox(message, "退出未完成", MessageBoxButtons.OK, MessageBoxIcon.Error);
|
||||
}
|
||||
}
|
||||
|
||||
private AppStatus ReadStatus(AppStatus fallback)
|
||||
{
|
||||
try
|
||||
{
|
||||
return _controller.Status;
|
||||
}
|
||||
catch (Exception exception)
|
||||
{
|
||||
return fallback with { Message = UiTheme.ExceptionText(exception) };
|
||||
}
|
||||
}
|
||||
|
||||
private void ReportError(string title, string message)
|
||||
{
|
||||
if (_disposed) return;
|
||||
_panel.ShowNotice(message, true);
|
||||
if (_panel.Visible)
|
||||
{
|
||||
_panel.Activate();
|
||||
return;
|
||||
}
|
||||
try { _tray.ShowBalloonTip(6000, title, message, ToolTipIcon.Error); }
|
||||
catch (Exception balloonException) { _ = balloonException; }
|
||||
}
|
||||
|
||||
private DialogResult ShowOwnedMessageBox(string text, string title, MessageBoxButtons buttons,
|
||||
MessageBoxIcon icon, MessageBoxDefaultButton defaultButton = MessageBoxDefaultButton.Button1)
|
||||
{
|
||||
var previous = _panel.AllowAutoHide;
|
||||
_panel.AllowAutoHide = false;
|
||||
try
|
||||
{
|
||||
var owner = _panel.Visible ? _panel : null;
|
||||
return MessageBox.Show(owner, text, title, buttons, icon, defaultButton);
|
||||
}
|
||||
finally
|
||||
{
|
||||
_panel.AllowAutoHide = previous;
|
||||
if (_panel.Visible && !_disposed) _panel.Activate();
|
||||
}
|
||||
}
|
||||
|
||||
private void Post(Action action)
|
||||
{
|
||||
if (_disposed) return;
|
||||
if (Environment.CurrentManagedThreadId == _uiThreadId)
|
||||
{
|
||||
action();
|
||||
return;
|
||||
}
|
||||
|
||||
void SafeInvoke()
|
||||
{
|
||||
if (!_disposed) action();
|
||||
}
|
||||
|
||||
try
|
||||
{
|
||||
if (_dispatcher.IsHandleCreated)
|
||||
_dispatcher.BeginInvoke((Action)SafeInvoke);
|
||||
else if (_initialContext is not null)
|
||||
_initialContext.Post(_ => SafeInvoke(), null);
|
||||
else
|
||||
throw new InvalidOperationException("界面消息泵尚未初始化。");
|
||||
}
|
||||
catch (ObjectDisposedException) { }
|
||||
catch (InvalidOperationException)
|
||||
{
|
||||
try { _initialContext?.Post(_ => SafeInvoke(), null); }
|
||||
catch (InvalidOperationException) { }
|
||||
}
|
||||
}
|
||||
|
||||
private void TryEnablePerMonitorV2()
|
||||
{
|
||||
try { Application.SetHighDpiMode(HighDpiMode.PerMonitorV2); }
|
||||
catch (InvalidOperationException) { }
|
||||
}
|
||||
|
||||
private void Cleanup()
|
||||
{
|
||||
if (_disposed) return;
|
||||
_disposed = true;
|
||||
|
||||
try { _controller.StatusChanged -= OnStatusChanged; }
|
||||
catch (Exception cleanupException) { _ = cleanupException; }
|
||||
|
||||
_clockTimer.Stop();
|
||||
_clockTimer.Tick -= OnClockTick;
|
||||
_clockTimer.Dispose();
|
||||
|
||||
_panel.RefreshRequested -= OnPanelRefresh;
|
||||
_panel.SettingsRequested -= OnPanelSettings;
|
||||
_panel.LogsRequested -= OnPanelLogs;
|
||||
_panel.PolicyRequested -= OnPanelPolicy;
|
||||
_panel.Dispose();
|
||||
|
||||
_tray.Visible = false;
|
||||
_tray.MouseClick -= OnTrayMouseClick;
|
||||
_tray.ContextMenuStrip = null;
|
||||
_tray.Dispose();
|
||||
_menu.Opening -= OnMenuOpening;
|
||||
_menu.Dispose();
|
||||
_icons.Dispose();
|
||||
_dispatcher.Dispose();
|
||||
}
|
||||
|
||||
protected override void Dispose(bool disposing)
|
||||
{
|
||||
if (disposing) Cleanup();
|
||||
base.Dispose(disposing);
|
||||
}
|
||||
|
||||
public static void RenderPreview(string directory)
|
||||
{
|
||||
if (string.IsNullOrWhiteSpace(directory))
|
||||
throw new ArgumentException("必须提供预览图片输出目录。", nameof(directory));
|
||||
Directory.CreateDirectory(directory);
|
||||
|
||||
Exception? failure = null;
|
||||
var render = () =>
|
||||
{
|
||||
try
|
||||
{
|
||||
try { Application.SetHighDpiMode(HighDpiMode.PerMonitorV2); }
|
||||
catch (InvalidOperationException) { }
|
||||
try { Application.EnableVisualStyles(); }
|
||||
catch (Exception e) when (e is UnauthorizedAccessException or IOException or InvalidOperationException or TypeInitializationException) { }
|
||||
|
||||
var states = new[]
|
||||
{
|
||||
("gateway-panel-healthy-proxy.png", CreateHealthyProxyPreview()),
|
||||
("gateway-panel-fault-direct.png", CreateFaultDirectPreview()),
|
||||
("gateway-panel-paused-initial.png", CreatePausedInitialPreview())
|
||||
};
|
||||
foreach (var (fileName, state) in states)
|
||||
{
|
||||
using var panel = new StatusPanelForm();
|
||||
panel.ApplyStatus(state);
|
||||
RenderForm(panel, Path.Combine(directory, fileName));
|
||||
}
|
||||
|
||||
using (var expandedPanel = new StatusPanelForm())
|
||||
{
|
||||
expandedPanel.ApplyStatus(CreateHealthyProxyPreview());
|
||||
expandedPanel.SetDiagnosticsExpanded(true);
|
||||
RenderForm(expandedPanel, Path.Combine(directory, "gateway-panel-diagnostics-expanded.png"));
|
||||
}
|
||||
|
||||
var adapter = PreviewAdapter();
|
||||
var settings = PreviewSettings(adapter);
|
||||
using var settingsForm = new SettingsForm(new PreviewController(settings, adapter), settings, [adapter]);
|
||||
RenderForm(settingsForm, Path.Combine(directory, "gateway-settings-preview.png"));
|
||||
settingsForm.SelectPageForPreview(1);
|
||||
RenderForm(settingsForm, Path.Combine(directory, "gateway-settings-rules.png"));
|
||||
settingsForm.SelectPageForPreview(2);
|
||||
RenderForm(settingsForm, Path.Combine(directory, "gateway-settings-detection.png"));
|
||||
settingsForm.ScrollPageForPreview(2, 10000);
|
||||
RenderForm(settingsForm, Path.Combine(directory, "gateway-settings-detection-scrolled.png"));
|
||||
|
||||
File.Copy(Path.Combine(directory, "gateway-panel-healthy-proxy.png"),
|
||||
Path.Combine(directory, "gateway-panel-preview.png"), overwrite: true);
|
||||
File.Copy(Path.Combine(directory, "gateway-settings-preview.png"),
|
||||
Path.Combine(directory, "settings-preview.png"), overwrite: true);
|
||||
}
|
||||
catch (Exception exception)
|
||||
{
|
||||
failure = exception;
|
||||
}
|
||||
};
|
||||
|
||||
if (Thread.CurrentThread.GetApartmentState() == ApartmentState.STA)
|
||||
{
|
||||
render();
|
||||
}
|
||||
else
|
||||
{
|
||||
var thread = new Thread(() => render()) { IsBackground = true, Name = "GatewayKeepalive.Preview" };
|
||||
thread.SetApartmentState(ApartmentState.STA);
|
||||
thread.Start();
|
||||
thread.Join();
|
||||
}
|
||||
|
||||
if (failure is not null) ExceptionDispatchInfo.Capture(failure).Throw();
|
||||
}
|
||||
|
||||
private static void RenderForm(Form form, string path)
|
||||
{
|
||||
form.StartPosition = FormStartPosition.Manual;
|
||||
form.Location = new Point(-32000, -32000);
|
||||
form.Show();
|
||||
Application.DoEvents();
|
||||
form.PerformLayout();
|
||||
using var bitmap = new Bitmap(form.Width, form.Height, PixelFormat.Format32bppArgb);
|
||||
form.DrawToBitmap(bitmap, new Rectangle(Point.Empty, form.Size));
|
||||
form.Hide();
|
||||
bitmap.Save(path, ImageFormat.Png);
|
||||
}
|
||||
|
||||
private static AppStatus CreateHealthyProxyPreview()
|
||||
{
|
||||
var now = DateTimeOffset.Now;
|
||||
var adapter = PreviewAdapter();
|
||||
var routes = new[]
|
||||
{
|
||||
new RouteEntry("192.168.6.2", 1, 12, 12),
|
||||
new RouteEntry("192.168.6.1", 101, 12, 12)
|
||||
};
|
||||
var dns = new DnsConfiguration(false, ["192.168.6.2"], []);
|
||||
var network = new NetworkSnapshot(adapter, routes, dns, ["192.168.6.2"], now);
|
||||
var checks = new[]
|
||||
{
|
||||
new ProbeResult("旁路由连接", true, 2, "192.168.6.2:1070 TCP 可达"),
|
||||
new ProbeResult("旁路由 DNS", true, 12, "指定 DNS 查询成功"),
|
||||
new ProbeResult("SOCKS5", true, 45, "握手与 CONNECT 通过"),
|
||||
new ProbeResult("当前出口", true, 220, "TLS 验证通过")
|
||||
};
|
||||
var health = new HealthReport(checks, true, true, now);
|
||||
return new AppStatus(NetworkMode.Proxy, ControlPolicy.Auto, network, health, 3, 0,
|
||||
"检测正常", now.AddMinutes(-2))
|
||||
{
|
||||
ActivePath = NetworkMode.Proxy,
|
||||
LastEvent = "旁路由已恢复,自动切回",
|
||||
LastEventAt = now.AddMinutes(-2)
|
||||
};
|
||||
}
|
||||
|
||||
private static AppStatus CreateFaultDirectPreview()
|
||||
{
|
||||
var now = DateTimeOffset.Now;
|
||||
var adapter = PreviewAdapter();
|
||||
var network = new NetworkSnapshot(adapter,
|
||||
[new RouteEntry("192.168.6.1", 1, 12, 12)],
|
||||
new DnsConfiguration(true, [], []), ["192.168.6.1"], now);
|
||||
var checks = new[]
|
||||
{
|
||||
new ProbeResult("旁路由连接", false, 2500, "192.168.6.2 无法建立探测连接"),
|
||||
new ProbeResult("旁路由 DNS", false, 1200, "未收到有效的 DNS 响应"),
|
||||
new ProbeResult("SOCKS5", false, 2500, "192.168.6.2:1070 连接超时"),
|
||||
new ProbeResult("当前出口", true, 180, "TLS 验证通过 · 当前走主路由")
|
||||
};
|
||||
var health = new HealthReport(checks, false, true, now);
|
||||
return new AppStatus(NetworkMode.Degraded, ControlPolicy.Auto, network, health, 0, 3,
|
||||
"旁路由候选不可用", now.AddSeconds(-45))
|
||||
{
|
||||
ActivePath = NetworkMode.Direct,
|
||||
LastEvent = "旁路由连续失败,已切回主路由",
|
||||
LastEventAt = now.AddSeconds(-45)
|
||||
};
|
||||
}
|
||||
|
||||
private static AppStatus CreatePausedInitialPreview() => new(
|
||||
NetworkMode.Initializing, ControlPolicy.Paused, null, null, 0, 0,
|
||||
"请选择网卡并保存配置")
|
||||
{
|
||||
ActivePath = NetworkMode.Initializing,
|
||||
LastEvent = null,
|
||||
LastEventAt = null
|
||||
};
|
||||
|
||||
private static AdapterInfo PreviewAdapter() => new(
|
||||
Guid.Parse("4c0d9b61-6f43-4f6f-8d07-d8c2867d7f10"),
|
||||
12, "Ethernet", true, [new Ipv4Address("192.168.6.100", 24)], 25, "preview-network");
|
||||
|
||||
private static AppSettings PreviewSettings(AdapterInfo adapter) => new()
|
||||
{
|
||||
Configured = true,
|
||||
AdapterId = adapter.Id,
|
||||
DirectGateway = "192.168.6.1",
|
||||
ProxyGateway = "192.168.6.2",
|
||||
ProxyDns = "192.168.6.2",
|
||||
PreserveOriginalDirectDns = true,
|
||||
TestHosts = ["www.google.com", "cloudflare.com"],
|
||||
RequiredHealthyTargets = 1,
|
||||
DirectTestHosts = ["www.baidu.com", "www.microsoft.com"],
|
||||
RequiredDirectHealthyTargets = 1,
|
||||
Policy = ControlPolicy.Auto
|
||||
};
|
||||
|
||||
private sealed class PreviewController(AppSettings settings, AdapterInfo adapter) : ITrayController
|
||||
{
|
||||
public AppSettings Settings => settings;
|
||||
public AppStatus Status => CreatePausedInitialPreview();
|
||||
public string DataDirectory => Path.GetTempPath();
|
||||
public bool AutostartEnabled => false;
|
||||
public event Action<AppStatus>? StatusChanged { add { } remove { } }
|
||||
public Task<IReadOnlyList<AdapterInfo>> ListAdaptersAsync() => Task.FromResult<IReadOnlyList<AdapterInfo>>([adapter]);
|
||||
public Task RefreshAsync() => Task.CompletedTask;
|
||||
public Task SetPolicyAsync(ControlPolicy policy) => Task.CompletedTask;
|
||||
public Task SaveSettingsAsync(AppSettings value) => Task.CompletedTask;
|
||||
public Task RestoreAsync() => Task.CompletedTask;
|
||||
public Task StopAsync() => Task.CompletedTask;
|
||||
public void SetAutostart(bool enabled) { }
|
||||
}
|
||||
|
||||
private sealed class DarkMenuColors : ProfessionalColorTable
|
||||
{
|
||||
public override Color ToolStripDropDownBackground => UiTheme.Card;
|
||||
public override Color ImageMarginGradientBegin => UiTheme.Card;
|
||||
public override Color ImageMarginGradientMiddle => UiTheme.Card;
|
||||
public override Color ImageMarginGradientEnd => UiTheme.Card;
|
||||
public override Color MenuBorder => UiTheme.Border;
|
||||
public override Color MenuItemBorder => UiTheme.Accent;
|
||||
public override Color MenuItemSelected => UiTheme.CardHover;
|
||||
public override Color MenuItemSelectedGradientBegin => UiTheme.CardHover;
|
||||
public override Color MenuItemSelectedGradientEnd => UiTheme.CardHover;
|
||||
public override Color SeparatorDark => UiTheme.Border;
|
||||
public override Color SeparatorLight => UiTheme.BorderSoft;
|
||||
public override Color CheckBackground => UiTheme.CardHover;
|
||||
public override Color CheckSelectedBackground => UiTheme.CardPressed;
|
||||
public override Color CheckPressedBackground => UiTheme.CardPressed;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,945 @@
|
||||
using System.ComponentModel;
|
||||
using System.Drawing;
|
||||
using System.Drawing.Drawing2D;
|
||||
using System.Runtime.InteropServices;
|
||||
using System.Windows.Forms;
|
||||
|
||||
namespace GatewayKeepalive.Tray.Ui;
|
||||
|
||||
internal sealed class PanelChip : Control
|
||||
{
|
||||
private bool _hovered;
|
||||
private bool _pressed;
|
||||
private Color _accent = UiTheme.Accent;
|
||||
|
||||
public PanelChip()
|
||||
{
|
||||
SetStyle(ControlStyles.UserPaint | ControlStyles.AllPaintingInWmPaint |
|
||||
ControlStyles.OptimizedDoubleBuffer | ControlStyles.ResizeRedraw |
|
||||
ControlStyles.Selectable, true);
|
||||
TabStop = true;
|
||||
Cursor = Cursors.Hand;
|
||||
AccessibleRole = AccessibleRole.PushButton;
|
||||
Font = UiTheme.Body;
|
||||
}
|
||||
|
||||
[Browsable(false)]
|
||||
[DesignerSerializationVisibility(DesignerSerializationVisibility.Hidden)]
|
||||
public bool Selected { get; set; }
|
||||
|
||||
[Browsable(false)]
|
||||
[DesignerSerializationVisibility(DesignerSerializationVisibility.Hidden)]
|
||||
public bool Emphasized { get; set; }
|
||||
|
||||
[Browsable(false)]
|
||||
[DesignerSerializationVisibility(DesignerSerializationVisibility.Hidden)]
|
||||
public bool Ghost { get; set; }
|
||||
|
||||
[Browsable(false)]
|
||||
[DesignerSerializationVisibility(DesignerSerializationVisibility.Hidden)]
|
||||
public int CornerRadiusDip { get; set; } = 8;
|
||||
|
||||
[Browsable(false)]
|
||||
[DesignerSerializationVisibility(DesignerSerializationVisibility.Hidden)]
|
||||
public Color AccentColor
|
||||
{
|
||||
get => _accent;
|
||||
set { _accent = value; Invalidate(); }
|
||||
}
|
||||
|
||||
public void PerformClick()
|
||||
{
|
||||
if (!Enabled) return;
|
||||
Focus();
|
||||
OnClick(EventArgs.Empty);
|
||||
}
|
||||
|
||||
protected override void OnPaint(PaintEventArgs e)
|
||||
{
|
||||
base.OnPaint(e);
|
||||
var graphics = e.Graphics;
|
||||
UiTheme.Configure(graphics);
|
||||
|
||||
var bounds = new Rectangle(0, 0, Math.Max(1, Width - 1), Math.Max(1, Height - 1));
|
||||
var radius = UiTheme.S(this, CornerRadiusDip);
|
||||
var fill = Ghost && !_hovered && !_pressed && !Selected && !Emphasized
|
||||
? UiTheme.Background
|
||||
: Emphasized
|
||||
? UiTheme.WithAlpha(AccentColor, 54)
|
||||
: Selected
|
||||
? UiTheme.WithAlpha(AccentColor, 38)
|
||||
: _pressed ? UiTheme.CardPressed : _hovered ? UiTheme.CardHover : UiTheme.Card;
|
||||
var border = Ghost && !_hovered && !Selected && !Emphasized
|
||||
? UiTheme.Background
|
||||
: Selected || Emphasized
|
||||
? UiTheme.WithAlpha(AccentColor, 165)
|
||||
: _hovered ? UiTheme.Border : UiTheme.BorderSoft;
|
||||
|
||||
UiTheme.FillRounded(graphics, bounds, radius, fill);
|
||||
UiTheme.StrokeRounded(graphics, bounds, radius, border, UiTheme.SF(this, 1f));
|
||||
|
||||
var foreground = !Enabled ? UiTheme.Faint
|
||||
: Selected || Emphasized ? AccentColor
|
||||
: Ghost ? UiTheme.Muted : UiTheme.Text;
|
||||
UiTheme.DrawText(graphics, Text, Font ?? UiTheme.Body, foreground,
|
||||
Rectangle.Inflate(bounds, -UiTheme.S(this, 5), 0),
|
||||
TextFormatFlags.HorizontalCenter | TextFormatFlags.VerticalCenter |
|
||||
TextFormatFlags.SingleLine | TextFormatFlags.EndEllipsis);
|
||||
|
||||
if (Focused && ShowFocusCues)
|
||||
{
|
||||
var focus = Rectangle.Inflate(bounds, -UiTheme.S(this, 3), -UiTheme.S(this, 3));
|
||||
ControlPaint.DrawFocusRectangle(graphics, focus, foreground, Color.Transparent);
|
||||
}
|
||||
}
|
||||
|
||||
protected override void OnMouseEnter(EventArgs e)
|
||||
{
|
||||
_hovered = true;
|
||||
Invalidate();
|
||||
base.OnMouseEnter(e);
|
||||
}
|
||||
|
||||
protected override void OnMouseLeave(EventArgs e)
|
||||
{
|
||||
_hovered = false;
|
||||
if (_pressed)
|
||||
{
|
||||
_pressed = false;
|
||||
Capture = false;
|
||||
}
|
||||
Invalidate();
|
||||
base.OnMouseLeave(e);
|
||||
}
|
||||
|
||||
protected override void OnMouseDown(MouseEventArgs e)
|
||||
{
|
||||
base.OnMouseDown(e);
|
||||
if (e.Button != MouseButtons.Left || !Enabled) return;
|
||||
_pressed = true;
|
||||
Capture = true;
|
||||
Focus();
|
||||
Invalidate();
|
||||
}
|
||||
|
||||
protected override void OnMouseUp(MouseEventArgs e)
|
||||
{
|
||||
base.OnMouseUp(e);
|
||||
if (e.Button != MouseButtons.Left || !_pressed) return;
|
||||
_pressed = false;
|
||||
Capture = false;
|
||||
Invalidate();
|
||||
if (ClientRectangle.Contains(e.Location) && Enabled) OnClick(EventArgs.Empty);
|
||||
}
|
||||
|
||||
protected override bool IsInputKey(Keys keyData) =>
|
||||
keyData is Keys.Enter or Keys.Space || base.IsInputKey(keyData);
|
||||
|
||||
protected override void OnKeyDown(KeyEventArgs e)
|
||||
{
|
||||
if (e.KeyCode is Keys.Enter or Keys.Space)
|
||||
{
|
||||
PerformClick();
|
||||
e.Handled = true;
|
||||
return;
|
||||
}
|
||||
base.OnKeyDown(e);
|
||||
}
|
||||
|
||||
protected override void OnEnabledChanged(EventArgs e)
|
||||
{
|
||||
Cursor = Enabled ? Cursors.Hand : Cursors.Default;
|
||||
Invalidate();
|
||||
base.OnEnabledChanged(e);
|
||||
}
|
||||
}
|
||||
|
||||
internal sealed class ThemeButton : Button
|
||||
{
|
||||
private readonly bool _primary;
|
||||
|
||||
public ThemeButton(bool primary)
|
||||
{
|
||||
_primary = primary;
|
||||
AutoSize = true;
|
||||
AutoSizeMode = AutoSizeMode.GrowAndShrink;
|
||||
MinimumSize = new Size(82, 36);
|
||||
Padding = new Padding(14, 4, 14, 4);
|
||||
Margin = new Padding(6, 4, 0, 4);
|
||||
FlatStyle = FlatStyle.Flat;
|
||||
FlatAppearance.BorderSize = 1;
|
||||
FlatAppearance.BorderColor = primary ? UiTheme.Accent : UiTheme.Border;
|
||||
FlatAppearance.MouseOverBackColor = primary ? Color.FromArgb(0x5D, 0xE2, 0xBD) : UiTheme.CardHover;
|
||||
FlatAppearance.MouseDownBackColor = primary ? Color.FromArgb(0x3D, 0xBE, 0x9A) : UiTheme.CardPressed;
|
||||
BackColor = primary ? UiTheme.Accent : UiTheme.Card;
|
||||
ForeColor = primary ? UiTheme.Background : UiTheme.Text;
|
||||
Font = UiTheme.BodyBold;
|
||||
UseVisualStyleBackColor = false;
|
||||
UseMnemonic = false;
|
||||
TextAlign = ContentAlignment.MiddleCenter;
|
||||
Cursor = Cursors.Hand;
|
||||
}
|
||||
|
||||
public bool Primary => _primary;
|
||||
}
|
||||
|
||||
internal sealed class WrapTextLabel : Label
|
||||
{
|
||||
public WrapTextLabel()
|
||||
{
|
||||
SetStyle(ControlStyles.UserPaint | ControlStyles.AllPaintingInWmPaint |
|
||||
ControlStyles.OptimizedDoubleBuffer | ControlStyles.ResizeRedraw, true);
|
||||
AutoSize = false;
|
||||
UseMnemonic = false;
|
||||
AccessibleRole = AccessibleRole.StaticText;
|
||||
}
|
||||
|
||||
protected override void OnPaint(PaintEventArgs e)
|
||||
{
|
||||
e.Graphics.Clear(BackColor);
|
||||
UiTheme.Configure(e.Graphics);
|
||||
var bounds = ClientRectangle;
|
||||
bounds = new Rectangle(bounds.X + Padding.Left, bounds.Y + Padding.Top,
|
||||
Math.Max(0, bounds.Width - Padding.Horizontal),
|
||||
Math.Max(0, bounds.Height - Padding.Vertical));
|
||||
e.Graphics.TextRenderingHint = System.Drawing.Text.TextRenderingHint.ClearTypeGridFit;
|
||||
using var brush = new SolidBrush(ForeColor);
|
||||
using var format = new StringFormat(StringFormat.GenericTypographic)
|
||||
{
|
||||
FormatFlags = StringFormatFlags.LineLimit,
|
||||
Trimming = StringTrimming.EllipsisCharacter,
|
||||
HotkeyPrefix = System.Drawing.Text.HotkeyPrefix.None,
|
||||
Alignment = StringAlignment.Near,
|
||||
LineAlignment = StringAlignment.Near
|
||||
};
|
||||
e.Graphics.DrawString(Text, Font, brush,
|
||||
new RectangleF(bounds.X, bounds.Y, bounds.Width, bounds.Height), format);
|
||||
}
|
||||
|
||||
protected override void OnTextChanged(EventArgs e)
|
||||
{
|
||||
Invalidate();
|
||||
base.OnTextChanged(e);
|
||||
}
|
||||
}
|
||||
|
||||
internal sealed class ToggleSwitch : Control
|
||||
{
|
||||
private bool _checked;
|
||||
private bool _hovered;
|
||||
|
||||
public ToggleSwitch()
|
||||
{
|
||||
SetStyle(ControlStyles.UserPaint | ControlStyles.AllPaintingInWmPaint |
|
||||
ControlStyles.OptimizedDoubleBuffer | ControlStyles.ResizeRedraw |
|
||||
ControlStyles.Selectable, true);
|
||||
TabStop = true;
|
||||
Cursor = Cursors.Hand;
|
||||
AccessibleRole = AccessibleRole.CheckButton;
|
||||
Size = new Size(42, 22);
|
||||
}
|
||||
|
||||
[Browsable(false)]
|
||||
[DesignerSerializationVisibility(DesignerSerializationVisibility.Hidden)]
|
||||
public bool Checked
|
||||
{
|
||||
get => _checked;
|
||||
set
|
||||
{
|
||||
if (_checked == value) return;
|
||||
_checked = value;
|
||||
Invalidate();
|
||||
}
|
||||
}
|
||||
|
||||
public event EventHandler? UserToggled;
|
||||
|
||||
protected override void OnPaint(PaintEventArgs e)
|
||||
{
|
||||
base.OnPaint(e);
|
||||
UiTheme.Configure(e.Graphics);
|
||||
var scale = UiTheme.Scale(this);
|
||||
var track = new RectangleF(
|
||||
(Width - 38 * scale) / 2f,
|
||||
(Height - 20 * scale) / 2f,
|
||||
38 * scale,
|
||||
20 * scale);
|
||||
var radius = track.Height / 2f;
|
||||
var trackColor = !Enabled ? UiTheme.BorderSoft : _checked ? UiTheme.Accent : _hovered ? UiTheme.CardPressed : UiTheme.Border;
|
||||
using (var path = new GraphicsPath())
|
||||
{
|
||||
path.AddArc(track.Left, track.Top, track.Height, track.Height, 90, 180);
|
||||
path.AddArc(track.Right - track.Height, track.Top, track.Height, track.Height, 270, 180);
|
||||
path.CloseFigure();
|
||||
using var fill = new SolidBrush(trackColor);
|
||||
e.Graphics.FillPath(fill, path);
|
||||
}
|
||||
|
||||
var knobSize = track.Height - 4 * scale;
|
||||
var knobX = _checked ? track.Right - knobSize - 2 * scale : track.Left + 2 * scale;
|
||||
using var knob = new SolidBrush(!Enabled ? UiTheme.Paused : UiTheme.Text);
|
||||
e.Graphics.FillEllipse(knob, knobX, track.Top + 2 * scale, knobSize, knobSize);
|
||||
if (Focused && ShowFocusCues)
|
||||
ControlPaint.DrawFocusRectangle(e.Graphics, ClientRectangle, UiTheme.Accent, Color.Transparent);
|
||||
}
|
||||
|
||||
protected override void OnMouseEnter(EventArgs e)
|
||||
{
|
||||
_hovered = true;
|
||||
Invalidate();
|
||||
base.OnMouseEnter(e);
|
||||
}
|
||||
|
||||
protected override void OnMouseLeave(EventArgs e)
|
||||
{
|
||||
_hovered = false;
|
||||
Invalidate();
|
||||
base.OnMouseLeave(e);
|
||||
}
|
||||
|
||||
protected override void OnMouseUp(MouseEventArgs e)
|
||||
{
|
||||
base.OnMouseUp(e);
|
||||
if (e.Button != MouseButtons.Left || !ClientRectangle.Contains(e.Location)) return;
|
||||
Toggle();
|
||||
}
|
||||
|
||||
protected override bool IsInputKey(Keys keyData) =>
|
||||
keyData is Keys.Space or Keys.Enter || base.IsInputKey(keyData);
|
||||
|
||||
protected override void OnKeyDown(KeyEventArgs e)
|
||||
{
|
||||
if (e.KeyCode is Keys.Space or Keys.Enter)
|
||||
{
|
||||
Toggle();
|
||||
e.Handled = true;
|
||||
return;
|
||||
}
|
||||
base.OnKeyDown(e);
|
||||
}
|
||||
|
||||
private void Toggle()
|
||||
{
|
||||
if (!Enabled) return;
|
||||
Focus();
|
||||
Checked = !Checked;
|
||||
UserToggled?.Invoke(this, EventArgs.Empty);
|
||||
}
|
||||
}
|
||||
|
||||
internal sealed class DisclosureButton : Control
|
||||
{
|
||||
private bool _hovered;
|
||||
private bool _expanded;
|
||||
private string _summary = string.Empty;
|
||||
|
||||
public DisclosureButton()
|
||||
{
|
||||
SetStyle(ControlStyles.UserPaint | ControlStyles.AllPaintingInWmPaint |
|
||||
ControlStyles.OptimizedDoubleBuffer | ControlStyles.ResizeRedraw |
|
||||
ControlStyles.Selectable, true);
|
||||
TabStop = true;
|
||||
Cursor = Cursors.Hand;
|
||||
AccessibleRole = AccessibleRole.ButtonDropDown;
|
||||
Font = UiTheme.Small;
|
||||
}
|
||||
|
||||
[Browsable(false)]
|
||||
[DesignerSerializationVisibility(DesignerSerializationVisibility.Hidden)]
|
||||
public bool Expanded
|
||||
{
|
||||
get => _expanded;
|
||||
set { _expanded = value; Invalidate(); }
|
||||
}
|
||||
|
||||
[Browsable(false)]
|
||||
[DesignerSerializationVisibility(DesignerSerializationVisibility.Hidden)]
|
||||
public string Summary
|
||||
{
|
||||
get => _summary;
|
||||
set { _summary = value ?? string.Empty; Invalidate(); }
|
||||
}
|
||||
|
||||
public event EventHandler? Toggled;
|
||||
|
||||
protected override void OnPaint(PaintEventArgs e)
|
||||
{
|
||||
base.OnPaint(e);
|
||||
e.Graphics.Clear(UiTheme.Background);
|
||||
UiTheme.Configure(e.Graphics);
|
||||
if (_hovered)
|
||||
UiTheme.FillRounded(e.Graphics, new Rectangle(0, 0, Width, Height), UiTheme.S(this, 6), UiTheme.BackgroundRaised);
|
||||
|
||||
UiTheme.DrawText(e.Graphics, Text, UiTheme.SmallBold, Enabled ? UiTheme.Text : UiTheme.Paused,
|
||||
new Rectangle(UiTheme.S(this, 2), 0, Width / 2, Height),
|
||||
TextFormatFlags.Left | TextFormatFlags.VerticalCenter | TextFormatFlags.SingleLine);
|
||||
UiTheme.DrawText(e.Graphics, Summary, UiTheme.Small, Enabled ? UiTheme.Muted : UiTheme.Faint,
|
||||
new Rectangle(Width / 2, 0, Width / 2 - UiTheme.S(this, 19), Height),
|
||||
TextFormatFlags.Right | TextFormatFlags.VerticalCenter | TextFormatFlags.SingleLine | TextFormatFlags.EndEllipsis);
|
||||
|
||||
var centerY = Height / 2f;
|
||||
var x = Width - UiTheme.S(this, 12);
|
||||
var d = UiTheme.S(this, 4);
|
||||
using var pen = new Pen(UiTheme.Muted, UiTheme.SF(this, 1.3f))
|
||||
{
|
||||
StartCap = LineCap.Round,
|
||||
EndCap = LineCap.Round
|
||||
};
|
||||
if (_expanded)
|
||||
{
|
||||
e.Graphics.DrawLine(pen, x - d, centerY + d / 2f, x, centerY - d / 2f);
|
||||
e.Graphics.DrawLine(pen, x, centerY - d / 2f, x + d, centerY + d / 2f);
|
||||
}
|
||||
else
|
||||
{
|
||||
e.Graphics.DrawLine(pen, x - d / 2f, centerY - d, x + d / 2f, centerY);
|
||||
e.Graphics.DrawLine(pen, x + d / 2f, centerY, x - d / 2f, centerY + d);
|
||||
}
|
||||
|
||||
if (Focused && ShowFocusCues)
|
||||
ControlPaint.DrawFocusRectangle(e.Graphics, ClientRectangle, UiTheme.Text, Color.Transparent);
|
||||
}
|
||||
|
||||
protected override void OnMouseEnter(EventArgs e)
|
||||
{
|
||||
_hovered = true;
|
||||
Invalidate();
|
||||
base.OnMouseEnter(e);
|
||||
}
|
||||
|
||||
protected override void OnMouseLeave(EventArgs e)
|
||||
{
|
||||
_hovered = false;
|
||||
Invalidate();
|
||||
base.OnMouseLeave(e);
|
||||
}
|
||||
|
||||
protected override void OnMouseUp(MouseEventArgs e)
|
||||
{
|
||||
base.OnMouseUp(e);
|
||||
if (Enabled && e.Button == MouseButtons.Left && ClientRectangle.Contains(e.Location)) Toggle();
|
||||
}
|
||||
|
||||
protected override bool IsInputKey(Keys keyData) =>
|
||||
keyData is Keys.Space or Keys.Enter || base.IsInputKey(keyData);
|
||||
|
||||
protected override void OnKeyDown(KeyEventArgs e)
|
||||
{
|
||||
if (Enabled && (e.KeyCode is Keys.Space or Keys.Enter))
|
||||
{
|
||||
Toggle();
|
||||
e.Handled = true;
|
||||
return;
|
||||
}
|
||||
base.OnKeyDown(e);
|
||||
}
|
||||
|
||||
private void Toggle()
|
||||
{
|
||||
Focus();
|
||||
Toggled?.Invoke(this, EventArgs.Empty);
|
||||
}
|
||||
}
|
||||
|
||||
internal sealed class CardPanel : Panel
|
||||
{
|
||||
public CardPanel()
|
||||
{
|
||||
SetStyle(ControlStyles.UserPaint | ControlStyles.AllPaintingInWmPaint |
|
||||
ControlStyles.OptimizedDoubleBuffer | ControlStyles.ResizeRedraw, true);
|
||||
BackColor = UiTheme.Card;
|
||||
ForeColor = UiTheme.Text;
|
||||
Padding = new Padding(14, 12, 14, 14);
|
||||
Margin = new Padding(0, 0, 0, 12);
|
||||
}
|
||||
|
||||
protected override void OnPaint(PaintEventArgs e)
|
||||
{
|
||||
base.OnPaint(e);
|
||||
UiTheme.Configure(e.Graphics);
|
||||
var bounds = new Rectangle(0, 0, Math.Max(1, Width - 1), Math.Max(1, Height - 1));
|
||||
UiTheme.StrokeRounded(e.Graphics, bounds, UiTheme.S(this, 10), UiTheme.BorderSoft, UiTheme.SF(this, 1f));
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Dark drop-down selector. A WinForms ComboBox delegates its arrow and popup chrome to the
|
||||
/// system theme, which cannot be recolored without visual styles; this control draws the field
|
||||
/// itself and opens a dark ContextMenuStrip, so nothing light leaks into the UI.
|
||||
/// </summary>
|
||||
internal sealed class DarkSelect : Control
|
||||
{
|
||||
private readonly List<object> _items = [];
|
||||
private readonly ToolStripDropDownMenu _menu = new();
|
||||
private int _selectedIndex = -1;
|
||||
private bool _hovered;
|
||||
private bool _open;
|
||||
|
||||
public DarkSelect()
|
||||
{
|
||||
SetStyle(ControlStyles.UserPaint | ControlStyles.AllPaintingInWmPaint |
|
||||
ControlStyles.OptimizedDoubleBuffer | ControlStyles.ResizeRedraw |
|
||||
ControlStyles.Selectable, true);
|
||||
TabStop = true;
|
||||
Cursor = Cursors.Hand;
|
||||
AccessibleRole = AccessibleRole.ComboBox;
|
||||
Font = UiTheme.Body;
|
||||
Height = 28;
|
||||
_menu.ShowImageMargin = false;
|
||||
_menu.ShowCheckMargin = false;
|
||||
_menu.BackColor = UiTheme.Card;
|
||||
_menu.ForeColor = UiTheme.Text;
|
||||
_menu.Font = UiTheme.Body;
|
||||
_menu.Padding = new Padding(3);
|
||||
_menu.Renderer = new ToolStripProfessionalRenderer(new DarkSelectColors());
|
||||
_menu.Closed += (_, _) => { _open = false; Invalidate(); };
|
||||
}
|
||||
|
||||
[System.ComponentModel.Browsable(false)]
|
||||
[System.ComponentModel.DesignerSerializationVisibility(System.ComponentModel.DesignerSerializationVisibility.Hidden)]
|
||||
public List<object> Items => _items;
|
||||
|
||||
[System.ComponentModel.Browsable(false)]
|
||||
[System.ComponentModel.DesignerSerializationVisibility(System.ComponentModel.DesignerSerializationVisibility.Hidden)]
|
||||
public int SelectedIndex
|
||||
{
|
||||
get => _selectedIndex;
|
||||
set
|
||||
{
|
||||
var next = value >= -1 && value < _items.Count ? value : -1;
|
||||
if (next == _selectedIndex) return;
|
||||
_selectedIndex = next;
|
||||
Invalidate();
|
||||
SelectedIndexChanged?.Invoke(this, EventArgs.Empty);
|
||||
}
|
||||
}
|
||||
|
||||
[System.ComponentModel.Browsable(false)]
|
||||
[System.ComponentModel.DesignerSerializationVisibility(System.ComponentModel.DesignerSerializationVisibility.Hidden)]
|
||||
public object? SelectedItem => _selectedIndex >= 0 ? _items[_selectedIndex] : null;
|
||||
|
||||
public event EventHandler? SelectedIndexChanged;
|
||||
|
||||
public void BeginUpdate() { }
|
||||
public void EndUpdate() { }
|
||||
|
||||
private string DisplayText => SelectedItem?.ToString() ?? string.Empty;
|
||||
|
||||
protected override void OnPaint(PaintEventArgs e)
|
||||
{
|
||||
base.OnPaint(e);
|
||||
UiTheme.Configure(e.Graphics);
|
||||
var bounds = new Rectangle(0, 0, Math.Max(1, Width - 1), Math.Max(1, Height - 1));
|
||||
var radius = UiTheme.S(this, 6);
|
||||
UiTheme.FillRounded(e.Graphics, bounds, radius, _open ? UiTheme.Card : UiTheme.Input);
|
||||
UiTheme.StrokeRounded(e.Graphics, bounds, radius, _open || _hovered ? UiTheme.Border : UiTheme.BorderSoft, UiTheme.SF(this, 1f));
|
||||
|
||||
var textBounds = new Rectangle(UiTheme.S(this, 8), 0, Math.Max(0, Width - UiTheme.S(this, 30)), Height);
|
||||
UiTheme.DrawText(e.Graphics, DisplayText, Font ?? UiTheme.Body, SelectedItem is null ? UiTheme.Faint : UiTheme.Text,
|
||||
textBounds, TextFormatFlags.Left | TextFormatFlags.VerticalCenter | TextFormatFlags.SingleLine | TextFormatFlags.EndEllipsis | TextFormatFlags.NoPrefix);
|
||||
|
||||
var size = UiTheme.S(this, 4);
|
||||
var centerX = Width - UiTheme.S(this, 15);
|
||||
var centerY = Height / 2f;
|
||||
using var pen = new Pen(Enabled ? UiTheme.Muted : UiTheme.Faint, UiTheme.SF(this, 1.4f))
|
||||
{
|
||||
StartCap = LineCap.Round,
|
||||
EndCap = LineCap.Round
|
||||
};
|
||||
e.Graphics.DrawLine(pen, centerX - size, centerY - size / 2f, centerX, centerY + size / 2f);
|
||||
e.Graphics.DrawLine(pen, centerX, centerY + size / 2f, centerX + size, centerY - size / 2f);
|
||||
|
||||
if (Focused && ShowFocusCues)
|
||||
ControlPaint.DrawFocusRectangle(e.Graphics, Rectangle.Inflate(bounds, -UiTheme.S(this, 3), -UiTheme.S(this, 3)), UiTheme.Text, Color.Transparent);
|
||||
}
|
||||
|
||||
protected override void OnMouseEnter(EventArgs e) { _hovered = true; Invalidate(); base.OnMouseEnter(e); }
|
||||
protected override void OnMouseLeave(EventArgs e) { _hovered = false; Invalidate(); base.OnMouseLeave(e); }
|
||||
|
||||
protected override void OnMouseUp(MouseEventArgs e)
|
||||
{
|
||||
base.OnMouseUp(e);
|
||||
if (e.Button == MouseButtons.Left && Enabled && ClientRectangle.Contains(e.Location)) ShowMenu();
|
||||
}
|
||||
|
||||
protected override bool IsInputKey(Keys keyData) =>
|
||||
keyData is Keys.Enter or Keys.Space or Keys.Down or Keys.Up || base.IsInputKey(keyData);
|
||||
|
||||
protected override void OnKeyDown(KeyEventArgs e)
|
||||
{
|
||||
if (!Enabled) { base.OnKeyDown(e); return; }
|
||||
if (e.KeyCode is Keys.Enter or Keys.Space or Keys.Down) { ShowMenu(); e.Handled = true; return; }
|
||||
if (e.KeyCode == Keys.Up && _selectedIndex > 0) { SelectedIndex--; e.Handled = true; return; }
|
||||
base.OnKeyDown(e);
|
||||
}
|
||||
|
||||
private void ShowMenu()
|
||||
{
|
||||
Focus();
|
||||
_menu.Items.Clear();
|
||||
for (var i = 0; i < _items.Count; i++)
|
||||
{
|
||||
var index = i;
|
||||
var item = new ToolStripMenuItem(_items[i].ToString() ?? string.Empty)
|
||||
{
|
||||
Font = i == _selectedIndex ? UiTheme.BodyBold : UiTheme.Body,
|
||||
ForeColor = UiTheme.Text,
|
||||
BackColor = UiTheme.Card,
|
||||
Padding = new Padding(6, 3, 6, 3)
|
||||
};
|
||||
item.Click += (_, _) => SelectedIndex = index;
|
||||
_menu.Items.Add(item);
|
||||
}
|
||||
if (_items.Count == 0)
|
||||
_menu.Items.Add(new ToolStripMenuItem("没有可用选项") { Enabled = false, ForeColor = UiTheme.Faint, BackColor = UiTheme.Card });
|
||||
|
||||
_open = true;
|
||||
Invalidate();
|
||||
_menu.MinimumSize = new Size(Width, 0);
|
||||
_menu.Show(this, new Point(0, Height + UiTheme.S(this, 2)));
|
||||
}
|
||||
|
||||
protected override void Dispose(bool disposing)
|
||||
{
|
||||
if (disposing) _menu.Dispose();
|
||||
base.Dispose(disposing);
|
||||
}
|
||||
|
||||
private sealed class DarkSelectColors : ProfessionalColorTable
|
||||
{
|
||||
public override Color ToolStripDropDownBackground => UiTheme.Card;
|
||||
public override Color ImageMarginGradientBegin => UiTheme.Card;
|
||||
public override Color ImageMarginGradientMiddle => UiTheme.Card;
|
||||
public override Color ImageMarginGradientEnd => UiTheme.Card;
|
||||
public override Color MenuBorder => UiTheme.Border;
|
||||
public override Color MenuItemBorder => UiTheme.CardHover;
|
||||
public override Color MenuItemSelected => UiTheme.CardHover;
|
||||
public override Color MenuItemSelectedGradientBegin => UiTheme.CardHover;
|
||||
public override Color MenuItemSelectedGradientEnd => UiTheme.CardHover;
|
||||
public override Color SeparatorDark => UiTheme.Border;
|
||||
public override Color SeparatorLight => UiTheme.BorderSoft;
|
||||
}
|
||||
}
|
||||
|
||||
internal sealed class DarkComboBox : ComboBox
|
||||
|
||||
{
|
||||
public DarkComboBox()
|
||||
{
|
||||
DropDownStyle = ComboBoxStyle.DropDownList;
|
||||
DrawMode = DrawMode.OwnerDrawFixed;
|
||||
ItemHeight = 24;
|
||||
BackColor = UiTheme.Input;
|
||||
ForeColor = UiTheme.Text;
|
||||
FlatStyle = FlatStyle.Flat;
|
||||
IntegralHeight = false;
|
||||
DropDownHeight = 260;
|
||||
}
|
||||
|
||||
protected override void WndProc(ref Message m)
|
||||
{
|
||||
base.WndProc(ref m);
|
||||
if (m.Msg != 0x000F) return; // WM_PAINT: FlatStyle.Flat draws a light system arrow.
|
||||
using var graphics = Graphics.FromHwnd(Handle);
|
||||
UiTheme.Configure(graphics);
|
||||
var arrowBox = new Rectangle(Width - UiTheme.S(this, 26), 1, UiTheme.S(this, 25), Math.Max(1, Height - 2));
|
||||
using (var background = new SolidBrush(UiTheme.Input)) graphics.FillRectangle(background, arrowBox);
|
||||
var size = Math.Max(3, UiTheme.S(this, 4));
|
||||
var centerX = arrowBox.X + arrowBox.Width / 2f - UiTheme.S(this, 2);
|
||||
var centerY = Height / 2f;
|
||||
using var pen = new Pen(Enabled ? UiTheme.Muted : UiTheme.Faint, UiTheme.SF(this, 1.4f))
|
||||
{
|
||||
StartCap = LineCap.Round,
|
||||
EndCap = LineCap.Round
|
||||
};
|
||||
graphics.DrawLine(pen, centerX - size, centerY - size / 2f, centerX, centerY + size / 2f);
|
||||
graphics.DrawLine(pen, centerX, centerY + size / 2f, centerX + size, centerY - size / 2f);
|
||||
}
|
||||
|
||||
protected override void OnDrawItem(DrawItemEventArgs e)
|
||||
{
|
||||
if (e.Index < 0 || e.Index >= Items.Count)
|
||||
{
|
||||
e.Graphics.FillRectangle(new SolidBrush(BackColor), e.Bounds);
|
||||
return;
|
||||
}
|
||||
|
||||
var selected = (e.State & DrawItemState.Selected) != 0;
|
||||
using var background = new SolidBrush(selected ? UiTheme.CardHover : UiTheme.Input);
|
||||
e.Graphics.FillRectangle(background, e.Bounds);
|
||||
var text = GetItemText(Items[e.Index]);
|
||||
TextRenderer.DrawText(e.Graphics, text, Font,
|
||||
Rectangle.Inflate(e.Bounds, -5, 0), Enabled ? UiTheme.Text : UiTheme.Faint,
|
||||
TextFormatFlags.Left | TextFormatFlags.VerticalCenter | TextFormatFlags.SingleLine |
|
||||
TextFormatFlags.EndEllipsis | TextFormatFlags.NoPrefix);
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Deterministic scroll area for the dark UI: content offset is owned here (no native scrollbar),
|
||||
/// so the slim thumb always paints above cards and the right gutter is reserved, never overlapped.
|
||||
/// </summary>
|
||||
internal sealed class DarkScrollPanel : Panel
|
||||
{
|
||||
private readonly Panel _content = new() { BackColor = UiTheme.Background, AutoSize = false };
|
||||
private readonly DarkScrollBar _bar = new();
|
||||
private readonly List<Control> _stacks = [];
|
||||
private int _offset;
|
||||
|
||||
public DarkScrollPanel()
|
||||
{
|
||||
SetStyle(ControlStyles.UserPaint | ControlStyles.AllPaintingInWmPaint |
|
||||
ControlStyles.OptimizedDoubleBuffer | ControlStyles.ResizeRedraw, true);
|
||||
AutoScroll = false;
|
||||
BackColor = UiTheme.Background;
|
||||
Padding = new Padding(2, 8, 2, 10);
|
||||
Controls.Add(_content);
|
||||
Controls.Add(_bar);
|
||||
_bar.Scrolled += (_, value) => SetOffset(value);
|
||||
}
|
||||
|
||||
/// <summary>Right-side room reserved for the scrollbar; content never occupies it.</summary>
|
||||
public int GutterWidth => UiTheme.S(this, DarkScrollBar.GutterWidthDip);
|
||||
|
||||
public int ContentHeight => _content.Height;
|
||||
|
||||
public int ViewportHeight => Math.Max(0, ClientSize.Height - Padding.Vertical);
|
||||
|
||||
public void ScrollByPixels(int delta) => SetOffset(_offset + delta);
|
||||
|
||||
/// <summary>Page children are re-hosted into the offset content panel, preserving order.</summary>
|
||||
protected override void OnControlAdded(ControlEventArgs e)
|
||||
{
|
||||
base.OnControlAdded(e);
|
||||
if (e.Control is null || ReferenceEquals(e.Control, _content) || ReferenceEquals(e.Control, _bar)) return;
|
||||
var stack = e.Control;
|
||||
Controls.Remove(stack);
|
||||
_stacks.Add(stack);
|
||||
stack.Dock = DockStyle.None;
|
||||
// AutoSize would snap each stack back to its minimum width; height is measured per layout instead.
|
||||
stack.AutoSize = false;
|
||||
stack.Width = ContentWidth;
|
||||
_content.Controls.Add(stack);
|
||||
}
|
||||
|
||||
private int ContentWidth => Math.Max(0, ClientSize.Width - Padding.Horizontal - GutterWidth);
|
||||
|
||||
protected override void OnLayout(LayoutEventArgs levent)
|
||||
{
|
||||
base.OnLayout(levent);
|
||||
var width = ContentWidth;
|
||||
var y = 0;
|
||||
foreach (var stack in _stacks)
|
||||
{
|
||||
stack.Width = width;
|
||||
var preferred = stack.GetPreferredSize(new Size(width, 0));
|
||||
if (stack.Height != preferred.Height) stack.Height = preferred.Height;
|
||||
stack.Location = new Point(0, y);
|
||||
y += stack.Height + stack.Margin.Bottom;
|
||||
}
|
||||
_content.Bounds = new Rectangle(Padding.Left, Padding.Top - _offset, width, Math.Max(1, y));
|
||||
_bar.Bounds = new Rectangle(Math.Max(0, ClientSize.Width - GutterWidth + UiTheme.S(this, 4)), UiTheme.S(this, 4),
|
||||
UiTheme.S(this, DarkScrollBar.ThumbWidthDip), Math.Max(0, ClientSize.Height - UiTheme.S(this, 8)));
|
||||
_bar.BringToFront();
|
||||
ClampOffset();
|
||||
SyncBar();
|
||||
}
|
||||
|
||||
private void SyncBar()
|
||||
{
|
||||
_bar.Maximum = Math.Max(0, ContentHeight - ViewportHeight);
|
||||
_bar.Viewport = ViewportHeight;
|
||||
_bar.Total = Math.Max(ContentHeight, ViewportHeight);
|
||||
_bar.Value = _offset;
|
||||
}
|
||||
|
||||
private void SetOffset(int value)
|
||||
{
|
||||
_offset = Math.Clamp(value, 0, Math.Max(0, ContentHeight - ViewportHeight));
|
||||
_content.Top = Padding.Top - _offset;
|
||||
SyncBar();
|
||||
}
|
||||
|
||||
private void ClampOffset() => SetOffset(_offset);
|
||||
|
||||
protected override void OnMouseWheel(MouseEventArgs e)
|
||||
{
|
||||
ScrollByPixels(-e.Delta / 120 * UiTheme.S(this, 76));
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>Slim overlay scrollbar whose thumb only appears while the page actually overflows.</summary>
|
||||
internal sealed class DarkScrollBar : Control
|
||||
{
|
||||
public const int GutterWidthDip = 12;
|
||||
public const int ThumbWidthDip = 5;
|
||||
|
||||
private int _value;
|
||||
private bool _hovered;
|
||||
private bool _dragging;
|
||||
private int _dragStartY;
|
||||
private int _dragStartValue;
|
||||
|
||||
public DarkScrollBar()
|
||||
{
|
||||
SetStyle(ControlStyles.UserPaint | ControlStyles.AllPaintingInWmPaint |
|
||||
ControlStyles.OptimizedDoubleBuffer | ControlStyles.ResizeRedraw, true);
|
||||
BackColor = UiTheme.Background;
|
||||
Width = UiTheme.S(this, ThumbWidthDip);
|
||||
}
|
||||
|
||||
[System.ComponentModel.Browsable(false)]
|
||||
[System.ComponentModel.DesignerSerializationVisibility(System.ComponentModel.DesignerSerializationVisibility.Hidden)]
|
||||
public int Maximum { get; set; }
|
||||
[System.ComponentModel.Browsable(false)]
|
||||
[System.ComponentModel.DesignerSerializationVisibility(System.ComponentModel.DesignerSerializationVisibility.Hidden)]
|
||||
public int Viewport { get; set; }
|
||||
[System.ComponentModel.Browsable(false)]
|
||||
[System.ComponentModel.DesignerSerializationVisibility(System.ComponentModel.DesignerSerializationVisibility.Hidden)]
|
||||
public int Total { get; set; }
|
||||
public event EventHandler<int>? Scrolled;
|
||||
|
||||
[System.ComponentModel.Browsable(false)]
|
||||
[System.ComponentModel.DesignerSerializationVisibility(System.ComponentModel.DesignerSerializationVisibility.Hidden)]
|
||||
public int Value
|
||||
{
|
||||
get => _value;
|
||||
set
|
||||
{
|
||||
if (_value == value) return;
|
||||
_value = value;
|
||||
Invalidate();
|
||||
}
|
||||
}
|
||||
|
||||
private bool Scrollable => Maximum > 0;
|
||||
|
||||
private Rectangle ThumbBounds
|
||||
{
|
||||
get
|
||||
{
|
||||
var trackHeight = Height;
|
||||
var ratio = Math.Min(1d, Viewport / (double)Math.Max(1, Total));
|
||||
var thumbHeight = Math.Clamp((int)Math.Round(trackHeight * ratio), UiTheme.S(this, 26), Math.Max(UiTheme.S(this, 26), trackHeight));
|
||||
var travel = Math.Max(0, trackHeight - thumbHeight);
|
||||
var position = Maximum <= 0 ? 0d : Math.Clamp(Value / (double)Maximum, 0d, 1d);
|
||||
return new Rectangle(0, (int)Math.Round(travel * position), Width, thumbHeight);
|
||||
}
|
||||
}
|
||||
|
||||
protected override void OnPaint(PaintEventArgs e)
|
||||
{
|
||||
base.OnPaint(e);
|
||||
if (!Scrollable) return;
|
||||
UiTheme.Configure(e.Graphics);
|
||||
var color = _dragging ? UiTheme.Muted : _hovered ? UiTheme.Faint : UiTheme.Border;
|
||||
var thumb = ThumbBounds;
|
||||
UiTheme.FillRounded(e.Graphics, new Rectangle(thumb.X, thumb.Y, Math.Max(1, thumb.Width - 1), thumb.Height), Math.Max(1, thumb.Width / 2 - 1), color);
|
||||
}
|
||||
|
||||
protected override void OnMouseEnter(EventArgs e) { _hovered = true; Invalidate(); base.OnMouseEnter(e); }
|
||||
protected override void OnMouseLeave(EventArgs e) { _hovered = false; Invalidate(); base.OnMouseLeave(e); }
|
||||
|
||||
protected override void OnMouseDown(MouseEventArgs e)
|
||||
{
|
||||
base.OnMouseDown(e);
|
||||
if (e.Button != MouseButtons.Left || !Scrollable) return;
|
||||
_dragging = true;
|
||||
_dragStartY = e.Y;
|
||||
_dragStartValue = Value;
|
||||
Capture = true;
|
||||
if (!ThumbBounds.Contains(e.Location))
|
||||
{
|
||||
var jump = e.Y < ThumbBounds.Top ? -Viewport : Viewport;
|
||||
Scrolled?.Invoke(this, Math.Clamp(Value + jump, 0, Maximum));
|
||||
}
|
||||
Invalidate();
|
||||
}
|
||||
|
||||
protected override void OnMouseMove(MouseEventArgs e)
|
||||
{
|
||||
base.OnMouseMove(e);
|
||||
if (!_dragging || Maximum <= 0) return;
|
||||
var travel = Math.Max(1, Height - ThumbBounds.Height);
|
||||
var next = _dragStartValue + (int)Math.Round((e.Y - _dragStartY) * (Maximum / (double)travel));
|
||||
Scrolled?.Invoke(this, Math.Clamp(next, 0, Maximum));
|
||||
}
|
||||
|
||||
protected override void OnMouseUp(MouseEventArgs e)
|
||||
{
|
||||
base.OnMouseUp(e);
|
||||
if (!_dragging) return;
|
||||
_dragging = false;
|
||||
Capture = false;
|
||||
Invalidate();
|
||||
}
|
||||
}
|
||||
|
||||
internal class FramelessForm : Form
|
||||
|
||||
{
|
||||
public FramelessForm()
|
||||
{
|
||||
FormBorderStyle = FormBorderStyle.None;
|
||||
ShowInTaskbar = false;
|
||||
StartPosition = FormStartPosition.Manual;
|
||||
BackColor = UiTheme.Background;
|
||||
ForeColor = UiTheme.Text;
|
||||
Font = UiTheme.Body;
|
||||
DoubleBuffered = true;
|
||||
KeyPreview = true;
|
||||
AutoScaleMode = AutoScaleMode.None;
|
||||
}
|
||||
|
||||
protected override CreateParams CreateParams
|
||||
{
|
||||
get
|
||||
{
|
||||
const int CsDropShadow = 0x00020000;
|
||||
var parameters = base.CreateParams;
|
||||
parameters.ClassStyle |= CsDropShadow;
|
||||
return parameters;
|
||||
}
|
||||
}
|
||||
|
||||
protected override void OnResize(EventArgs e)
|
||||
{
|
||||
base.OnResize(e);
|
||||
ApplyRoundedRegion();
|
||||
}
|
||||
|
||||
protected override void OnDpiChanged(DpiChangedEventArgs e)
|
||||
{
|
||||
base.OnDpiChanged(e);
|
||||
ApplyRoundedRegion();
|
||||
}
|
||||
|
||||
private void ApplyRoundedRegion()
|
||||
{
|
||||
if (Width <= 0 || Height <= 0) return;
|
||||
using var path = UiTheme.Rounded(new Rectangle(0, 0, Width, Height), UiTheme.S(this, 12));
|
||||
var previous = Region;
|
||||
Region = new Region(path);
|
||||
previous?.Dispose();
|
||||
}
|
||||
|
||||
protected override void OnPaint(PaintEventArgs e)
|
||||
{
|
||||
base.OnPaint(e);
|
||||
UiTheme.Configure(e.Graphics);
|
||||
var bounds = new Rectangle(0, 0, Math.Max(1, Width - 1), Math.Max(1, Height - 1));
|
||||
UiTheme.StrokeRounded(e.Graphics, bounds, UiTheme.S(this, 12), UiTheme.Border, UiTheme.SF(this, 1f));
|
||||
}
|
||||
|
||||
protected override bool ProcessCmdKey(ref Message msg, Keys keyData)
|
||||
{
|
||||
if (keyData == Keys.Escape)
|
||||
{
|
||||
OnEscapePressed();
|
||||
return true;
|
||||
}
|
||||
return base.ProcessCmdKey(ref msg, keyData);
|
||||
}
|
||||
|
||||
protected virtual void OnEscapePressed() => Hide();
|
||||
}
|
||||
@@ -0,0 +1,910 @@
|
||||
using System.Drawing;
|
||||
using System.Globalization;
|
||||
using System.Text.RegularExpressions;
|
||||
using System.Windows.Forms;
|
||||
using GatewayKeepalive.Core;
|
||||
|
||||
namespace GatewayKeepalive.Tray.Ui;
|
||||
|
||||
internal sealed class SettingsForm : FramelessForm
|
||||
{
|
||||
private readonly ITrayController _controller;
|
||||
private readonly AppSettings _original;
|
||||
private readonly IReadOnlyList<AdapterInfo> _adapters;
|
||||
|
||||
private readonly DarkSelect _adapterBox = new();
|
||||
private readonly TextBox _directGateway = new();
|
||||
private readonly TextBox _proxyGateway = new();
|
||||
private readonly TextBox _proxyDns = new();
|
||||
private readonly CheckBox _preserveOriginalDirectDns = new();
|
||||
private readonly CheckBox _directDnsAutomatic = new();
|
||||
private readonly TextBox _directDnsServers = new();
|
||||
private readonly CheckBox _useSocks = new();
|
||||
private readonly TextBox _socksPort = new();
|
||||
private readonly TextBox _testHosts = new();
|
||||
private readonly TextBox _requiredTargets = new();
|
||||
private readonly TextBox _directTestHosts = new();
|
||||
private readonly TextBox _requiredDirectTargets = new();
|
||||
private readonly TextBox _probeTimeout = new();
|
||||
private readonly TextBox _roundTimeout = new();
|
||||
private readonly TextBox _interval = new();
|
||||
private readonly TextBox _successThreshold = new();
|
||||
private readonly TextBox _failureThreshold = new();
|
||||
private readonly TextBox _recoveryCooldown = new();
|
||||
private readonly TextBox _failureCooldown = new();
|
||||
private readonly CheckBox _notifications = new();
|
||||
private readonly CheckBox _restoreOnExit = new();
|
||||
|
||||
private readonly ThemeButton _saveButton = new(true) { Text = "保存" };
|
||||
private readonly ThemeButton _cancelButton = new(false) { Text = "取消" };
|
||||
private readonly ThemeButton _refreshAdaptersButton = new(false) { Text = "刷新网卡" };
|
||||
private readonly ThemeButton _closeButton = new(false) { Text = "关闭" };
|
||||
private readonly Label _message = new();
|
||||
private readonly ToolTip _toolTip = new();
|
||||
private readonly PanelChip[] _tabs = new PanelChip[3];
|
||||
private readonly DarkScrollPanel[] _pages = new DarkScrollPanel[3];
|
||||
private readonly List<Control> _pageStacks = [];
|
||||
private readonly List<WrapTextLabel> _helpLabels = [];
|
||||
private TableLayoutPanel _outer = null!;
|
||||
private TableLayoutPanel _body = null!;
|
||||
private Panel _pageHost = null!;
|
||||
private Control _directDnsPolicy = null!;
|
||||
private Control _directDnsServersField = null!;
|
||||
private WrapTextLabel _preserveHint = null!;
|
||||
private Control _socksPortField = null!;
|
||||
private bool _saving;
|
||||
private bool _refreshing;
|
||||
private bool _dragging;
|
||||
private Point _dragOffset;
|
||||
private int _activeTab;
|
||||
|
||||
public SettingsForm(ITrayController controller, AppSettings settings, IReadOnlyList<AdapterInfo> adapters)
|
||||
{
|
||||
_controller = controller ?? throw new ArgumentNullException(nameof(controller));
|
||||
_original = settings ?? throw new ArgumentNullException(nameof(settings));
|
||||
_adapters = adapters ?? [];
|
||||
|
||||
Text = "设置 · 网关保活";
|
||||
AutoScaleDimensions = new SizeF(96f, 96f);
|
||||
AutoScaleMode = AutoScaleMode.Dpi;
|
||||
StartPosition = FormStartPosition.CenterParent;
|
||||
ClientSize = new Size(620, 560);
|
||||
BuildInterface();
|
||||
FillValues();
|
||||
}
|
||||
|
||||
public AppSettings? SavedSettings { get; private set; }
|
||||
|
||||
internal void SelectPageForPreview(int index) => SelectTab(index);
|
||||
|
||||
internal void ScrollPageForPreview(int index, int delta)
|
||||
{
|
||||
if (index < 0 || index >= _pages.Length) return;
|
||||
_pages[index].ScrollByPixels(delta);
|
||||
}
|
||||
|
||||
private void BuildInterface()
|
||||
{
|
||||
SuspendLayout();
|
||||
_outer = new TableLayoutPanel
|
||||
{
|
||||
Dock = DockStyle.Fill,
|
||||
ColumnCount = 1,
|
||||
RowCount = 3,
|
||||
BackColor = UiTheme.Background,
|
||||
Padding = new Padding(1),
|
||||
Margin = new Padding(0)
|
||||
};
|
||||
_outer.ColumnStyles.Add(new ColumnStyle(SizeType.Percent, 100f));
|
||||
_outer.RowStyles.Add(new RowStyle(SizeType.Absolute, 64));
|
||||
_outer.RowStyles.Add(new RowStyle(SizeType.Percent, 100f));
|
||||
_outer.RowStyles.Add(new RowStyle(SizeType.Absolute, 66));
|
||||
_outer.Controls.Add(BuildHeader(), 0, 0);
|
||||
_outer.Controls.Add(BuildBody(), 0, 1);
|
||||
_outer.Controls.Add(BuildFooter(), 0, 2);
|
||||
Controls.Add(_outer);
|
||||
|
||||
AcceptButton = _saveButton;
|
||||
CancelButton = _cancelButton;
|
||||
_saveButton.Click += async (_, _) => await SaveAsync();
|
||||
_cancelButton.Click += (_, _) => CloseWithCancel();
|
||||
_closeButton.Click += (_, _) => CloseWithCancel();
|
||||
_refreshAdaptersButton.Click += async (_, _) => await RefreshAdaptersAsync();
|
||||
_preserveOriginalDirectDns.CheckedChanged += (_, _) => UpdateConditionalFields();
|
||||
_directDnsAutomatic.CheckedChanged += (_, _) => UpdateConditionalFields();
|
||||
_useSocks.CheckedChanged += (_, _) => UpdateConditionalFields();
|
||||
SelectTab(0);
|
||||
ResumeLayout(true);
|
||||
}
|
||||
|
||||
private Control BuildHeader()
|
||||
{
|
||||
var header = new TableLayoutPanel
|
||||
{
|
||||
Dock = DockStyle.Fill,
|
||||
BackColor = UiTheme.Background,
|
||||
ColumnCount = 2,
|
||||
RowCount = 1,
|
||||
Padding = new Padding(18, 8, 16, 4)
|
||||
};
|
||||
header.ColumnStyles.Add(new ColumnStyle(SizeType.Percent, 100f));
|
||||
header.ColumnStyles.Add(new ColumnStyle(SizeType.AutoSize));
|
||||
header.RowStyles.Add(new RowStyle(SizeType.Percent, 100f));
|
||||
|
||||
var titles = new TableLayoutPanel
|
||||
{
|
||||
Dock = DockStyle.Fill,
|
||||
AutoSize = true,
|
||||
ColumnCount = 1,
|
||||
RowCount = 2,
|
||||
BackColor = UiTheme.Background
|
||||
};
|
||||
var title = new Label
|
||||
{
|
||||
Text = "网关保活设置",
|
||||
AutoSize = true,
|
||||
Font = UiTheme.Title,
|
||||
ForeColor = UiTheme.Text,
|
||||
BackColor = UiTheme.Background
|
||||
};
|
||||
var subtitle = new Label
|
||||
{
|
||||
Text = "修改只在保存后生效",
|
||||
AutoSize = true,
|
||||
Font = UiTheme.Small,
|
||||
ForeColor = UiTheme.Muted,
|
||||
BackColor = UiTheme.Background
|
||||
};
|
||||
titles.Controls.Add(title, 0, 0);
|
||||
titles.Controls.Add(subtitle, 0, 1);
|
||||
_closeButton.Anchor = AnchorStyles.Right;
|
||||
header.Controls.Add(titles, 0, 0);
|
||||
header.Controls.Add(_closeButton, 1, 0);
|
||||
|
||||
header.MouseDown += BeginDrag;
|
||||
header.MouseMove += ContinueDrag;
|
||||
header.MouseUp += EndDrag;
|
||||
foreach (Control child in titles.Controls)
|
||||
{
|
||||
child.MouseDown += BeginDrag;
|
||||
child.MouseMove += ContinueDrag;
|
||||
child.MouseUp += EndDrag;
|
||||
}
|
||||
return header;
|
||||
}
|
||||
|
||||
private Control BuildBody()
|
||||
{
|
||||
var body = new TableLayoutPanel
|
||||
{
|
||||
Dock = DockStyle.Fill,
|
||||
ColumnCount = 1,
|
||||
RowCount = 2,
|
||||
BackColor = UiTheme.Background,
|
||||
Padding = new Padding(14, 2, 14, 0)
|
||||
};
|
||||
body.ColumnStyles.Add(new ColumnStyle(SizeType.Percent, 100f));
|
||||
body.RowStyles.Add(new RowStyle(SizeType.Absolute, 42));
|
||||
body.RowStyles.Add(new RowStyle(SizeType.Percent, 100f));
|
||||
_body = body;
|
||||
|
||||
var tabRow = new TableLayoutPanel
|
||||
{
|
||||
Dock = DockStyle.Fill,
|
||||
ColumnCount = 3,
|
||||
RowCount = 1,
|
||||
BackColor = UiTheme.Background,
|
||||
Margin = new Padding(0)
|
||||
};
|
||||
for (var i = 0; i < 3; i++) tabRow.ColumnStyles.Add(new ColumnStyle(SizeType.Percent, 33.333f));
|
||||
tabRow.RowStyles.Add(new RowStyle(SizeType.Percent, 100f));
|
||||
|
||||
_pages[0] = MakePage();
|
||||
_pages[1] = MakePage();
|
||||
_pages[2] = MakePage();
|
||||
_pages[0].Controls.Add(BuildConnectionPage());
|
||||
_pages[1].Controls.Add(BuildRulesPage());
|
||||
_pages[2].Controls.Add(BuildDetectionPage());
|
||||
|
||||
var names = new[] { "连接", "保活规则", "检测与提醒" };
|
||||
for (var i = 0; i < 3; i++)
|
||||
{
|
||||
var index = i;
|
||||
var tab = new PanelChip
|
||||
{
|
||||
Text = names[i],
|
||||
Font = UiTheme.BodyBold,
|
||||
Dock = DockStyle.Fill,
|
||||
Margin = new Padding(3, 2, 3, 4),
|
||||
AccessibleName = names[i]
|
||||
};
|
||||
tab.Click += (_, _) => SelectTab(index);
|
||||
_tabs[i] = tab;
|
||||
tabRow.Controls.Add(tab, i, 0);
|
||||
}
|
||||
|
||||
_pageHost = new Panel
|
||||
{
|
||||
Dock = DockStyle.Fill,
|
||||
BackColor = UiTheme.Background,
|
||||
Margin = new Padding(0)
|
||||
};
|
||||
foreach (var page in _pages) _pageHost.Controls.Add(page);
|
||||
body.Controls.Add(tabRow, 0, 0);
|
||||
body.Controls.Add(_pageHost, 0, 1);
|
||||
return body;
|
||||
}
|
||||
|
||||
private Control BuildFooter()
|
||||
{
|
||||
var footer = new TableLayoutPanel
|
||||
{
|
||||
Dock = DockStyle.Fill,
|
||||
BackColor = UiTheme.BackgroundRaised,
|
||||
ColumnCount = 2,
|
||||
RowCount = 1,
|
||||
Padding = new Padding(16, 8, 16, 8)
|
||||
};
|
||||
footer.ColumnStyles.Add(new ColumnStyle(SizeType.Percent, 100f));
|
||||
footer.ColumnStyles.Add(new ColumnStyle(SizeType.AutoSize));
|
||||
footer.RowStyles.Add(new RowStyle(SizeType.Percent, 100f));
|
||||
|
||||
_message.Text = "尚未保存";
|
||||
_message.AutoSize = false;
|
||||
_message.Dock = DockStyle.Fill;
|
||||
_message.TextAlign = ContentAlignment.MiddleLeft;
|
||||
_message.Font = UiTheme.Small;
|
||||
_message.ForeColor = UiTheme.Muted;
|
||||
_message.BackColor = UiTheme.BackgroundRaised;
|
||||
_message.AutoEllipsis = true;
|
||||
|
||||
var actions = new FlowLayoutPanel
|
||||
{
|
||||
AutoSize = true,
|
||||
WrapContents = false,
|
||||
FlowDirection = FlowDirection.LeftToRight,
|
||||
Anchor = AnchorStyles.Right,
|
||||
BackColor = UiTheme.BackgroundRaised,
|
||||
Margin = new Padding(0)
|
||||
};
|
||||
actions.Controls.Add(_cancelButton);
|
||||
actions.Controls.Add(_saveButton);
|
||||
footer.Controls.Add(_message, 0, 0);
|
||||
footer.Controls.Add(actions, 1, 0);
|
||||
return footer;
|
||||
}
|
||||
|
||||
private DarkScrollPanel MakePage() => new()
|
||||
{
|
||||
Dock = DockStyle.Fill,
|
||||
BackColor = UiTheme.Background,
|
||||
Padding = new Padding(2, 8, 2, 10),
|
||||
Visible = false
|
||||
};
|
||||
|
||||
private Control BuildConnectionPage()
|
||||
{
|
||||
var page = NewPageStack();
|
||||
_adapterBox.Dock = DockStyle.Fill;
|
||||
_adapterBox.AccessibleName = "网络适配器";
|
||||
_adapterBox.Margin = new Padding(0, 0, 8, 0);
|
||||
_refreshAdaptersButton.MinimumSize = new Size(82, 28);
|
||||
_refreshAdaptersButton.Margin = new Padding(0);
|
||||
|
||||
var adapterRow = new TableLayoutPanel
|
||||
{
|
||||
Dock = DockStyle.Top,
|
||||
AutoSize = true,
|
||||
ColumnCount = 2,
|
||||
RowCount = 1,
|
||||
Margin = new Padding(0)
|
||||
};
|
||||
adapterRow.ColumnStyles.Add(new ColumnStyle(SizeType.Percent, 100f));
|
||||
adapterRow.ColumnStyles.Add(new ColumnStyle(SizeType.AutoSize));
|
||||
adapterRow.Controls.Add(_adapterBox, 0, 0);
|
||||
adapterRow.Controls.Add(_refreshAdaptersButton, 1, 0);
|
||||
AddRow(page, MakeCard("网卡", "选择本机要管理的网络接口", Field("网络适配器", adapterRow)));
|
||||
_toolTip.SetToolTip(_adapterBox, "只管理这里明确选择的网卡");
|
||||
|
||||
ConfigureText(_directGateway);
|
||||
ConfigureText(_proxyGateway);
|
||||
ConfigureText(_proxyDns);
|
||||
AddRow(page, MakeCard("网关", "填写主路由和旁路由的 IPv4 地址",
|
||||
Pair(Field("主路由网关", _directGateway), Field("旁路由网关", _proxyGateway)),
|
||||
Field("旁路由 DNS", _proxyDns)));
|
||||
|
||||
ConfigureCheck(_preserveOriginalDirectDns, "保留接管前的直连 DNS");
|
||||
ConfigureCheck(_directDnsAutomatic, "直连时自动获取 DNS");
|
||||
ConfigureText(_directDnsServers);
|
||||
_preserveHint = HelpLabel("恢复直连时使用接管前读取到、并在系统里生效的原始 DNS。");
|
||||
_directDnsPolicy = NewPageStack();
|
||||
AddRow((TableLayoutPanel)_directDnsPolicy, WrapControl(_directDnsAutomatic));
|
||||
_directDnsServersField = Field("直连 DNS 服务器(逗号或空格分隔)", _directDnsServers);
|
||||
AddRow((TableLayoutPanel)_directDnsPolicy, _directDnsServersField);
|
||||
var dnsCard = MakeCard("直连 DNS", "决定恢复主路由时使用的 DNS",
|
||||
WrapControl(_preserveOriginalDirectDns), _preserveHint, _directDnsPolicy);
|
||||
AddRow(page, dnsCard);
|
||||
_toolTip.SetToolTip(_preserveOriginalDirectDns,
|
||||
"勾选后,恢复直连会沿用接管前读取到的真实 DNS。若当前已经走旁路由且没有可信的原 DNS,保存时会提示你选择自动或静态 DNS。");
|
||||
_pageStacks.Add(page);
|
||||
return page;
|
||||
}
|
||||
|
||||
private Control BuildRulesPage()
|
||||
{
|
||||
var page = NewPageStack();
|
||||
ConfigureText(_interval);
|
||||
ConfigureText(_probeTimeout);
|
||||
ConfigureText(_roundTimeout);
|
||||
AddRow(page, MakeCard("检测节奏", "检测越频繁,故障发现越快",
|
||||
Field("检测间隔(秒)", _interval),
|
||||
Pair(Field("单次检查超时(毫秒)", _probeTimeout), Field("整轮检查预算(毫秒)", _roundTimeout))));
|
||||
|
||||
ConfigureText(_successThreshold);
|
||||
ConfigureText(_failureThreshold);
|
||||
ConfigureText(_recoveryCooldown);
|
||||
ConfigureText(_failureCooldown);
|
||||
AddRow(page, MakeCard("切换判定", "连续达到阈值并经过冷却后才会自动切换",
|
||||
Pair(Field("连续成功几次后恢复旁路由", _successThreshold), Field("连续失败几次后切回主路由", _failureThreshold)),
|
||||
Pair(Field("切回旁路由前的冷却(秒)", _recoveryCooldown), Field("切回主路由前的冷却(秒)", _failureCooldown))));
|
||||
|
||||
var note = HelpLabel("自动保活开启时才按这些规则切换。手动固定路径后,程序仍会继续监测,但不会自动改回另一条路径。");
|
||||
AddRow(page, MakeCard("说明", string.Empty, note));
|
||||
_pageStacks.Add(page);
|
||||
return page;
|
||||
}
|
||||
|
||||
private Control BuildDetectionPage()
|
||||
{
|
||||
var page = NewPageStack();
|
||||
ConfigureCheck(_useSocks, "同时检查 SOCKS5 可用性");
|
||||
ConfigureText(_socksPort);
|
||||
_socksPortField = Field("SOCKS5 端口", _socksPort);
|
||||
AddRow(page, MakeCard("探测方式", "用于判断旁路由是否真的可用", WrapControl(_useSocks), _socksPortField));
|
||||
|
||||
ConfigureText(_testHosts, multiline: true);
|
||||
ConfigureText(_requiredTargets);
|
||||
AddRow(page, MakeCard("旁路由探测目标", "走旁路由时验证这些网站",
|
||||
Field("目标列表(每行一个,最多 5 个)", _testHosts),
|
||||
Field("至少成功几个目标", _requiredTargets)));
|
||||
|
||||
ConfigureText(_directTestHosts, multiline: true);
|
||||
ConfigureText(_requiredDirectTargets);
|
||||
AddRow(page, MakeCard("直连探测目标", "回退到主路由后验证这些网站",
|
||||
Field("目标列表(每行一个,最多 5 个)", _directTestHosts),
|
||||
Field("至少成功几个目标", _requiredDirectTargets)));
|
||||
|
||||
ConfigureCheck(_notifications, "状态变化时发送通知");
|
||||
ConfigureCheck(_restoreOnExit, "退出时恢复接管前的网络配置");
|
||||
var hint = HelpLabel("如果恢复失败,程序会继续留在托盘并说明原因。");
|
||||
AddRow(page, MakeCard("提醒与退出", "通知只针对切换、故障和恢复事件",
|
||||
WrapControl(_notifications), WrapControl(_restoreOnExit), hint));
|
||||
_pageStacks.Add(page);
|
||||
return page;
|
||||
}
|
||||
|
||||
private TableLayoutPanel NewPageStack()
|
||||
{
|
||||
var stack = new TableLayoutPanel
|
||||
{
|
||||
Dock = DockStyle.Top,
|
||||
AutoSize = true,
|
||||
AutoSizeMode = AutoSizeMode.GrowAndShrink,
|
||||
ColumnCount = 1,
|
||||
RowCount = 0,
|
||||
BackColor = UiTheme.Background,
|
||||
Margin = new Padding(0)
|
||||
};
|
||||
stack.ColumnStyles.Add(new ColumnStyle(SizeType.Percent, 100f));
|
||||
return stack;
|
||||
}
|
||||
|
||||
private static void AddRow(TableLayoutPanel table, Control control)
|
||||
{
|
||||
var row = table.RowCount;
|
||||
table.RowCount++;
|
||||
table.RowStyles.Add(new RowStyle(SizeType.AutoSize));
|
||||
control.Dock = DockStyle.Top;
|
||||
table.Controls.Add(control, 0, row);
|
||||
}
|
||||
|
||||
private static CardPanel MakeCard(string title, string subtitle, params Control[] controls)
|
||||
{
|
||||
var card = new CardPanel
|
||||
{
|
||||
Dock = DockStyle.Top,
|
||||
AutoSize = true,
|
||||
AutoSizeMode = AutoSizeMode.GrowAndShrink,
|
||||
Margin = new Padding(0, 0, 0, 10)
|
||||
};
|
||||
var stack = new TableLayoutPanel
|
||||
{
|
||||
Dock = DockStyle.Fill,
|
||||
AutoSize = true,
|
||||
AutoSizeMode = AutoSizeMode.GrowAndShrink,
|
||||
ColumnCount = 1,
|
||||
RowCount = 0,
|
||||
BackColor = UiTheme.Card,
|
||||
Margin = new Padding(0)
|
||||
};
|
||||
stack.ColumnStyles.Add(new ColumnStyle(SizeType.Percent, 100f));
|
||||
AddRow(stack, SectionTitle(title, subtitle));
|
||||
foreach (var control in controls) AddRow(stack, control);
|
||||
card.Controls.Add(stack);
|
||||
return card;
|
||||
}
|
||||
|
||||
private static Control SectionTitle(string title, string subtitle)
|
||||
{
|
||||
var host = new TableLayoutPanel
|
||||
{
|
||||
Dock = DockStyle.Top,
|
||||
AutoSize = true,
|
||||
AutoSizeMode = AutoSizeMode.GrowAndShrink,
|
||||
ColumnCount = 1,
|
||||
RowCount = subtitle.Length == 0 ? 1 : 2,
|
||||
BackColor = UiTheme.Card,
|
||||
Margin = new Padding(0, 0, 0, 10)
|
||||
};
|
||||
host.Controls.Add(new Label
|
||||
{
|
||||
Text = title,
|
||||
AutoSize = true,
|
||||
Font = UiTheme.Heading,
|
||||
ForeColor = UiTheme.Text,
|
||||
BackColor = UiTheme.Card
|
||||
}, 0, 0);
|
||||
if (subtitle.Length > 0)
|
||||
{
|
||||
host.Controls.Add(new Label
|
||||
{
|
||||
Text = subtitle,
|
||||
AutoSize = true,
|
||||
Font = UiTheme.Small,
|
||||
ForeColor = UiTheme.Faint,
|
||||
BackColor = UiTheme.Card
|
||||
}, 0, 1);
|
||||
}
|
||||
return host;
|
||||
}
|
||||
|
||||
private static Control Field(string label, Control input)
|
||||
{
|
||||
var stack = new TableLayoutPanel
|
||||
{
|
||||
Dock = DockStyle.Fill,
|
||||
AutoSize = true,
|
||||
AutoSizeMode = AutoSizeMode.GrowAndShrink,
|
||||
ColumnCount = 1,
|
||||
RowCount = 2,
|
||||
Margin = new Padding(0, 0, 0, 8),
|
||||
BackColor = UiTheme.Card
|
||||
};
|
||||
stack.ColumnStyles.Add(new ColumnStyle(SizeType.Percent, 100f));
|
||||
stack.RowStyles.Add(new RowStyle(SizeType.AutoSize));
|
||||
stack.RowStyles.Add(new RowStyle(SizeType.AutoSize));
|
||||
stack.Controls.Add(new Label
|
||||
{
|
||||
Text = label,
|
||||
AutoSize = true,
|
||||
Font = UiTheme.Small,
|
||||
ForeColor = UiTheme.Muted,
|
||||
BackColor = UiTheme.Card,
|
||||
Margin = new Padding(0, 0, 0, 3)
|
||||
}, 0, 0);
|
||||
input.Margin = new Padding(0);
|
||||
input.Dock = DockStyle.Top;
|
||||
stack.Controls.Add(input, 0, 1);
|
||||
return stack;
|
||||
}
|
||||
|
||||
private static Control Pair(Control left, Control right)
|
||||
{
|
||||
var row = new TableLayoutPanel
|
||||
{
|
||||
Dock = DockStyle.Top,
|
||||
AutoSize = true,
|
||||
AutoSizeMode = AutoSizeMode.GrowAndShrink,
|
||||
ColumnCount = 2,
|
||||
RowCount = 1,
|
||||
Margin = new Padding(0),
|
||||
BackColor = UiTheme.Card
|
||||
};
|
||||
row.ColumnStyles.Add(new ColumnStyle(SizeType.Percent, 50f));
|
||||
row.ColumnStyles.Add(new ColumnStyle(SizeType.Percent, 50f));
|
||||
row.RowStyles.Add(new RowStyle(SizeType.AutoSize));
|
||||
left.Margin = new Padding(0, 0, 6, 0);
|
||||
right.Margin = new Padding(6, 0, 0, 0);
|
||||
left.Dock = DockStyle.Fill;
|
||||
right.Dock = DockStyle.Fill;
|
||||
row.Controls.Add(left, 0, 0);
|
||||
row.Controls.Add(right, 1, 0);
|
||||
return row;
|
||||
}
|
||||
|
||||
private static Control WrapControl(Control control)
|
||||
{
|
||||
var host = new Panel
|
||||
{
|
||||
Dock = DockStyle.Top,
|
||||
AutoSize = true,
|
||||
AutoSizeMode = AutoSizeMode.GrowAndShrink,
|
||||
BackColor = UiTheme.Card,
|
||||
Margin = new Padding(0, 2, 0, 4)
|
||||
};
|
||||
control.Dock = DockStyle.Top;
|
||||
host.Controls.Add(control);
|
||||
return host;
|
||||
}
|
||||
|
||||
private WrapTextLabel HelpLabel(string text)
|
||||
{
|
||||
var label = new WrapTextLabel
|
||||
{
|
||||
Text = text,
|
||||
Dock = DockStyle.Top,
|
||||
Height = 42,
|
||||
Font = UiTheme.Small,
|
||||
ForeColor = UiTheme.Muted,
|
||||
BackColor = UiTheme.Card,
|
||||
Padding = new Padding(0, 2, 0, 2),
|
||||
Margin = new Padding(0, 2, 0, 4)
|
||||
};
|
||||
_helpLabels.Add(label);
|
||||
return label;
|
||||
}
|
||||
|
||||
private void SelectTab(int index)
|
||||
{
|
||||
_activeTab = index;
|
||||
for (var i = 0; i < _tabs.Length; i++)
|
||||
{
|
||||
if (_tabs[i] is not null) _tabs[i].Selected = i == index;
|
||||
if (_pages[i] is not null) _pages[i].Visible = i == index;
|
||||
}
|
||||
if (index >= 0 && index < _pages.Length && _pages[index].Visible)
|
||||
_pages[index].BringToFront();
|
||||
ApplyPageGutters();
|
||||
|
||||
}
|
||||
|
||||
/// <summary>The page itself reserves the scrollbar gutter, so docked cards stay clear of it.</summary>
|
||||
private void ApplyPageGutters()
|
||||
{
|
||||
foreach (var page in _pages)
|
||||
{
|
||||
if (page is null) continue;
|
||||
var right = UiTheme.S(this, 4) + page.GutterWidth;
|
||||
if (page.Padding.Right == right) continue;
|
||||
page.Padding = new Padding(page.Padding.Left, page.Padding.Top, right, page.Padding.Bottom);
|
||||
page.PerformLayout();
|
||||
}
|
||||
}
|
||||
|
||||
private void FillValues()
|
||||
{
|
||||
FillAdapters(_adapters, _original.AdapterId);
|
||||
_directGateway.Text = _original.DirectGateway;
|
||||
_proxyGateway.Text = _original.ProxyGateway;
|
||||
_proxyDns.Text = _original.ProxyDns;
|
||||
_preserveOriginalDirectDns.Checked = _original.PreserveOriginalDirectDns;
|
||||
_directDnsAutomatic.Checked = _original.DirectDnsAutomatic;
|
||||
_directDnsServers.Text = string.Join(", ", _original.DirectDnsServers);
|
||||
_useSocks.Checked = _original.UseSocks;
|
||||
_socksPort.Text = _original.SocksPort.ToString(CultureInfo.InvariantCulture);
|
||||
_testHosts.Text = string.Join(Environment.NewLine, _original.TestHosts);
|
||||
_requiredTargets.Text = _original.RequiredHealthyTargets.ToString(CultureInfo.InvariantCulture);
|
||||
_directTestHosts.Text = string.Join(Environment.NewLine, _original.DirectTestHosts);
|
||||
_requiredDirectTargets.Text = _original.RequiredDirectHealthyTargets.ToString(CultureInfo.InvariantCulture);
|
||||
_probeTimeout.Text = _original.ProbeTimeoutMs.ToString(CultureInfo.InvariantCulture);
|
||||
_roundTimeout.Text = _original.RoundTimeoutMs.ToString(CultureInfo.InvariantCulture);
|
||||
_interval.Text = _original.IntervalSeconds.ToString(CultureInfo.InvariantCulture);
|
||||
_successThreshold.Text = _original.SuccessThreshold.ToString(CultureInfo.InvariantCulture);
|
||||
_failureThreshold.Text = _original.FailureThreshold.ToString(CultureInfo.InvariantCulture);
|
||||
_recoveryCooldown.Text = _original.RecoveryCooldownSeconds.ToString(CultureInfo.InvariantCulture);
|
||||
_failureCooldown.Text = _original.FailureCooldownSeconds.ToString(CultureInfo.InvariantCulture);
|
||||
_notifications.Checked = _original.Notifications;
|
||||
_restoreOnExit.Checked = _original.RestoreOnExit;
|
||||
UpdateConditionalFields();
|
||||
|
||||
_message.Text = _original.Configured
|
||||
? "尚未保存"
|
||||
: "首次配置。保存后仍然暂停,不会立即接管网络。";
|
||||
}
|
||||
|
||||
private void FillAdapters(IEnumerable<AdapterInfo> adapters, Guid desired)
|
||||
{
|
||||
_adapterBox.BeginUpdate();
|
||||
try
|
||||
{
|
||||
_adapterBox.Items.Clear();
|
||||
var selectedIndex = -1;
|
||||
foreach (var adapter in adapters.OrderBy(x => x.Index))
|
||||
{
|
||||
var item = new AdapterItem(adapter);
|
||||
_adapterBox.Items.Add(item);
|
||||
if (adapter.Id == desired) selectedIndex = _adapterBox.Items.Count - 1;
|
||||
}
|
||||
_adapterBox.SelectedIndex = selectedIndex;
|
||||
}
|
||||
finally
|
||||
{
|
||||
_adapterBox.EndUpdate();
|
||||
}
|
||||
}
|
||||
|
||||
private async Task RefreshAdaptersAsync()
|
||||
{
|
||||
if (_refreshing || _saving) return;
|
||||
_refreshing = true;
|
||||
_refreshAdaptersButton.Enabled = false;
|
||||
SetMessage("正在读取网卡…", false);
|
||||
var desired = SelectedAdapterId();
|
||||
try
|
||||
{
|
||||
var adapters = await _controller.ListAdaptersAsync();
|
||||
FillAdapters(adapters, desired);
|
||||
SetMessage(adapters.Count == 0 ? "没有找到可用网卡。" : "网卡列表已刷新,请确认选择。", adapters.Count == 0);
|
||||
}
|
||||
catch (Exception exception)
|
||||
{
|
||||
SetMessage(UiTheme.ExceptionText(exception), true);
|
||||
}
|
||||
finally
|
||||
{
|
||||
_refreshing = false;
|
||||
_refreshAdaptersButton.Enabled = !_saving;
|
||||
}
|
||||
}
|
||||
|
||||
private async Task SaveAsync()
|
||||
{
|
||||
if (_saving) return;
|
||||
AppSettings settings;
|
||||
try
|
||||
{
|
||||
settings = BuildSettings();
|
||||
}
|
||||
catch (Exception exception) when (exception is ArgumentException or FormatException)
|
||||
{
|
||||
SetMessage(exception.Message, true);
|
||||
return;
|
||||
}
|
||||
|
||||
_saving = true;
|
||||
SetInputsEnabled(false);
|
||||
SetMessage("正在保存…", false);
|
||||
UseWaitCursor = true;
|
||||
try
|
||||
{
|
||||
await _controller.SaveSettingsAsync(settings);
|
||||
SavedSettings = settings;
|
||||
DialogResult = DialogResult.OK;
|
||||
Close();
|
||||
}
|
||||
catch (Exception exception)
|
||||
{
|
||||
if (settings.PreserveOriginalDirectDns &&
|
||||
exception is InvalidOperationException &&
|
||||
exception.Message.Contains("原直连 DNS", StringComparison.Ordinal))
|
||||
{
|
||||
SetMessage("无法保留原始直连 DNS。请取消勾选“保留接管前的直连 DNS”,再选择自动获取或填写静态 DNS。", true);
|
||||
}
|
||||
else
|
||||
{
|
||||
SetMessage(UiTheme.ExceptionText(exception), true);
|
||||
}
|
||||
}
|
||||
finally
|
||||
{
|
||||
UseWaitCursor = false;
|
||||
if (!IsDisposed && Visible)
|
||||
{
|
||||
_saving = false;
|
||||
SetInputsEnabled(true);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private AppSettings BuildSettings()
|
||||
{
|
||||
if (_adapterBox.SelectedItem is not AdapterItem selected)
|
||||
throw new ArgumentException("请选择要管理的网卡。");
|
||||
|
||||
var hosts = SplitValues(_testHosts.Text);
|
||||
var directHosts = SplitValues(_directTestHosts.Text);
|
||||
var preserveOriginalDns = _preserveOriginalDirectDns.Checked;
|
||||
var directDns = preserveOriginalDns
|
||||
? _original.DirectDnsServers
|
||||
: _directDnsAutomatic.Checked ? [] : SplitValues(_directDnsServers.Text);
|
||||
var settings = _original with
|
||||
{
|
||||
SchemaVersion = 1,
|
||||
Configured = true,
|
||||
AdapterId = selected.Adapter.Id,
|
||||
DirectGateway = _directGateway.Text.Trim(),
|
||||
ProxyGateway = _proxyGateway.Text.Trim(),
|
||||
ProxyDns = _proxyDns.Text.Trim(),
|
||||
PreserveOriginalDirectDns = preserveOriginalDns,
|
||||
DirectDnsAutomatic = preserveOriginalDns ? _original.DirectDnsAutomatic : _directDnsAutomatic.Checked,
|
||||
DirectDnsServers = directDns,
|
||||
UseSocks = _useSocks.Checked,
|
||||
SocksPort = ParseInteger(_socksPort, "SOCKS5 端口", 1, 65535),
|
||||
TestHosts = hosts,
|
||||
RequiredHealthyTargets = ParseInteger(_requiredTargets, "旁路由至少成功目标数", 1, Math.Max(1, hosts.Length)),
|
||||
DirectTestHosts = directHosts,
|
||||
RequiredDirectHealthyTargets = ParseInteger(_requiredDirectTargets, "直连至少成功目标数", 1, Math.Max(1, directHosts.Length)),
|
||||
IntervalSeconds = ParseInteger(_interval, "检测间隔", 1, 3600),
|
||||
SuccessThreshold = ParseInteger(_successThreshold, "连续成功阈值", 1, 100),
|
||||
FailureThreshold = ParseInteger(_failureThreshold, "连续失败阈值", 1, 100),
|
||||
RecoveryCooldownSeconds = ParseInteger(_recoveryCooldown, "恢复冷却", 0, 3600),
|
||||
FailureCooldownSeconds = ParseInteger(_failureCooldown, "故障回退冷却", 0, 3600),
|
||||
ProbeTimeoutMs = ParseInteger(_probeTimeout, "单次检查超时", 100, 30000),
|
||||
RoundTimeoutMs = ParseInteger(_roundTimeout, "整轮检查预算", 100, 60000),
|
||||
Notifications = _notifications.Checked,
|
||||
RestoreOnExit = _restoreOnExit.Checked,
|
||||
Policy = _original.Configured ? _original.Policy : ControlPolicy.Paused
|
||||
};
|
||||
settings.Validate();
|
||||
return settings;
|
||||
}
|
||||
|
||||
private static string[] SplitValues(string text) =>
|
||||
Regex.Split(text ?? string.Empty, "[\\r\\n,;\\s]+")
|
||||
.Select(x => x.Trim())
|
||||
.Where(x => x.Length > 0)
|
||||
.Distinct(StringComparer.OrdinalIgnoreCase)
|
||||
.ToArray();
|
||||
|
||||
private static int ParseInteger(TextBox box, string name, int minimum, int maximum)
|
||||
{
|
||||
if (!int.TryParse(box.Text.Trim(), NumberStyles.Integer, CultureInfo.InvariantCulture, out var value))
|
||||
throw new ArgumentException($"{name}必须是整数。");
|
||||
if (value < minimum || value > maximum)
|
||||
throw new ArgumentException($"{name}必须在 {minimum}~{maximum} 之间。");
|
||||
return value;
|
||||
}
|
||||
|
||||
private Guid SelectedAdapterId() =>
|
||||
_adapterBox.SelectedItem is AdapterItem item ? item.Adapter.Id : Guid.Empty;
|
||||
|
||||
private void UpdateConditionalFields()
|
||||
{
|
||||
var preserve = _preserveOriginalDirectDns.Checked;
|
||||
_preserveHint.Visible = preserve;
|
||||
_directDnsPolicy.Visible = !preserve;
|
||||
_directDnsAutomatic.Enabled = !_saving;
|
||||
_directDnsServersField.Visible = !preserve && !_directDnsAutomatic.Checked;
|
||||
_directDnsServers.Enabled = !_saving && !preserve;
|
||||
_socksPortField.Visible = _useSocks.Checked;
|
||||
_socksPort.Enabled = !_saving;
|
||||
PerformLayout();
|
||||
}
|
||||
|
||||
private void SetInputsEnabled(bool enabled)
|
||||
{
|
||||
foreach (var control in new Control[]
|
||||
{
|
||||
_adapterBox, _directGateway, _proxyGateway, _proxyDns, _preserveOriginalDirectDns,
|
||||
_directDnsAutomatic, _directDnsServers, _useSocks, _socksPort, _testHosts, _requiredTargets,
|
||||
_directTestHosts, _requiredDirectTargets, _probeTimeout, _roundTimeout, _interval,
|
||||
_successThreshold, _failureThreshold, _recoveryCooldown, _failureCooldown,
|
||||
_notifications, _restoreOnExit, _refreshAdaptersButton, _saveButton, _cancelButton, _closeButton
|
||||
})
|
||||
control.Enabled = enabled;
|
||||
foreach (var tab in _tabs) tab.Enabled = enabled;
|
||||
if (enabled) UpdateConditionalFields();
|
||||
}
|
||||
|
||||
private void SetMessage(string text, bool error)
|
||||
{
|
||||
_message.Text = text;
|
||||
_message.ForeColor = error ? UiTheme.Danger : UiTheme.Muted;
|
||||
}
|
||||
|
||||
private void CloseWithCancel()
|
||||
{
|
||||
if (_saving) return;
|
||||
DialogResult = DialogResult.Cancel;
|
||||
Close();
|
||||
}
|
||||
|
||||
protected override void OnEscapePressed() => CloseWithCancel();
|
||||
|
||||
protected override void OnFormClosing(FormClosingEventArgs e)
|
||||
{
|
||||
if (_saving && e.CloseReason == CloseReason.UserClosing)
|
||||
{
|
||||
e.Cancel = true;
|
||||
return;
|
||||
}
|
||||
base.OnFormClosing(e);
|
||||
}
|
||||
|
||||
protected override void OnLoad(EventArgs e)
|
||||
{
|
||||
base.OnLoad(e);
|
||||
var area = Screen.FromPoint(Cursor.Position).WorkingArea;
|
||||
var margin = UiTheme.S(this, 8);
|
||||
var minimumWidth = Math.Min(UiTheme.S(this, 480), Math.Max(320, area.Width - margin * 2));
|
||||
var minimumHeight = Math.Min(UiTheme.S(this, 400), Math.Max(300, area.Height - margin * 2));
|
||||
MinimumSize = new Size(minimumWidth, minimumHeight);
|
||||
Width = Math.Min(UiTheme.S(this, 620), Math.Max(minimumWidth, area.Width - margin * 2));
|
||||
Height = Math.Min(UiTheme.S(this, 560), Math.Max(minimumHeight, area.Height - margin * 2));
|
||||
|
||||
_outer.RowStyles[0].Height = UiTheme.S(this, 64);
|
||||
_outer.RowStyles[2].Height = UiTheme.S(this, 66);
|
||||
_body.RowStyles[0].Height = UiTheme.S(this, 42);
|
||||
_testHosts.Height = UiTheme.S(this, 92);
|
||||
_directTestHosts.Height = UiTheme.S(this, 92);
|
||||
foreach (var label in _helpLabels) label.Height = UiTheme.S(this, 42);
|
||||
foreach (var page in _pages) page.Padding = new Padding(UiTheme.S(this, 2), UiTheme.S(this, 8),
|
||||
UiTheme.S(this, 2), UiTheme.S(this, 8));
|
||||
|
||||
if (Owner is null)
|
||||
Location = new Point(area.Left + (area.Width - Width) / 2, area.Top + (area.Height - Height) / 2);
|
||||
}
|
||||
|
||||
private void BeginDrag(object? sender, MouseEventArgs e)
|
||||
{
|
||||
if (e.Button != MouseButtons.Left) return;
|
||||
var screen = (sender as Control)?.PointToScreen(e.Location) ?? PointToScreen(e.Location);
|
||||
_dragOffset = new Point(screen.X - Left, screen.Y - Top);
|
||||
_dragging = true;
|
||||
}
|
||||
|
||||
private void ContinueDrag(object? sender, MouseEventArgs e)
|
||||
{
|
||||
if (!_dragging || e.Button != MouseButtons.Left) return;
|
||||
var screen = (sender as Control)?.PointToScreen(e.Location) ?? PointToScreen(e.Location);
|
||||
Location = new Point(screen.X - _dragOffset.X, screen.Y - _dragOffset.Y);
|
||||
}
|
||||
|
||||
private void EndDrag(object? sender, MouseEventArgs e) => _dragging = false;
|
||||
|
||||
private static void ConfigureText(TextBox box, bool multiline = false)
|
||||
{
|
||||
box.BorderStyle = BorderStyle.FixedSingle;
|
||||
box.BackColor = UiTheme.Input;
|
||||
box.ForeColor = UiTheme.Text;
|
||||
box.Font = UiTheme.Body;
|
||||
box.Multiline = multiline;
|
||||
box.Dock = DockStyle.Top;
|
||||
// Five short lines are the supported maximum, so no native light scrollbar is needed.
|
||||
if (multiline)
|
||||
{
|
||||
box.ScrollBars = ScrollBars.None;
|
||||
box.WordWrap = false;
|
||||
box.AcceptsReturn = true;
|
||||
}
|
||||
box.Height = multiline ? 92 : 28;
|
||||
box.Margin = new Padding(0);
|
||||
}
|
||||
|
||||
private static void ConfigureCheck(CheckBox check, string text)
|
||||
{
|
||||
check.Text = text;
|
||||
check.AutoSize = true;
|
||||
check.Font = UiTheme.Body;
|
||||
check.ForeColor = UiTheme.Text;
|
||||
check.BackColor = UiTheme.Card;
|
||||
check.Margin = new Padding(0, 3, 0, 3);
|
||||
}
|
||||
|
||||
protected override void Dispose(bool disposing)
|
||||
{
|
||||
if (disposing) _toolTip.Dispose();
|
||||
base.Dispose(disposing);
|
||||
}
|
||||
|
||||
private sealed record AdapterItem(AdapterInfo Adapter)
|
||||
{
|
||||
public override string ToString()
|
||||
{
|
||||
var address = Adapter.Addresses.Length > 0
|
||||
? string.Join(", ", Adapter.Addresses.Select(x => $"{x.Address}/{x.PrefixLength}"))
|
||||
: "无 IPv4 地址";
|
||||
return $"{Adapter.Name} [{Adapter.Index}] · {(Adapter.Up ? "已连接" : "未连接")} · {address}";
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,94 @@
|
||||
using System.Windows.Forms;
|
||||
using GatewayKeepalive.Core;
|
||||
|
||||
namespace GatewayKeepalive.Tray.Ui;
|
||||
|
||||
internal sealed record StatusNotification(string Key, string Title, string Message, ToolTipIcon Icon);
|
||||
|
||||
internal sealed class StatusNotificationTracker
|
||||
{
|
||||
private readonly HashSet<string> _seenEvents = new(StringComparer.Ordinal);
|
||||
private bool _initialized;
|
||||
private DateTimeOffset? _lastSwitchAt;
|
||||
private string? _activeFaultKey;
|
||||
|
||||
public IReadOnlyList<StatusNotification> Observe(AppStatus status)
|
||||
{
|
||||
var events = new List<StatusNotification>(2);
|
||||
var switchAt = status.LastSwitchAt;
|
||||
var isMode = status.Mode is NetworkMode.Direct or NetworkMode.Proxy;
|
||||
var isRealSwitch = isMode && switchAt is not null && switchAt != _lastSwitchAt;
|
||||
var isFault = status.Mode is NetworkMode.Degraded or NetworkMode.Disconnected or
|
||||
NetworkMode.RecoveryRequired or NetworkMode.ExternalConfiguration;
|
||||
var fingerprint = status.Network?.Adapter.NetworkFingerprint ?? "unknown";
|
||||
var faultKey = isFault ? $"fault:{status.Mode}:{fingerprint}" : null;
|
||||
|
||||
if (!_initialized)
|
||||
{
|
||||
_initialized = true;
|
||||
_lastSwitchAt = isMode ? switchAt : null;
|
||||
_activeFaultKey = faultKey;
|
||||
if (isRealSwitch && switchAt is not null) Remember(SwitchKey(status, switchAt.Value));
|
||||
if (faultKey is not null) Remember(faultKey);
|
||||
return events;
|
||||
}
|
||||
|
||||
// A switch timestamp is consumed only after the controller exposes a settled mode.
|
||||
if (isMode && switchAt is not null) _lastSwitchAt = switchAt;
|
||||
if (isRealSwitch && switchAt is not null)
|
||||
{
|
||||
var key = SwitchKey(status, switchAt.Value);
|
||||
if (Mark(key))
|
||||
{
|
||||
var title = status.Mode == NetworkMode.Proxy ? "已切换到旁路由" : "已切换到主路由直连";
|
||||
var gateway = status.Network?.Gateway ?? "网关已更新";
|
||||
events.Add(new StatusNotification(key, title,
|
||||
$"{gateway} · {Clip(status.Message, 120)}", ToolTipIcon.Info));
|
||||
}
|
||||
}
|
||||
|
||||
if (_activeFaultKey is not null && _activeFaultKey != faultKey)
|
||||
_seenEvents.Remove(_activeFaultKey);
|
||||
|
||||
if (faultKey is not null && faultKey != _activeFaultKey)
|
||||
{
|
||||
_activeFaultKey = faultKey;
|
||||
if (Mark(faultKey))
|
||||
{
|
||||
var (title, icon) = status.Mode switch
|
||||
{
|
||||
NetworkMode.RecoveryRequired => ("需要恢复网络配置", ToolTipIcon.Error),
|
||||
NetworkMode.ExternalConfiguration => ("检测到外部网络配置", ToolTipIcon.Warning),
|
||||
NetworkMode.Disconnected => ("目标网卡未连接", ToolTipIcon.Error),
|
||||
_ => ("网络持续异常", ToolTipIcon.Warning)
|
||||
};
|
||||
events.Add(new StatusNotification(faultKey, title, Clip(status.Message, 150), icon));
|
||||
}
|
||||
}
|
||||
else if (faultKey is null)
|
||||
{
|
||||
_activeFaultKey = null;
|
||||
}
|
||||
|
||||
return events;
|
||||
}
|
||||
|
||||
private static string SwitchKey(AppStatus status, DateTimeOffset at) =>
|
||||
$"switch:{at.UtcTicks}:{status.Mode}";
|
||||
|
||||
private bool Mark(string key)
|
||||
{
|
||||
if (!_seenEvents.Add(key)) return false;
|
||||
if (_seenEvents.Count > 64) _seenEvents.Clear();
|
||||
return true;
|
||||
}
|
||||
|
||||
private void Remember(string key)
|
||||
{
|
||||
_seenEvents.Add(key);
|
||||
if (_seenEvents.Count > 64) _seenEvents.Clear();
|
||||
}
|
||||
|
||||
private static string Clip(string text, int max) =>
|
||||
string.IsNullOrWhiteSpace(text) ? string.Empty : text.Length <= max ? text : text[..(max - 1)] + "…";
|
||||
}
|
||||
@@ -0,0 +1,634 @@
|
||||
using System.ComponentModel;
|
||||
using System.Drawing;
|
||||
using System.Windows.Forms;
|
||||
using GatewayKeepalive.Core;
|
||||
|
||||
namespace GatewayKeepalive.Tray.Ui;
|
||||
|
||||
internal sealed class StatusPanelForm : FramelessForm
|
||||
{
|
||||
private const int DesignWidth = 390;
|
||||
private const int DesignPad = 18;
|
||||
private const int DesignHeaderTop = 10;
|
||||
private const int DesignHeaderHeight = 22;
|
||||
private const int DesignHeroTop = 38;
|
||||
private const int DesignPolicyTop = 146;
|
||||
private const int DesignInfoTop = 202;
|
||||
private const int DesignFailureTop = 280;
|
||||
private const int DesignDiagnosticsTop = 278;
|
||||
private const int DesignDiagnosticsWithFailureTop = 318;
|
||||
private const int DesignDetailRowHeight = 30;
|
||||
private const int DesignActionHeight = 30;
|
||||
|
||||
private readonly ToggleSwitch _autoToggle;
|
||||
private readonly DisclosureButton _diagnosticsToggle;
|
||||
private readonly List<PanelChip> _actionButtons = [];
|
||||
private readonly ToolTip _toolTip = new();
|
||||
|
||||
private AppStatus _status = AppStatus.Initial;
|
||||
private Rectangle _headerDragBounds;
|
||||
private string? _notice;
|
||||
private bool _noticeIsError;
|
||||
private bool _busy;
|
||||
private bool _dragging;
|
||||
private bool _diagnosticsExpanded;
|
||||
private int _actionTop;
|
||||
private Point _dragOffset;
|
||||
|
||||
public StatusPanelForm()
|
||||
{
|
||||
Text = "网关保活";
|
||||
TopMost = true;
|
||||
KeyPreview = true;
|
||||
AutoScaleMode = AutoScaleMode.None;
|
||||
|
||||
_autoToggle = new ToggleSwitch { AccessibleName = "自动保活", TabStop = true };
|
||||
_autoToggle.UserToggled += (_, _) =>
|
||||
PolicyRequested?.Invoke(_autoToggle.Checked ? ControlPolicy.Auto : ControlPolicy.Paused);
|
||||
_toolTip.SetToolTip(_autoToggle, "启用或暂停自动切换;手动固定请在托盘右键菜单选择");
|
||||
Controls.Add(_autoToggle);
|
||||
|
||||
_diagnosticsToggle = new DisclosureButton { Text = "诊断详情" };
|
||||
_diagnosticsToggle.Toggled += (_, _) =>
|
||||
{
|
||||
_diagnosticsExpanded = !_diagnosticsExpanded;
|
||||
_diagnosticsToggle.Expanded = _diagnosticsExpanded;
|
||||
Relayout(keepBottom: Visible);
|
||||
EnsureVisibleOnScreen();
|
||||
Invalidate();
|
||||
};
|
||||
_toolTip.SetToolTip(_diagnosticsToggle, "展开或收起 TCP、DNS、SOCKS5 和出口检查详情");
|
||||
Controls.Add(_diagnosticsToggle);
|
||||
|
||||
AddAction("立即检测", true, () =>
|
||||
{
|
||||
if (IsUnconfigured()) SettingsRequested?.Invoke(this, EventArgs.Empty);
|
||||
else RefreshRequested?.Invoke(this, EventArgs.Empty);
|
||||
}, "立即执行一次网络检查");
|
||||
AddAction("设置", false, () => SettingsRequested?.Invoke(this, EventArgs.Empty), "打开连接、规则和检测设置");
|
||||
AddAction("日志", false, () => LogsRequested?.Invoke(this, EventArgs.Empty), "打开日志文件夹");
|
||||
|
||||
ApplyStatus(AppStatus.Initial);
|
||||
Relayout();
|
||||
}
|
||||
|
||||
public event EventHandler? RefreshRequested;
|
||||
public event EventHandler? SettingsRequested;
|
||||
public event EventHandler? LogsRequested;
|
||||
public event Action<ControlPolicy>? PolicyRequested;
|
||||
|
||||
public DateTime? LastAutoHideAt { get; private set; }
|
||||
|
||||
[Browsable(false)]
|
||||
[DesignerSerializationVisibility(DesignerSerializationVisibility.Hidden)]
|
||||
public bool AllowAutoHide { get; set; } = true;
|
||||
|
||||
public AppStatus Status => _status;
|
||||
|
||||
private void AddAction(string text, bool emphasized, Action click, string toolTip)
|
||||
{
|
||||
var button = new PanelChip
|
||||
{
|
||||
Text = text,
|
||||
Font = emphasized ? UiTheme.BodyBold : UiTheme.Body,
|
||||
Emphasized = emphasized,
|
||||
Ghost = !emphasized,
|
||||
AccessibleName = text
|
||||
};
|
||||
button.Click += (_, _) => click();
|
||||
_toolTip.SetToolTip(button, toolTip);
|
||||
_actionButtons.Add(button);
|
||||
Controls.Add(button);
|
||||
}
|
||||
|
||||
public void ApplyStatus(AppStatus status)
|
||||
{
|
||||
_status = status ?? AppStatus.Initial;
|
||||
_notice = null;
|
||||
_noticeIsError = false;
|
||||
_autoToggle.Checked = _status.Policy == ControlPolicy.Auto;
|
||||
_diagnosticsToggle.Summary = DiagnosticsSummary();
|
||||
UpdateConfigurationState();
|
||||
Relayout(keepBottom: Visible);
|
||||
Invalidate();
|
||||
}
|
||||
|
||||
public void ShowNotice(string message, bool isError)
|
||||
{
|
||||
_notice = string.IsNullOrWhiteSpace(message) ? null : message;
|
||||
_noticeIsError = isError;
|
||||
Relayout(keepBottom: Visible);
|
||||
EnsureVisibleOnScreen();
|
||||
Invalidate();
|
||||
}
|
||||
|
||||
public void SetBusy(bool busy)
|
||||
{
|
||||
_busy = busy;
|
||||
_autoToggle.Enabled = !busy && !IsUnconfigured();
|
||||
_diagnosticsToggle.Enabled = !IsUnconfigured();
|
||||
foreach (var button in _actionButtons) button.Enabled = !busy;
|
||||
Invalidate();
|
||||
}
|
||||
|
||||
public void RefreshClock()
|
||||
{
|
||||
if (!Visible) return;
|
||||
Invalidate(new Rectangle(0, UiTheme.S(this, DesignInfoTop), ClientSize.Width,
|
||||
UiTheme.S(this, 84)));
|
||||
}
|
||||
|
||||
public void ShowNearTray()
|
||||
{
|
||||
var area = Screen.FromPoint(Cursor.Position).WorkingArea;
|
||||
Relayout();
|
||||
var margin = UiTheme.S(this, 10);
|
||||
var x = area.Right - Width - margin;
|
||||
var y = area.Bottom - Height - margin;
|
||||
if (x < area.Left + margin) x = area.Left + margin;
|
||||
if (y < area.Top + margin) y = area.Top + margin;
|
||||
Location = new Point(x, y);
|
||||
Show();
|
||||
TopMost = false;
|
||||
TopMost = true;
|
||||
Activate();
|
||||
BringToFront();
|
||||
Invalidate();
|
||||
}
|
||||
|
||||
public void HidePanel()
|
||||
{
|
||||
if (!Visible) return;
|
||||
Hide();
|
||||
LastAutoHideAt = DateTime.UtcNow;
|
||||
}
|
||||
|
||||
protected override void OnDeactivate(EventArgs e)
|
||||
{
|
||||
base.OnDeactivate(e);
|
||||
if (AllowAutoHide && Visible && !_dragging) HidePanel();
|
||||
}
|
||||
|
||||
protected override void OnEscapePressed() => HidePanel();
|
||||
|
||||
protected override void OnFormClosing(FormClosingEventArgs e)
|
||||
{
|
||||
if (e.CloseReason == CloseReason.UserClosing)
|
||||
{
|
||||
e.Cancel = true;
|
||||
HidePanel();
|
||||
return;
|
||||
}
|
||||
base.OnFormClosing(e);
|
||||
}
|
||||
|
||||
protected override void OnHandleCreated(EventArgs e)
|
||||
{
|
||||
base.OnHandleCreated(e);
|
||||
Relayout();
|
||||
}
|
||||
|
||||
protected override void OnDpiChanged(DpiChangedEventArgs e)
|
||||
{
|
||||
base.OnDpiChanged(e);
|
||||
Relayout(keepBottom: Visible);
|
||||
}
|
||||
|
||||
private void Relayout(bool keepBottom = false)
|
||||
{
|
||||
var oldBottom = Bottom;
|
||||
var width = UiTheme.S(this, DesignWidth);
|
||||
var pad = UiTheme.S(this, DesignPad);
|
||||
var contentWidth = width - pad * 2;
|
||||
|
||||
_headerDragBounds = new Rectangle(pad, UiTheme.S(this, DesignHeaderTop), contentWidth,
|
||||
UiTheme.S(this, DesignHeaderHeight));
|
||||
_autoToggle.Bounds = new Rectangle(width - pad - UiTheme.S(this, 42),
|
||||
UiTheme.S(this, DesignPolicyTop + 9), UiTheme.S(this, 42), UiTheme.S(this, 22));
|
||||
|
||||
var failure = FailureSummary() is not null;
|
||||
var diagnosticsTop = failure
|
||||
? UiTheme.S(this, DesignDiagnosticsWithFailureTop)
|
||||
: UiTheme.S(this, DesignDiagnosticsTop);
|
||||
_diagnosticsToggle.Bounds = new Rectangle(pad, diagnosticsTop, contentWidth, UiTheme.S(this, 26));
|
||||
_diagnosticsToggle.Summary = DiagnosticsSummary();
|
||||
|
||||
var detailRows = _diagnosticsExpanded ? Math.Clamp(_status.Health?.Checks.Length ?? 1, 1, 4) : 0;
|
||||
_actionTop = diagnosticsTop + UiTheme.S(this, 32) +
|
||||
detailRows * UiTheme.S(this, DesignDetailRowHeight) +
|
||||
(_diagnosticsExpanded ? UiTheme.S(this, 6) : UiTheme.S(this, 8));
|
||||
|
||||
var actionGap = UiTheme.S(this, 8);
|
||||
var actionWidth = (contentWidth - actionGap * 2) / 3;
|
||||
for (var i = 0; i < _actionButtons.Count; i++)
|
||||
{
|
||||
_actionButtons[i].Bounds = IsUnconfigured()
|
||||
? new Rectangle(pad, _actionTop, i == 0 ? contentWidth : 0,
|
||||
UiTheme.S(this, DesignActionHeight))
|
||||
: new Rectangle(pad + i * (actionWidth + actionGap), _actionTop, actionWidth,
|
||||
UiTheme.S(this, DesignActionHeight));
|
||||
}
|
||||
|
||||
var height = _actionTop + UiTheme.S(this, DesignActionHeight) + UiTheme.S(this, 10);
|
||||
ClientSize = new Size(width, height);
|
||||
MinimumSize = new Size(width, height);
|
||||
MaximumSize = new Size(width, height);
|
||||
if (keepBottom && Visible) Top = oldBottom - Height;
|
||||
Invalidate();
|
||||
}
|
||||
|
||||
private bool IsUnconfigured() =>
|
||||
_status.Network is null && _status.Mode == NetworkMode.Initializing;
|
||||
|
||||
private void UpdateConfigurationState()
|
||||
{
|
||||
var initial = IsUnconfigured();
|
||||
_actionButtons[0].Text = initial ? "开始设置" : "立即检测";
|
||||
_actionButtons[1].Visible = !initial;
|
||||
_actionButtons[2].Visible = !initial;
|
||||
_autoToggle.Enabled = !initial && !_busy;
|
||||
_diagnosticsToggle.Enabled = !initial;
|
||||
}
|
||||
|
||||
private void EnsureVisibleOnScreen()
|
||||
{
|
||||
if (!Visible) return;
|
||||
var area = Screen.FromControl(this).WorkingArea;
|
||||
var left = Math.Clamp(Left, area.Left, Math.Max(area.Left, area.Right - Width));
|
||||
var top = Math.Clamp(Top, area.Top, Math.Max(area.Top, area.Bottom - Height));
|
||||
Location = new Point(left, top);
|
||||
}
|
||||
|
||||
protected override void OnPaint(PaintEventArgs e)
|
||||
{
|
||||
base.OnPaint(e);
|
||||
var graphics = e.Graphics;
|
||||
UiTheme.Configure(graphics);
|
||||
var pad = UiTheme.S(this, DesignPad);
|
||||
var width = ClientSize.Width;
|
||||
var checks = _status.Health?.Checks ?? [];
|
||||
|
||||
DrawHeader(graphics, pad, width);
|
||||
DrawActualPath(graphics, pad, width);
|
||||
DrawAutoPolicy(graphics, pad, width);
|
||||
DrawStatusSummary(graphics, pad, width, checks);
|
||||
DrawFailure(graphics, pad, width);
|
||||
if (_diagnosticsExpanded) DrawDiagnostics(graphics, pad, width, checks);
|
||||
}
|
||||
|
||||
private void DrawHeader(Graphics graphics, int pad, int width)
|
||||
{
|
||||
UiTheme.DrawText(graphics, "网关保活", UiTheme.PanelTitle, UiTheme.Text,
|
||||
new Rectangle(pad, UiTheme.S(this, DesignHeaderTop), UiTheme.S(this, 150), UiTheme.S(this, DesignHeaderHeight)),
|
||||
TextFormatFlags.Left | TextFormatFlags.VerticalCenter | TextFormatFlags.SingleLine);
|
||||
|
||||
var (label, color) = InternetStatus();
|
||||
var textWidth = UiTheme.S(this, 100);
|
||||
var dot = UiTheme.S(this, 6);
|
||||
var x = width - pad - textWidth;
|
||||
var centerY = UiTheme.S(this, DesignHeaderTop) + UiTheme.S(this, DesignHeaderHeight) / 2;
|
||||
using var brush = new SolidBrush(color);
|
||||
graphics.FillEllipse(brush, x, centerY - dot / 2, dot, dot);
|
||||
UiTheme.DrawText(graphics, label, UiTheme.Small, color,
|
||||
new Rectangle(x + UiTheme.S(this, 12), UiTheme.S(this, DesignHeaderTop),
|
||||
textWidth - UiTheme.S(this, 12), UiTheme.S(this, DesignHeaderHeight)),
|
||||
TextFormatFlags.Right | TextFormatFlags.VerticalCenter | TextFormatFlags.SingleLine | TextFormatFlags.EndEllipsis);
|
||||
}
|
||||
|
||||
private void DrawActualPath(Graphics graphics, int pad, int width)
|
||||
{
|
||||
var active = ActivePath();
|
||||
var network = _status.Network;
|
||||
var gateway = network?.Gateway ?? "--";
|
||||
var dns = network is null ? "--"
|
||||
: network.EffectiveDns.Length > 0 ? string.Join(", ", network.EffectiveDns)
|
||||
: network.Dns.Automatic ? "自动获取" : string.Join(", ", network.Dns.Servers);
|
||||
|
||||
UiTheme.DrawText(graphics, "当前连接", UiTheme.Small, UiTheme.Muted,
|
||||
new Rectangle(pad, UiTheme.S(this, DesignHeroTop), UiTheme.S(this, 180), UiTheme.S(this, 13)),
|
||||
TextFormatFlags.Left | TextFormatFlags.VerticalCenter | TextFormatFlags.SingleLine);
|
||||
|
||||
var title = ActivePathName(active);
|
||||
var titleColor = active switch
|
||||
{
|
||||
NetworkMode.Switching => UiTheme.Warning,
|
||||
NetworkMode.Initializing or NetworkMode.Disconnected => UiTheme.Muted,
|
||||
NetworkMode.RecoveryRequired => UiTheme.Danger,
|
||||
_ => UiTheme.Text
|
||||
};
|
||||
UiTheme.DrawText(graphics, title, UiTheme.Hero, titleColor,
|
||||
new Rectangle(pad, UiTheme.S(this, DesignHeroTop + 14), UiTheme.S(this, 210), UiTheme.S(this, 37)),
|
||||
TextFormatFlags.Left | TextFormatFlags.VerticalCenter | TextFormatFlags.SingleLine | TextFormatFlags.EndEllipsis);
|
||||
|
||||
var gatewayY = UiTheme.S(this, DesignHeroTop + 52);
|
||||
UiTheme.DrawText(graphics, "网关", UiTheme.Small, UiTheme.Faint,
|
||||
new Rectangle(pad, gatewayY, UiTheme.S(this, 30), UiTheme.S(this, 17)),
|
||||
TextFormatFlags.Left | TextFormatFlags.VerticalCenter | TextFormatFlags.SingleLine);
|
||||
UiTheme.DrawText(graphics, gateway, UiTheme.BodyBold, UiTheme.Text,
|
||||
new Rectangle(pad + UiTheme.S(this, 32), gatewayY, UiTheme.S(this, 132), UiTheme.S(this, 17)),
|
||||
TextFormatFlags.Left | TextFormatFlags.VerticalCenter | TextFormatFlags.SingleLine | TextFormatFlags.EndEllipsis);
|
||||
UiTheme.DrawText(graphics, "DNS " + UiTheme.Clip(dns, 23), UiTheme.Small, UiTheme.Muted,
|
||||
new Rectangle(width - pad - UiTheme.S(this, 155), gatewayY, UiTheme.S(this, 155), UiTheme.S(this, 17)),
|
||||
TextFormatFlags.Right | TextFormatFlags.VerticalCenter | TextFormatFlags.SingleLine | TextFormatFlags.EndEllipsis);
|
||||
|
||||
var pathY = UiTheme.S(this, DesignHeroTop + 72);
|
||||
if (IsUnconfigured())
|
||||
{
|
||||
UiTheme.DrawText(graphics, "先选择网卡和网关,再启用自动保活", UiTheme.Small, UiTheme.Muted,
|
||||
new Rectangle(pad, pathY, width - pad * 2, UiTheme.S(this, 18)),
|
||||
TextFormatFlags.Left | TextFormatFlags.VerticalCenter | TextFormatFlags.SingleLine | TextFormatFlags.EndEllipsis);
|
||||
DrawSeparator(graphics, UiTheme.S(this, DesignHeroTop + 96), pad, width - pad);
|
||||
return;
|
||||
}
|
||||
|
||||
var x = pad;
|
||||
UiTheme.DrawText(graphics, "此电脑", UiTheme.Small, UiTheme.Muted,
|
||||
new Rectangle(x, pathY, UiTheme.S(this, 48), UiTheme.S(this, 18)),
|
||||
TextFormatFlags.Left | TextFormatFlags.VerticalCenter | TextFormatFlags.SingleLine);
|
||||
x += UiTheme.S(this, 52);
|
||||
DrawChevron(graphics, x, pathY);
|
||||
x += UiTheme.S(this, 17);
|
||||
var route = active switch
|
||||
{
|
||||
NetworkMode.Proxy => "旁路由",
|
||||
NetworkMode.Direct => "主路由",
|
||||
NetworkMode.ExternalConfiguration => "外部路由",
|
||||
NetworkMode.Switching => "切换中",
|
||||
NetworkMode.Disconnected => "未连接",
|
||||
NetworkMode.Initializing => "等待接管",
|
||||
_ => "当前路由"
|
||||
};
|
||||
var routeColor = active is NetworkMode.Direct or NetworkMode.Proxy ? UiTheme.Accent : UiTheme.Muted;
|
||||
UiTheme.DrawText(graphics, route, UiTheme.SmallBold, routeColor,
|
||||
new Rectangle(x, pathY, UiTheme.S(this, 62), UiTheme.S(this, 18)),
|
||||
TextFormatFlags.Left | TextFormatFlags.VerticalCenter | TextFormatFlags.SingleLine | TextFormatFlags.EndEllipsis);
|
||||
x += UiTheme.S(this, 66);
|
||||
DrawChevron(graphics, x, pathY);
|
||||
x += UiTheme.S(this, 17);
|
||||
UiTheme.DrawText(graphics, "互联网", UiTheme.SmallBold, UiTheme.InternetColor(_status.Health),
|
||||
new Rectangle(x, pathY, width - pad - x, UiTheme.S(this, 18)),
|
||||
TextFormatFlags.Left | TextFormatFlags.VerticalCenter | TextFormatFlags.SingleLine | TextFormatFlags.EndEllipsis);
|
||||
|
||||
DrawSeparator(graphics, UiTheme.S(this, DesignHeroTop + 96), pad, width - pad);
|
||||
}
|
||||
|
||||
private void DrawAutoPolicy(Graphics graphics, int pad, int width)
|
||||
{
|
||||
var top = UiTheme.S(this, DesignPolicyTop);
|
||||
UiTheme.DrawText(graphics, "自动保活", UiTheme.BodyBold, UiTheme.Text,
|
||||
new Rectangle(pad, top, UiTheme.S(this, 86), UiTheme.S(this, 20)),
|
||||
TextFormatFlags.Left | TextFormatFlags.VerticalCenter | TextFormatFlags.SingleLine);
|
||||
UiTheme.DrawText(graphics, PolicyHint(), UiTheme.Small, UiTheme.Muted,
|
||||
new Rectangle(pad, top + UiTheme.S(this, 19), width - pad * 2 - UiTheme.S(this, 155), UiTheme.S(this, 17)),
|
||||
TextFormatFlags.Left | TextFormatFlags.VerticalCenter | TextFormatFlags.SingleLine | TextFormatFlags.EndEllipsis);
|
||||
|
||||
var state = PolicyStateText();
|
||||
var stateColor = _status.Policy == ControlPolicy.Auto ? UiTheme.Accent
|
||||
: _status.Policy == ControlPolicy.Paused ? UiTheme.Muted : UiTheme.Text;
|
||||
UiTheme.DrawText(graphics, state, UiTheme.Small, stateColor,
|
||||
new Rectangle(width - pad - UiTheme.S(this, 132), top + UiTheme.S(this, 8),
|
||||
UiTheme.S(this, 82), UiTheme.S(this, 22)),
|
||||
TextFormatFlags.Right | TextFormatFlags.VerticalCenter | TextFormatFlags.SingleLine | TextFormatFlags.EndEllipsis);
|
||||
DrawSeparator(graphics, UiTheme.S(this, DesignPolicyTop + 45), pad, width - pad);
|
||||
}
|
||||
|
||||
private void DrawStatusSummary(Graphics graphics, int pad, int width, ProbeResult[] checks)
|
||||
{
|
||||
var top = UiTheme.S(this, DesignInfoTop);
|
||||
if (IsUnconfigured())
|
||||
{
|
||||
UiTheme.DrawText(graphics, "配置完成后,这里会显示当前出口和旁路由状态。", UiTheme.Small, UiTheme.Muted,
|
||||
new Rectangle(pad, top + UiTheme.S(this, 8), width - pad * 2, UiTheme.S(this, 18)),
|
||||
TextFormatFlags.Left | TextFormatFlags.VerticalCenter | TextFormatFlags.SingleLine | TextFormatFlags.EndEllipsis);
|
||||
UiTheme.DrawText(graphics, "保存设置后才会开始检测。", UiTheme.Small, UiTheme.Faint,
|
||||
new Rectangle(pad, top + UiTheme.S(this, 28), width - pad * 2, UiTheme.S(this, 17)),
|
||||
TextFormatFlags.Left | TextFormatFlags.VerticalCenter | TextFormatFlags.SingleLine);
|
||||
return;
|
||||
}
|
||||
|
||||
var egress = checks.FirstOrDefault(x => x.Name == "当前出口");
|
||||
var egressValue = _status.Health is null ? "等待检测"
|
||||
: _status.Health.InternetHealthy ? egress is null ? "可达" : $"可达 · {egress.ElapsedMs} ms"
|
||||
: "不可达";
|
||||
var egressColor = UiTheme.InternetColor(_status.Health);
|
||||
DrawMetric(graphics, pad, top, width / 2 - pad - UiTheme.S(this, 6), "当前出口", egressValue, egressColor);
|
||||
|
||||
var candidateValue = _status.Health is null ? "等待检测" : _status.Health.ProxyHealthy ? "可用" : "不可用";
|
||||
var candidateColor = _status.Health is null ? UiTheme.Paused : _status.Health.ProxyHealthy ? UiTheme.Healthy : UiTheme.Warning;
|
||||
DrawMetric(graphics, width / 2 + UiTheme.S(this, 6), top, width / 2 - pad - UiTheme.S(this, 6),
|
||||
"旁路由状态", candidateValue, candidateColor);
|
||||
|
||||
var metadata = $"上次切换 {UiTheme.Elapsed(_status.LastSwitchAt)} · 成功 {_status.SuccessCount} · 失败 {_status.FailureCount}";
|
||||
UiTheme.DrawText(graphics, metadata, UiTheme.Small, UiTheme.Faint,
|
||||
new Rectangle(pad, UiTheme.S(this, DesignInfoTop + 35), width - pad * 2, UiTheme.S(this, 16)),
|
||||
TextFormatFlags.Left | TextFormatFlags.VerticalCenter | TextFormatFlags.SingleLine | TextFormatFlags.EndEllipsis);
|
||||
|
||||
var eventText = string.IsNullOrWhiteSpace(_status.LastEvent) ? "尚无切换或恢复事件" : _status.LastEvent;
|
||||
if (_status.LastEventAt is { } eventAt) eventText = UiTheme.LocalTime(eventAt) + " · " + eventText;
|
||||
UiTheme.DrawText(graphics, UiTheme.Clip(eventText, 58), UiTheme.Small, UiTheme.Muted,
|
||||
new Rectangle(pad, UiTheme.S(this, DesignInfoTop + 53), width - pad * 2, UiTheme.S(this, 17)),
|
||||
TextFormatFlags.Left | TextFormatFlags.VerticalCenter | TextFormatFlags.SingleLine | TextFormatFlags.EndEllipsis);
|
||||
}
|
||||
|
||||
private void DrawFailure(Graphics graphics, int pad, int width)
|
||||
{
|
||||
var failure = FailureSummary();
|
||||
if (failure is null) return;
|
||||
var y = UiTheme.S(this, DesignFailureTop);
|
||||
var color = _noticeIsError || _status.Mode is NetworkMode.RecoveryRequired or NetworkMode.Disconnected
|
||||
? UiTheme.Danger : UiTheme.Warning;
|
||||
var bounds = new Rectangle(pad, y, width - pad * 2, UiTheme.S(this, 30));
|
||||
UiTheme.FillRounded(graphics, bounds, UiTheme.S(this, 7), UiTheme.WithAlpha(color, 18));
|
||||
UiTheme.StrokeRounded(graphics, bounds, UiTheme.S(this, 7), UiTheme.WithAlpha(color, 72), UiTheme.SF(this, 1f));
|
||||
var dot = UiTheme.S(this, 5);
|
||||
using var brush = new SolidBrush(color);
|
||||
graphics.FillEllipse(brush, bounds.X + UiTheme.S(this, 9), bounds.Y + bounds.Height / 2 - dot / 2, dot, dot);
|
||||
UiTheme.DrawText(graphics, UiTheme.Clip(failure, 78), UiTheme.Small, color,
|
||||
new Rectangle(bounds.X + UiTheme.S(this, 21), bounds.Y,
|
||||
bounds.Width - UiTheme.S(this, 29), bounds.Height),
|
||||
TextFormatFlags.Left | TextFormatFlags.VerticalCenter | TextFormatFlags.SingleLine | TextFormatFlags.EndEllipsis);
|
||||
}
|
||||
|
||||
private void DrawDiagnostics(Graphics graphics, int pad, int width, ProbeResult[] checks)
|
||||
{
|
||||
var top = _diagnosticsToggle.Bottom + UiTheme.S(this, 4);
|
||||
if (checks.Length == 0)
|
||||
{
|
||||
UiTheme.DrawText(graphics, "尚未执行检测", UiTheme.Small, UiTheme.Muted,
|
||||
new Rectangle(pad, top, width - pad * 2, UiTheme.S(this, DesignDetailRowHeight)),
|
||||
TextFormatFlags.Left | TextFormatFlags.VerticalCenter | TextFormatFlags.SingleLine);
|
||||
return;
|
||||
}
|
||||
|
||||
var rows = checks.Take(4).ToArray();
|
||||
for (var i = 0; i < rows.Length; i++)
|
||||
{
|
||||
var row = new Rectangle(pad, top + i * UiTheme.S(this, DesignDetailRowHeight),
|
||||
width - pad * 2, UiTheme.S(this, DesignDetailRowHeight));
|
||||
var probe = rows[i];
|
||||
var color = UiTheme.CheckColor(probe);
|
||||
var dot = UiTheme.S(this, 5);
|
||||
using (var brush = new SolidBrush(color))
|
||||
graphics.FillEllipse(brush, row.X, row.Y + UiTheme.S(this, 8), dot, dot);
|
||||
UiTheme.DrawText(graphics, UiTheme.Clip(probe.Name, 18), UiTheme.SmallBold, UiTheme.Text,
|
||||
new Rectangle(row.X + UiTheme.S(this, 11), row.Y + UiTheme.S(this, 1),
|
||||
UiTheme.S(this, 135), UiTheme.S(this, 14)),
|
||||
TextFormatFlags.Left | TextFormatFlags.VerticalCenter | TextFormatFlags.SingleLine | TextFormatFlags.EndEllipsis);
|
||||
var status = probe.Success ? $"正常 · {probe.ElapsedMs} ms" : "异常";
|
||||
UiTheme.DrawText(graphics, status, UiTheme.Small, color,
|
||||
new Rectangle(row.Right - UiTheme.S(this, 112), row.Y + UiTheme.S(this, 1),
|
||||
UiTheme.S(this, 112), UiTheme.S(this, 14)),
|
||||
TextFormatFlags.Right | TextFormatFlags.VerticalCenter | TextFormatFlags.SingleLine);
|
||||
UiTheme.DrawText(graphics, UiTheme.Clip(probe.Detail, 58), UiTheme.Small, UiTheme.Faint,
|
||||
new Rectangle(row.X + UiTheme.S(this, 11), row.Y + UiTheme.S(this, 15),
|
||||
row.Width - UiTheme.S(this, 15), UiTheme.S(this, 13)),
|
||||
TextFormatFlags.Left | TextFormatFlags.VerticalCenter | TextFormatFlags.SingleLine | TextFormatFlags.EndEllipsis);
|
||||
if (i < rows.Length - 1)
|
||||
DrawSeparator(graphics, row.Bottom, row.Left, row.Right, alpha: 115);
|
||||
}
|
||||
}
|
||||
|
||||
private void DrawMetric(Graphics graphics, int x, int y, int width, string label, string value, Color color)
|
||||
{
|
||||
UiTheme.DrawText(graphics, label, UiTheme.Small, UiTheme.Muted,
|
||||
new Rectangle(x, y, width, UiTheme.S(this, 13)),
|
||||
TextFormatFlags.Left | TextFormatFlags.VerticalCenter | TextFormatFlags.SingleLine);
|
||||
UiTheme.DrawText(graphics, value, UiTheme.BodyBold, color,
|
||||
new Rectangle(x, y + UiTheme.S(this, 13), width, UiTheme.S(this, 20)),
|
||||
TextFormatFlags.Left | TextFormatFlags.VerticalCenter | TextFormatFlags.SingleLine | TextFormatFlags.EndEllipsis);
|
||||
}
|
||||
|
||||
private void DrawChevron(Graphics graphics, int x, int y)
|
||||
{
|
||||
using var pen = new Pen(UiTheme.Faint, UiTheme.SF(this, 1.1f));
|
||||
var mid = y + UiTheme.S(this, 9);
|
||||
graphics.DrawLine(pen, x, mid - UiTheme.S(this, 3), x + UiTheme.S(this, 4), mid);
|
||||
graphics.DrawLine(pen, x + UiTheme.S(this, 4), mid, x, mid + UiTheme.S(this, 3));
|
||||
}
|
||||
|
||||
private void DrawSeparator(Graphics graphics, int y, int left, int right, int alpha = 255)
|
||||
{
|
||||
using var pen = new Pen(UiTheme.WithAlpha(UiTheme.BorderSoft, alpha), UiTheme.SF(this, 1f));
|
||||
graphics.DrawLine(pen, left, y, right, y);
|
||||
}
|
||||
|
||||
private NetworkMode ActivePath() =>
|
||||
_status.ActivePath != NetworkMode.Initializing ? _status.ActivePath : _status.Mode;
|
||||
|
||||
private (string Label, Color Color) InternetStatus()
|
||||
{
|
||||
if (IsUnconfigured()) return ("尚未配置", UiTheme.Muted);
|
||||
if (_status.Mode is NetworkMode.Disconnected or NetworkMode.RecoveryRequired)
|
||||
return ("网络异常", UiTheme.Danger);
|
||||
if (_status.Health is null) return ("等待检测", UiTheme.Paused);
|
||||
return _status.Health.InternetHealthy
|
||||
? ("外网可达", UiTheme.Healthy)
|
||||
: ("外网不可达", UiTheme.Warning);
|
||||
}
|
||||
|
||||
private static string ActivePathName(NetworkMode mode) => mode switch
|
||||
{
|
||||
NetworkMode.Proxy => "旁路由",
|
||||
NetworkMode.Direct => "主路由",
|
||||
NetworkMode.Switching => "切换中",
|
||||
NetworkMode.Degraded => "网络异常",
|
||||
NetworkMode.Disconnected => "未连接",
|
||||
NetworkMode.ExternalConfiguration => "外部网络",
|
||||
NetworkMode.RecoveryRequired => "需要恢复",
|
||||
_ => "尚未配置"
|
||||
};
|
||||
|
||||
private string PolicyHint() => _status.Policy switch
|
||||
{
|
||||
_ when IsUnconfigured() => "完成设置后可启用",
|
||||
ControlPolicy.Auto => "按健康检查自动选择路径",
|
||||
ControlPolicy.Paused => "自动切换已暂停,仍在监测",
|
||||
ControlPolicy.ForceDirect => "手动固定主路由;自动切换未启用",
|
||||
_ => "手动固定旁路由;自动切换未启用"
|
||||
};
|
||||
|
||||
private string PolicyStateText() => _status.Policy switch
|
||||
{
|
||||
_ when IsUnconfigured() => "未启用",
|
||||
ControlPolicy.Auto => "已启用",
|
||||
ControlPolicy.Paused => "已暂停",
|
||||
ControlPolicy.ForceDirect => "固定主路由",
|
||||
_ => "固定旁路由"
|
||||
};
|
||||
|
||||
private string DiagnosticsSummary()
|
||||
{
|
||||
if (IsUnconfigured()) return "配置后可查看";
|
||||
var checks = _status.Health?.Checks ?? [];
|
||||
if (checks.Length == 0) return "尚未检测";
|
||||
var failed = checks.Count(x => !x.Success);
|
||||
return failed == 0 ? $"{checks.Length} 项 · 全部正常" : $"{checks.Length} 项 · {failed} 项异常";
|
||||
}
|
||||
|
||||
private string? FailureSummary()
|
||||
{
|
||||
if (_notice is not null) return _notice;
|
||||
if (_status.Mode is NetworkMode.RecoveryRequired or NetworkMode.Disconnected)
|
||||
return string.IsNullOrWhiteSpace(_status.Message) ? "网络需要处理" : _status.Message;
|
||||
|
||||
var checks = _status.Health?.Checks ?? [];
|
||||
if (_status.Health is { InternetHealthy: false })
|
||||
{
|
||||
var egress = checks.FirstOrDefault(x => !x.Success && x.Name.Contains("出口", StringComparison.Ordinal));
|
||||
return egress is null ? "当前出口不可达" : "当前出口不可达:" + egress.Detail;
|
||||
}
|
||||
if (_status.Health is { ProxyHealthy: false })
|
||||
{
|
||||
var proxy = checks.FirstOrDefault(x => !x.Success && x.Name.Contains("SOCKS", StringComparison.Ordinal))
|
||||
?? checks.FirstOrDefault(x => !x.Success &&
|
||||
(x.Name.Contains("旁路由", StringComparison.Ordinal) || x.Name.Contains("出口", StringComparison.Ordinal)));
|
||||
return proxy is null ? "旁路由状态不可用" : "旁路由状态不可用:" + proxy.Detail;
|
||||
}
|
||||
if (_status.Mode == NetworkMode.Degraded)
|
||||
return string.IsNullOrWhiteSpace(_status.Message) ? "网络状态异常" : _status.Message;
|
||||
if (_status.Mode == NetworkMode.ExternalConfiguration)
|
||||
return string.IsNullOrWhiteSpace(_status.Message) ? "检测到外部网络配置" : _status.Message;
|
||||
return null;
|
||||
}
|
||||
|
||||
protected override void OnMouseDown(MouseEventArgs e)
|
||||
{
|
||||
base.OnMouseDown(e);
|
||||
if (e.Button != MouseButtons.Left || !_headerDragBounds.Contains(e.Location)) return;
|
||||
_dragging = true;
|
||||
_dragOffset = PointToScreen(e.Location);
|
||||
_dragOffset = new Point(_dragOffset.X - Left, _dragOffset.Y - Top);
|
||||
Capture = true;
|
||||
}
|
||||
|
||||
internal void SetDiagnosticsExpanded(bool expanded)
|
||||
{
|
||||
if (_diagnosticsExpanded == expanded) return;
|
||||
_diagnosticsExpanded = expanded;
|
||||
_diagnosticsToggle.Expanded = expanded;
|
||||
Relayout();
|
||||
}
|
||||
|
||||
protected override void OnMouseMove(MouseEventArgs e)
|
||||
{
|
||||
base.OnMouseMove(e);
|
||||
if (!_dragging) return;
|
||||
var screen = PointToScreen(e.Location);
|
||||
Location = new Point(screen.X - _dragOffset.X, screen.Y - _dragOffset.Y);
|
||||
}
|
||||
|
||||
protected override void OnMouseUp(MouseEventArgs e)
|
||||
{
|
||||
base.OnMouseUp(e);
|
||||
if (e.Button != MouseButtons.Left || !_dragging) return;
|
||||
_dragging = false;
|
||||
Capture = false;
|
||||
EnsureVisibleOnScreen();
|
||||
}
|
||||
|
||||
protected override void Dispose(bool disposing)
|
||||
{
|
||||
if (disposing) _toolTip.Dispose();
|
||||
base.Dispose(disposing);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,206 @@
|
||||
using System.Drawing;
|
||||
using System.Drawing.Drawing2D;
|
||||
using System.Globalization;
|
||||
using System.Windows.Forms;
|
||||
using GatewayKeepalive.Core;
|
||||
|
||||
namespace GatewayKeepalive.Tray.Ui;
|
||||
|
||||
internal static class UiTheme
|
||||
{
|
||||
public static readonly Color Background = Color.FromArgb(0x18, 0x1B, 0x21);
|
||||
public static readonly Color BackgroundRaised = Color.FromArgb(0x1E, 0x23, 0x2B);
|
||||
public static readonly Color Card = Color.FromArgb(0x20, 0x25, 0x2D);
|
||||
public static readonly Color CardHover = Color.FromArgb(0x29, 0x31, 0x3B);
|
||||
public static readonly Color CardPressed = Color.FromArgb(0x33, 0x3C, 0x48);
|
||||
public static readonly Color Input = Color.FromArgb(0x1B, 0x20, 0x28);
|
||||
public static readonly Color Border = Color.FromArgb(0x35, 0x3D, 0x49);
|
||||
public static readonly Color BorderSoft = Color.FromArgb(0x29, 0x30, 0x3A);
|
||||
public static readonly Color Text = Color.FromArgb(0xE9, 0xEE, 0xF5);
|
||||
public static readonly Color Muted = Color.FromArgb(0x9C, 0xA7, 0xB5);
|
||||
public static readonly Color Faint = Color.FromArgb(0x6D, 0x78, 0x87);
|
||||
public static readonly Color Accent = Color.FromArgb(0x6E, 0xA8, 0xFE);
|
||||
public static readonly Color AccentSoft = Color.FromArgb(0x8D, 0xBB, 0xFF);
|
||||
public static readonly Color Healthy = Color.FromArgb(0x4F, 0xCB, 0x8D);
|
||||
public static readonly Color Direct = Color.FromArgb(0x6E, 0xA8, 0xFE);
|
||||
public static readonly Color Warning = Color.FromArgb(0xE0, 0xA2, 0x4A);
|
||||
public static readonly Color Danger = Color.FromArgb(0xE4, 0x69, 0x6C);
|
||||
public static readonly Color Paused = Color.FromArgb(0x78, 0x84, 0x94);
|
||||
|
||||
private static readonly FontFamily Family = PickFamily();
|
||||
|
||||
public static readonly Font Title = new(Family, 13.5f, FontStyle.Bold, GraphicsUnit.Point);
|
||||
public static readonly Font PanelTitle = new(Family, 10.5f, FontStyle.Bold, GraphicsUnit.Point);
|
||||
public static readonly Font Hero = new(Family, 23f, FontStyle.Bold, GraphicsUnit.Point);
|
||||
public static readonly Font Heading = new(Family, 10.5f, FontStyle.Bold, GraphicsUnit.Point);
|
||||
public static readonly Font Body = new(Family, 9.5f, FontStyle.Regular, GraphicsUnit.Point);
|
||||
public static readonly Font BodyBold = new(Family, 9.5f, FontStyle.Bold, GraphicsUnit.Point);
|
||||
public static readonly Font Small = new(Family, 8.25f, FontStyle.Regular, GraphicsUnit.Point);
|
||||
public static readonly Font SmallBold = new(Family, 8.25f, FontStyle.Bold, GraphicsUnit.Point);
|
||||
public static readonly Font Number = new(Family, 15f, FontStyle.Bold, GraphicsUnit.Point);
|
||||
|
||||
public static float Scale(Control control) => Math.Max(1f, control.DeviceDpi / 96f);
|
||||
|
||||
public static int S(Control control, int designPixels) =>
|
||||
(int)Math.Round(designPixels * Scale(control), MidpointRounding.AwayFromZero);
|
||||
|
||||
public static float SF(Control control, float designPixels) => designPixels * Scale(control);
|
||||
|
||||
public static Color WithAlpha(Color color, int alpha) => Color.FromArgb(alpha, color);
|
||||
|
||||
public static GraphicsPath Rounded(Rectangle bounds, int radius)
|
||||
{
|
||||
var path = new GraphicsPath();
|
||||
if (bounds.Width <= 0 || bounds.Height <= 0)
|
||||
{
|
||||
path.AddRectangle(bounds);
|
||||
return path;
|
||||
}
|
||||
|
||||
var diameter = Math.Min(radius * 2, Math.Min(bounds.Width, bounds.Height));
|
||||
if (diameter <= 0)
|
||||
{
|
||||
path.AddRectangle(bounds);
|
||||
return path;
|
||||
}
|
||||
|
||||
path.AddArc(bounds.X, bounds.Y, diameter, diameter, 180, 90);
|
||||
path.AddArc(bounds.Right - diameter, bounds.Y, diameter, diameter, 270, 90);
|
||||
path.AddArc(bounds.Right - diameter, bounds.Bottom - diameter, diameter, diameter, 0, 90);
|
||||
path.AddArc(bounds.X, bounds.Bottom - diameter, diameter, diameter, 90, 90);
|
||||
path.CloseFigure();
|
||||
return path;
|
||||
}
|
||||
|
||||
public static void FillRounded(Graphics graphics, Rectangle bounds, int radius, Color color)
|
||||
{
|
||||
using var path = Rounded(bounds, radius);
|
||||
using var brush = new SolidBrush(color);
|
||||
graphics.FillPath(brush, path);
|
||||
}
|
||||
|
||||
public static void StrokeRounded(Graphics graphics, Rectangle bounds, int radius, Color color, float width)
|
||||
{
|
||||
using var path = Rounded(bounds, radius);
|
||||
using var pen = new Pen(color, width);
|
||||
graphics.DrawPath(pen, path);
|
||||
}
|
||||
|
||||
public static void DrawText(Graphics graphics, string text, Font font, Color color, Rectangle bounds,
|
||||
TextFormatFlags flags = TextFormatFlags.Left | TextFormatFlags.VerticalCenter | TextFormatFlags.SingleLine)
|
||||
{
|
||||
TextRenderer.DrawText(graphics, text, font, bounds, color,
|
||||
flags | TextFormatFlags.NoPadding | TextFormatFlags.NoPrefix);
|
||||
}
|
||||
|
||||
public static void Configure(Graphics graphics)
|
||||
{
|
||||
graphics.SmoothingMode = SmoothingMode.AntiAlias;
|
||||
graphics.PixelOffsetMode = PixelOffsetMode.HighQuality;
|
||||
graphics.TextRenderingHint = System.Drawing.Text.TextRenderingHint.ClearTypeGridFit;
|
||||
}
|
||||
|
||||
public static string PolicyName(ControlPolicy policy) => policy switch
|
||||
{
|
||||
ControlPolicy.Auto => "自动模式",
|
||||
ControlPolicy.ForceDirect => "固定直连",
|
||||
ControlPolicy.ForceProxy => "固定旁路由",
|
||||
ControlPolicy.Paused => "已暂停",
|
||||
_ => "未知模式"
|
||||
};
|
||||
|
||||
public static string ModeName(NetworkMode mode) => mode switch
|
||||
{
|
||||
NetworkMode.Initializing => "正在初始化",
|
||||
NetworkMode.Direct => "主路由直连",
|
||||
NetworkMode.Proxy => "旁路由网关",
|
||||
NetworkMode.Switching => "正在切换",
|
||||
NetworkMode.Degraded => "网络降级",
|
||||
NetworkMode.Disconnected => "网卡未连接",
|
||||
NetworkMode.ExternalConfiguration => "外部配置",
|
||||
NetworkMode.RecoveryRequired => "需要恢复",
|
||||
_ => "状态未知"
|
||||
};
|
||||
|
||||
public static Color ModeColor(NetworkMode mode, ControlPolicy policy)
|
||||
{
|
||||
if (mode == NetworkMode.RecoveryRequired) return Danger;
|
||||
if (mode == NetworkMode.Disconnected) return Danger;
|
||||
if (mode is NetworkMode.Switching or NetworkMode.Degraded) return Warning;
|
||||
if (mode == NetworkMode.ExternalConfiguration) return Accent;
|
||||
if (policy == ControlPolicy.Paused) return Paused;
|
||||
return mode switch
|
||||
{
|
||||
NetworkMode.Proxy or NetworkMode.Direct => Accent,
|
||||
_ => Paused
|
||||
};
|
||||
}
|
||||
|
||||
public static Color CheckColor(ProbeResult? check) => check switch
|
||||
{
|
||||
null => Paused,
|
||||
{ Success: true } => Healthy,
|
||||
_ => Danger
|
||||
};
|
||||
|
||||
public static Color InternetColor(HealthReport? health) => health switch
|
||||
{
|
||||
null => Paused,
|
||||
{ InternetHealthy: true } => Healthy,
|
||||
_ => Warning
|
||||
};
|
||||
|
||||
public static string CheckState(ProbeResult? check) => check switch
|
||||
{
|
||||
null => "待检测",
|
||||
{ Success: true } => "正常",
|
||||
_ => "异常"
|
||||
};
|
||||
|
||||
public static string Elapsed(DateTimeOffset? since)
|
||||
{
|
||||
if (since is null) return "尚未切换";
|
||||
var span = DateTimeOffset.Now - since.Value;
|
||||
if (span < TimeSpan.Zero) span = TimeSpan.Zero;
|
||||
if (span.TotalSeconds < 60) return $"{(int)span.TotalSeconds} 秒前";
|
||||
if (span.TotalMinutes < 60) return $"{(int)span.TotalMinutes} 分钟前";
|
||||
if (span.TotalHours < 24) return $"{(int)span.TotalHours} 小时前";
|
||||
return $"{(int)span.TotalDays} 天前";
|
||||
}
|
||||
|
||||
public static string LocalTime(DateTimeOffset value) => value.ToLocalTime().ToString("HH:mm:ss", CultureInfo.CurrentCulture);
|
||||
|
||||
public static string Clip(string? value, int maxLength)
|
||||
{
|
||||
if (string.IsNullOrWhiteSpace(value)) return string.Empty;
|
||||
var clean = value.Replace('\r', ' ').Replace('\n', ' ').Trim();
|
||||
return clean.Length <= maxLength ? clean : clean[..Math.Max(0, maxLength - 1)] + "…";
|
||||
}
|
||||
|
||||
public static string ExceptionText(Exception exception)
|
||||
{
|
||||
return exception switch
|
||||
{
|
||||
UnauthorizedAccessException => "需要管理员权限。请以管理员身份重新启动程序后再试。",
|
||||
RecoveryRequiredException => "存在未完成或需要人工核对的网络切换:" + exception.Message,
|
||||
TimeoutException => "操作超时,网络状态尚未确认。请稍后重新检测。",
|
||||
FileNotFoundException or DirectoryNotFoundException => "找不到文件或目录:" + exception.Message,
|
||||
ArgumentException => "配置或参数无效:" + exception.Message,
|
||||
IOException => "网络读写失败:" + exception.Message,
|
||||
InvalidOperationException => "当前无法执行:" + exception.Message,
|
||||
_ => "操作失败:" + (string.IsNullOrWhiteSpace(exception.Message) ? "发生未知错误。" : exception.Message)
|
||||
};
|
||||
}
|
||||
|
||||
public static Color Blend(Color color, int alpha) => Color.FromArgb(alpha, color);
|
||||
|
||||
private static FontFamily PickFamily()
|
||||
{
|
||||
foreach (var name in new[] { "Microsoft YaHei UI", "Microsoft YaHei", "Segoe UI" })
|
||||
{
|
||||
try { return new FontFamily(name); }
|
||||
catch (ArgumentException) { }
|
||||
}
|
||||
return FontFamily.GenericSansSerif;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,127 @@
|
||||
using System.Drawing;
|
||||
using System.Drawing.Drawing2D;
|
||||
using System.Runtime.InteropServices;
|
||||
using GatewayKeepalive.Core;
|
||||
|
||||
namespace GatewayKeepalive.Tray.Ui;
|
||||
|
||||
internal sealed class TrayIconFactory : IDisposable
|
||||
{
|
||||
private readonly Dictionary<string, Icon> _cache = new(StringComparer.Ordinal);
|
||||
|
||||
public Icon Get(AppStatus status)
|
||||
{
|
||||
var color = status.Policy == ControlPolicy.Paused ? UiTheme.Paused
|
||||
: status.Mode is NetworkMode.RecoveryRequired or NetworkMode.Disconnected ? UiTheme.Danger
|
||||
: status.Health is null ? UiTheme.Paused
|
||||
: status.Health.InternetHealthy ? UiTheme.Healthy
|
||||
: UiTheme.Warning;
|
||||
var shape = IconShape(status.ActivePath != NetworkMode.Initializing ? status.ActivePath : status.Mode,
|
||||
status.Policy);
|
||||
var key = $"{shape}:{color.ToArgb():X8}";
|
||||
if (_cache.TryGetValue(key, out var cached)) return cached;
|
||||
|
||||
using var bitmap = new Bitmap(32, 32, System.Drawing.Imaging.PixelFormat.Format32bppArgb);
|
||||
using (var graphics = Graphics.FromImage(bitmap))
|
||||
{
|
||||
graphics.Clear(Color.Transparent);
|
||||
graphics.SmoothingMode = SmoothingMode.AntiAlias;
|
||||
|
||||
using var background = new SolidBrush(Color.FromArgb(255, 17, 24, 33));
|
||||
using var pen = new Pen(color, 2.4f);
|
||||
graphics.FillEllipse(background, 2.5f, 2.5f, 27f, 27f);
|
||||
graphics.DrawEllipse(pen, 3.5f, 3.5f, 25f, 25f);
|
||||
DrawGlyph(graphics, shape, color);
|
||||
}
|
||||
|
||||
var handle = bitmap.GetHicon();
|
||||
try
|
||||
{
|
||||
using var temporary = Icon.FromHandle(handle);
|
||||
var icon = (Icon)temporary.Clone();
|
||||
_cache[key] = icon;
|
||||
return icon;
|
||||
}
|
||||
finally
|
||||
{
|
||||
DestroyIcon(handle);
|
||||
}
|
||||
}
|
||||
|
||||
private static TrayShape IconShape(NetworkMode mode, ControlPolicy policy)
|
||||
{
|
||||
if (policy == ControlPolicy.Paused) return TrayShape.Paused;
|
||||
return mode switch
|
||||
{
|
||||
NetworkMode.Proxy => TrayShape.Proxy,
|
||||
NetworkMode.Direct => TrayShape.Direct,
|
||||
NetworkMode.Switching or NetworkMode.Degraded => TrayShape.Warning,
|
||||
NetworkMode.Disconnected or NetworkMode.RecoveryRequired => TrayShape.Danger,
|
||||
NetworkMode.ExternalConfiguration => TrayShape.External,
|
||||
_ => TrayShape.Initializing
|
||||
};
|
||||
}
|
||||
|
||||
private static void DrawGlyph(Graphics graphics, TrayShape shape, Color color)
|
||||
{
|
||||
using var brush = new SolidBrush(color);
|
||||
using var pen = new Pen(color, 2.2f) { StartCap = LineCap.Round, EndCap = LineCap.Round };
|
||||
switch (shape)
|
||||
{
|
||||
case TrayShape.Proxy:
|
||||
graphics.DrawLine(pen, 10f, 12f, 22f, 20f);
|
||||
graphics.DrawLine(pen, 10f, 20f, 22f, 12f);
|
||||
graphics.FillEllipse(brush, 8f, 9.5f, 5f, 5f);
|
||||
graphics.FillEllipse(brush, 19f, 9.5f, 5f, 5f);
|
||||
graphics.FillEllipse(brush, 14f, 15.5f, 4f, 4f);
|
||||
break;
|
||||
case TrayShape.Direct:
|
||||
graphics.DrawLine(pen, 9f, 16f, 22f, 16f);
|
||||
graphics.DrawLine(pen, 17f, 11f, 22f, 16f);
|
||||
graphics.DrawLine(pen, 17f, 21f, 22f, 16f);
|
||||
break;
|
||||
case TrayShape.Warning:
|
||||
graphics.FillRectangle(brush, 15f, 9f, 2.5f, 9f);
|
||||
graphics.FillEllipse(brush, 15f, 21f, 2.5f, 2.5f);
|
||||
break;
|
||||
case TrayShape.Danger:
|
||||
graphics.DrawLine(pen, 11f, 11f, 21f, 21f);
|
||||
graphics.DrawLine(pen, 21f, 11f, 11f, 21f);
|
||||
break;
|
||||
case TrayShape.Paused:
|
||||
graphics.FillRectangle(brush, 11f, 10f, 4f, 12f);
|
||||
graphics.FillRectangle(brush, 18f, 10f, 4f, 12f);
|
||||
break;
|
||||
case TrayShape.External:
|
||||
using (var ring = new Pen(color, 2f))
|
||||
graphics.DrawEllipse(ring, 10f, 10f, 12f, 12f);
|
||||
graphics.FillEllipse(brush, 14.5f, 14.5f, 3f, 3f);
|
||||
break;
|
||||
default:
|
||||
graphics.FillEllipse(brush, 10f, 15f, 3f, 3f);
|
||||
graphics.FillEllipse(brush, 15f, 15f, 3f, 3f);
|
||||
graphics.FillEllipse(brush, 20f, 15f, 3f, 3f);
|
||||
break;
|
||||
}
|
||||
}
|
||||
|
||||
public void Dispose()
|
||||
{
|
||||
foreach (var icon in _cache.Values) icon.Dispose();
|
||||
_cache.Clear();
|
||||
}
|
||||
|
||||
[DllImport("user32.dll", SetLastError = true)]
|
||||
private static extern bool DestroyIcon(IntPtr handle);
|
||||
|
||||
private enum TrayShape
|
||||
{
|
||||
Initializing,
|
||||
Proxy,
|
||||
Direct,
|
||||
Warning,
|
||||
Danger,
|
||||
Paused,
|
||||
External
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,10 @@
|
||||
<?xml version="1.0" encoding="utf-8"?>
|
||||
<assembly manifestVersion="1.0" xmlns="urn:schemas-microsoft-com:asm.v1">
|
||||
<assemblyIdentity version="1.0.0.0" name="GatewayKeepalive.app" />
|
||||
<trustInfo xmlns="urn:schemas-microsoft-com:asm.v3"><security><requestedPrivileges><requestedExecutionLevel level="asInvoker" uiAccess="false" /></requestedPrivileges></security></trustInfo>
|
||||
<compatibility xmlns="urn:schemas-microsoft-com:compatibility.v1"><application><supportedOS Id="{8e0f7a12-bfb3-4fe8-b9a5-48fd50a15a9a}" /></application></compatibility>
|
||||
<application xmlns="urn:schemas-microsoft-com:asm.v3"><windowsSettings>
|
||||
<dpiAwareness xmlns="http://schemas.microsoft.com/SMI/2016/WindowsSettings">PerMonitorV2</dpiAwareness>
|
||||
<longPathAware xmlns="http://schemas.microsoft.com/SMI/2016/WindowsSettings">true</longPathAware>
|
||||
</windowsSettings></application>
|
||||
</assembly>
|
||||
Reference in New Issue
Block a user