重做设置界面并修复登录自启:页签单选、滚轮归属、任务缺失判定、发布去掉自带运行时

设置窗口
- 改为独立非模态窗口(进任务栏、可最小化),不再被托盘浮窗挡住导致点不到
- 位置改为按屏幕可用区域居中裁剪,不再默认落在右下角
- 三个页签重构为卡片式布局:统一卡片内边距、页签行独立成行、正文左右留白、
  帮助文字按实际换行测量高度、标题栏与底栏加分隔线
- 直连 DNS 复选框、下拉框、滚动条改为自绘控件,配色与整体统一

页签
- PanelChip 的选中/强调改为值变化即重绘,修掉“切换页签后旧页签仍显示为激活、
  鼠标移上去才更新”的陈旧绘制

滚轮
- 输入框滚不动自己时把滚轮交给外层页面,单行输入框不再吞掉滚轮事件

登录自启
- 改用任务 XML + schtasks 写入,COM 失败时用 schtasks 复核;写入后读回校验
- 任务不存在按 HRESULT 判定(0x80070002 / 0x80070003 / 0x8004130F),修掉
  “关闭登录自启必定报错并回滚”(晚绑定 COM 抛的是 FileNotFoundException)
- 状态读取 COM 失败时回退到 schtasks,失败按异常类型 + HResult 去重记录

诊断
- 界面报错统一写日志;控制器记录自启读写结果;启动自检记录管理员/自启/进程/参数

发布
- 去掉 -SelfContained 分支,只保留单文件运行时依赖包,脚本与文档同步
- 新增 tools/unblock-publish.ps1,清除发布产物的网络来源标记

测试
- 新增登录自启任务 XML 用例与“任务缺失按 HRESULT 判定”回归测试(46 → 47),探测 16/16
This commit is contained in:
chuzhongzai
2026-10-24 01:32:03 +08:00
parent 2d85527656
commit 7be117057a
23 changed files with 1549 additions and 217 deletions
+30 -2
View File
@@ -26,7 +26,19 @@ public sealed class GatewayController : ITrayController
public AppSettings Settings => _settings;
public AppStatus Status => _status;
public string DataDirectory => _store.DirectoryPath;
public bool AutostartEnabled => _getAutostart();
public bool AutostartEnabled
{
get
{
try { return _getAutostart(); }
catch (Exception e)
{
Log("控制器读取登录自启状态失败:" + e);
throw;
}
}
}
public event Action<AppStatus>? StatusChanged;
public GatewayController(INetworkBackend backend, IHealthProbe probe, JsonStore store, Action<string> log, Func<bool>? getAutostart = null, Action<bool>? setAutostart = null)
@@ -59,7 +71,23 @@ public sealed class GatewayController : ITrayController
}
public Task<IReadOnlyList<AdapterInfo>> ListAdaptersAsync() => Task.Run(() => _backend.ListAdaptersAsync(_stop.Token));
public void SetAutostart(bool enabled) => _setAutostart(enabled);
/// <summary>给界面层一个写日志的入口:界面上报的错也要能在日志里查到。</summary>
public void AppendLog(string text) => Log(text);
public void SetAutostart(bool enabled)
{
try
{
_setAutostart(enabled);
Log("控制器设置登录自启成功:启用=" + (enabled ? "是" : "否"));
}
catch (Exception e)
{
Log("控制器设置登录自启失败:启用=" + (enabled ? "是" : "否") + " " + e);
throw;
}
}
public async Task RefreshAsync()
{
+1
View File
@@ -109,6 +109,7 @@ public interface ITrayController
AppStatus Status { get; }
string DataDirectory { get; }
bool AutostartEnabled { get; }
void AppendLog(string text);
event Action<AppStatus>? StatusChanged;
Task<IReadOnlyList<AdapterInfo>> ListAdaptersAsync();
Task RefreshAsync();
+33 -3
View File
@@ -16,6 +16,7 @@ internal static class Program
{
var index = Array.IndexOf(args, "--preview");
var path = index + 1 < args.Length ? args[index + 1] : Path.Combine(AppContext.BaseDirectory, "preview");
Directory.CreateDirectory(path);
TrayApplicationContext.RenderPreview(path);
return 0;
}
@@ -37,7 +38,19 @@ internal static class Program
Process.Start(start);
return 0;
}
catch (Win32Exception e) when (e.NativeErrorCode == 1223) { return 1; }
catch (Win32Exception e) when (e.NativeErrorCode == 1223)
{
// 用户取消了 UAC:明确说清楚,否则看起来就是“双击了但什么都没发生”。
MessageBox.Show("网关保活需要管理员权限才能修改路由和 DNS。\n本次提权被取消,程序没有启动。", "网关保活",
MessageBoxButtons.OK, MessageBoxIcon.Information);
return 1;
}
catch (Win32Exception e)
{
MessageBox.Show("无法以管理员权限重新启动:" + e.Message, "网关保活",
MessageBoxButtons.OK, MessageBoxIcon.Error);
return 1;
}
}
using var identity = WindowsIdentity.GetCurrent();
@@ -50,10 +63,27 @@ internal static class Program
try
{
var store = new JsonStore(directory);
var startup = new LoginStartup();
var startup = new LoginStartup(logger.Write);
// 每次启动先留一行自检:出问题时光看日志就知道是不是提权/自启任务的问题。
try
{
logger.Write($"启动自检:管理员={backend.IsAdministrator} 登录自启={startup.IsEnabled} 进程={Environment.ProcessPath} 参数={(args.Length == 0 ? "(无)" : string.Join(' ', args))}");
}
catch (Exception exception)
{
logger.Write("启动自检失败:" + exception.Message);
}
// 校正旧任务指向要连 COM、必要时还会拉起 schtasks,放到后台做,别让托盘图标准备好之前先卡住。
_ = Task.Run(() =>
{
try { startup.RepairIfStale(); }
catch (Exception exception) { logger.Write("校正登录自启任务失败:" + exception); }
});
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"));
// 手动启动(不是登录任务、也没带 --minimized)就一定要有可见反馈:
// 只留一个托盘图标会让人以为程序没起来,于是反复双击、反复弹 UAC。
using var app = new TrayApplicationContext(controller, !args.Contains("--minimized"));
using var dispatcher = new Control();
_ = dispatcher.Handle;
var wait = ThreadPool.RegisterWaitForSingleObject(show, (_, _) =>
@@ -32,6 +32,7 @@ public sealed class TrayApplicationContext : ApplicationContext
private AppStatus _status = AppStatus.Initial;
private SettingsForm? _settingsForm;
private bool _exitPending;
private bool _busy;
private bool _exitInProgress;
private bool _disposed;
@@ -331,7 +332,6 @@ public sealed class TrayApplicationContext : ApplicationContext
}
_busy = true;
_panel.SetBusy(true);
var previousAutoHide = _panel.AllowAutoHide;
_panel.AllowAutoHide = false;
try
@@ -349,11 +349,18 @@ public sealed class TrayApplicationContext : ApplicationContext
return;
}
using var form = new SettingsForm(_controller, settings, adapters);
var form = new SettingsForm(_controller, settings, adapters);
_settingsForm = form;
var owner = _panel.Visible ? _panel : null;
form.ShowDialog(owner);
ApplyStatus(ReadStatus(_status));
var closed = new TaskCompletionSource();
form.FormClosed += (_, _) => closed.TrySetResult();
// 先收起托盘浮窗:它是最顶层窗口,留在屏幕上会盖住设置窗口。
_panel.HidePanel();
// 非模态独立窗口:会出现在任务栏,可以随时切回来;也不再被托盘浮窗当成 owner 挤到角落。
form.Show();
form.Activate();
await closed.Task;
form.Dispose();
if (!_disposed) ApplyStatus(ReadStatus(_status));
}
catch (Exception exception)
{
@@ -367,7 +374,12 @@ public sealed class TrayApplicationContext : ApplicationContext
{
_busy = false;
_panel.SetBusy(false);
if (_panel.Visible) _panel.Activate();
if (_exitPending)
{
// 用户在设置窗口打开期间点了退出:窗口已经关掉,接着把退出流程走完。
_exitPending = false;
_dispatcher.BeginInvoke((Action)(() => _ = ExitAsync()));
}
}
}
}
@@ -446,7 +458,10 @@ public sealed class TrayApplicationContext : ApplicationContext
if (_disposed || _exitInProgress) return;
if (_settingsForm is not null)
{
ReportError("暂时无法退出", "请先关闭设置窗口,再退出程序。");
// 设置窗口还开着:先把它关掉,OpenSettingsAsync 收尾后会接着把退出流程走完。
_exitPending = true;
try { _settingsForm.Close(); }
catch (Exception exception) when (exception is ObjectDisposedException or InvalidOperationException) { }
return;
}
if (_busy)
@@ -494,6 +509,8 @@ public sealed class TrayApplicationContext : ApplicationContext
private void ReportError(string title, string message)
{
if (_disposed) return;
// 界面上只要报了错,日志里就必须有对应的一条:否则用户只能描述“有报错”,无法定位。
_controller.AppendLog($"界面报错[{title}]:{message}");
_panel.ShowNotice(message, true);
if (_panel.Visible)
{
@@ -772,6 +789,7 @@ public sealed class TrayApplicationContext : ApplicationContext
public Task RestoreAsync() => Task.CompletedTask;
public Task StopAsync() => Task.CompletedTask;
public void SetAutostart(bool enabled) { }
public void AppendLog(string text) { }
}
private sealed class DarkMenuColors : ProfessionalColorTable
+611 -60
View File
@@ -10,6 +10,8 @@ internal sealed class PanelChip : Control
{
private bool _hovered;
private bool _pressed;
private bool _selected;
private bool _emphasized;
private Color _accent = UiTheme.Accent;
public PanelChip()
@@ -25,16 +27,39 @@ internal sealed class PanelChip : Control
[Browsable(false)]
[DesignerSerializationVisibility(DesignerSerializationVisibility.Hidden)]
public bool Selected { get; set; }
public bool Selected
{
get => _selected;
set
{
if (_selected == value) return;
_selected = value;
Invalidate();
}
}
[Browsable(false)]
[DesignerSerializationVisibility(DesignerSerializationVisibility.Hidden)]
public bool Emphasized { get; set; }
public bool Emphasized
{
get => _emphasized;
set
{
if (_emphasized == value) return;
_emphasized = value;
Invalidate();
}
}
[Browsable(false)]
[DesignerSerializationVisibility(DesignerSerializationVisibility.Hidden)]
public bool Ghost { get; set; }
/// <summary>未选中时不铺底色(分段标签用),避免一排实心方块压过页面内容。</summary>
[Browsable(false)]
[DesignerSerializationVisibility(DesignerSerializationVisibility.Hidden)]
public bool Quiet { get; set; }
[Browsable(false)]
[DesignerSerializationVisibility(DesignerSerializationVisibility.Hidden)]
public int CornerRadiusDip { get; set; } = 8;
@@ -62,25 +87,57 @@ internal sealed class PanelChip : Control
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));
// 页签是单选语义,而且“当前页”必须一眼认出来:选中项用实心 Accent 底 + 深色字(和主按钮同一套),
// 未选中项既无底色也无描边。此前的半透明底 + 彩色描边和“悬停/按下”长得很像,
// 一悬停就出现两个看起来都被激活的页签。
var flat = Quiet || Ghost;
Color fill;
Color border;
var selectedInk = false;
if (Selected)
{
fill = _pressed ? ControlPaint.Dark(AccentColor, 0.10f)
: _hovered ? ControlPaint.Light(AccentColor, 0.14f)
: AccentColor;
border = fill;
selectedInk = true;
}
else if (Emphasized)
{
fill = UiTheme.WithAlpha(AccentColor, 54);
border = UiTheme.WithAlpha(AccentColor, 150);
}
else if (_pressed)
{
fill = UiTheme.CardPressed;
border = flat ? Color.Transparent : UiTheme.Border;
}
else if (_hovered)
{
// 悬停只给一层很浅的中性底,永不用强调色:否则“悬停项”和“选中项”会互相冒充。
fill = UiTheme.CardHover;
border = flat ? Color.Transparent : UiTheme.Border;
}
else if (flat)
{
fill = Color.Transparent;
border = Color.Transparent;
}
else
{
fill = UiTheme.Card;
border = UiTheme.BorderSoft;
}
if (fill.A > 0) UiTheme.FillRounded(graphics, bounds, radius, fill);
if (border.A > 0) UiTheme.StrokeRounded(graphics, bounds, radius, border, UiTheme.SF(this, 1f));
var foreground = !Enabled ? UiTheme.Faint
: Selected || Emphasized ? AccentColor
: Ghost ? UiTheme.Muted : UiTheme.Text;
: selectedInk ? UiTheme.Background
: Emphasized ? AccentColor
: flat ? (_hovered || _pressed ? UiTheme.Text : UiTheme.Muted)
: UiTheme.Text;
UiTheme.DrawText(graphics, Text, Font ?? UiTheme.Body, foreground,
Rectangle.Inflate(bounds, -UiTheme.S(this, 5), 0),
TextFormatFlags.HorizontalCenter | TextFormatFlags.VerticalCenter |
@@ -154,33 +211,443 @@ internal sealed class PanelChip : Control
}
}
internal sealed class ThemeButton : Button
/// <summary>
/// 圆角按钮。系统 Button 只会画方角并用系统主题色做悬停,深色界面里会露出浅色边缘,
/// 所以整块自绘;仍然实现 IButtonControl,可以继续当 AcceptButton/CancelButton 用。
/// </summary>
internal sealed class ThemeButton : Control, IButtonControl
{
private readonly bool _primary;
private bool _hovered;
private bool _pressed;
private bool _default;
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;
SetStyle(ControlStyles.UserPaint | ControlStyles.AllPaintingInWmPaint |
ControlStyles.OptimizedDoubleBuffer | ControlStyles.ResizeRedraw |
ControlStyles.SupportsTransparentBackColor | ControlStyles.Selectable, true);
TabStop = true;
Cursor = Cursors.Hand;
AccessibleRole = AccessibleRole.PushButton;
BackColor = Color.Transparent;
Font = UiTheme.BodyBold;
Margin = new Padding(8, 0, 0, 0);
ApplyMetrics();
}
public bool Primary => _primary;
[Browsable(false)]
[DesignerSerializationVisibility(DesignerSerializationVisibility.Hidden)]
public DialogResult DialogResult { get; set; }
public void NotifyDefault(bool value)
{
if (_default == value) return;
_default = value;
Invalidate();
}
public void PerformClick()
{
if (Enabled) OnClick(EventArgs.Empty);
}
private void ApplyMetrics()
{
Height = UiTheme.S(this, 32);
var text = TextRenderer.MeasureText(Text, Font).Width;
MinimumSize = new Size(UiTheme.S(this, 84), Height);
Width = Math.Max(MinimumSize.Width, text + UiTheme.S(this, 28));
}
protected override void OnHandleCreated(EventArgs e)
{
base.OnHandleCreated(e);
ApplyMetrics();
}
protected override void OnTextChanged(EventArgs e)
{
base.OnTextChanged(e);
if (IsHandleCreated) ApplyMetrics();
Invalidate();
}
protected override void OnPaint(PaintEventArgs e)
{
var graphics = e.Graphics;
UiTheme.Configure(graphics);
graphics.Clear(Parent?.BackColor ?? UiTheme.Background);
var bounds = new Rectangle(0, 0, Math.Max(1, Width - 1), Math.Max(1, Height - 1));
var radius = UiTheme.S(this, 7);
Color fill, border, ink;
if (!Enabled)
{
fill = UiTheme.Card;
border = UiTheme.BorderSoft;
ink = UiTheme.Faint;
}
else if (_primary)
{
fill = _pressed ? ControlPaint.Dark(UiTheme.Accent, 0.10f)
: _hovered ? ControlPaint.Light(UiTheme.Accent, 0.14f)
: UiTheme.Accent;
border = fill;
ink = UiTheme.Background;
}
else
{
fill = _pressed ? UiTheme.CardPressed : _hovered ? UiTheme.CardHover : UiTheme.Card;
border = _hovered ? UiTheme.Border : UiTheme.BorderSoft;
ink = UiTheme.Text;
}
UiTheme.FillRounded(graphics, bounds, radius, fill);
UiTheme.StrokeRounded(graphics, bounds, radius, border, UiTheme.SF(this, 1f));
UiTheme.DrawText(graphics, Text, Font ?? UiTheme.BodyBold, ink,
Rectangle.Inflate(bounds, -UiTheme.S(this, 6), 0),
TextFormatFlags.HorizontalCenter | TextFormatFlags.VerticalCenter |
TextFormatFlags.SingleLine | TextFormatFlags.EndEllipsis);
if ((_default || Focused) && ShowFocusCues && Enabled)
{
var focus = Rectangle.Inflate(bounds, -UiTheme.S(this, 3), -UiTheme.S(this, 3));
ControlPaint.DrawFocusRectangle(graphics, focus, _primary ? UiTheme.Background : UiTheme.Text, fill);
}
}
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) PerformClick();
}
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);
}
}
/// <summary>
/// 圆角输入框外壳:里面放一个无边框 TextBox,自己画圆角、边框和聚焦高亮,
/// 避免系统 TextBox 的方角与固定 1px 边线在深色卡片上显得像“一堆临时控件”。
/// </summary>
internal sealed class FieldBox : Panel
{
private readonly bool _multiline;
private bool _hovered;
public FieldBox(TextBox inner, bool multiline)
{
SetStyle(ControlStyles.UserPaint | ControlStyles.AllPaintingInWmPaint |
ControlStyles.OptimizedDoubleBuffer | ControlStyles.ResizeRedraw |
ControlStyles.SupportsTransparentBackColor, true);
_multiline = multiline;
Inner = inner;
BackColor = Color.Transparent;
Margin = new Padding(0);
Dock = DockStyle.Top;
inner.BorderStyle = BorderStyle.None;
inner.BackColor = UiTheme.Input;
inner.ForeColor = UiTheme.Text;
inner.Font = UiTheme.Body;
inner.Multiline = multiline;
inner.Margin = new Padding(0);
if (multiline)
{
inner.WordWrap = true;
inner.AcceptsReturn = true;
// 只有内容真装不下时才挂竖条,短内容不该出现滚动条,也不该被滚轮接管。
inner.ScrollBars = ScrollBars.None;
inner.TextChanged += (_, _) => UpdateMultilineScrollBars();
inner.SizeChanged += (_, _) => UpdateMultilineScrollBars();
}
// 文本框会自己吞掉滚轮(单行框永远滚不动,也照样吞):滚不动的时候把事件交给外层页面,
// 否则鼠标一停在任何一个输入框上,整页就滚不动了。
inner.MouseWheel += (_, args) => ForwardWheelToPage(args);
inner.GotFocus += (_, _) => Invalidate();
inner.LostFocus += (_, _) => Invalidate();
inner.EnabledChanged += (_, _) => Invalidate();
inner.MouseEnter += (_, _) => { _hovered = true; Invalidate(); };
inner.MouseLeave += (_, _) => { _hovered = false; Invalidate(); };
Controls.Add(inner);
Height = UiTheme.S(this, multiline ? 92 : 32);
}
public TextBox Inner { get; }
/// <summary>多行框真的出现竖条时,滚轮应当先归它,而不是滚动整页。</summary>
public bool AcceptsWheel => _multiline && Inner.ScrollBars == ScrollBars.Vertical;
/// <summary>多行框自己滚不动时,把滚轮让给外层页面(单行框一律让出)。</summary>
private void ForwardWheelToPage(MouseEventArgs args)
{
if (AcceptsWheel || args.Delta == 0) return;
// 页面没滚(内容本来就不高)就别把事件吃掉,让它继续往上传。
if (!DarkScrollPanel.ForwardWheelToPage(this, -args.Delta / 120)) return;
if (args is HandledMouseEventArgs handled) handled.Handled = true;
}
private void UpdateMultilineScrollBars()
{
if (!_multiline || Inner.IsDisposed) return;
var lineHeight = Math.Max(1, Inner.Font.Height);
var contentWidth = Math.Max(1, Inner.ClientSize.Width - UiTheme.S(this, 2));
var visualLines = 0;
foreach (var line in Inner.Lines)
{
var width = TextRenderer.MeasureText(line.Length == 0 ? " " : line, Inner.Font,
new Size(int.MaxValue, int.MaxValue),
TextFormatFlags.NoPadding | TextFormatFlags.SingleLine).Width;
visualLines += Math.Max(1, (int)Math.Ceiling(width / (double)contentWidth));
}
var needed = visualLines * lineHeight + UiTheme.S(this, 6);
var target = needed > Inner.ClientSize.Height ? ScrollBars.Vertical : ScrollBars.None;
if (Inner.ScrollBars == target) return;
Inner.ScrollBars = target;
Invalidate();
}
protected override void OnLayout(LayoutEventArgs e)
{
base.OnLayout(e);
var padX = UiTheme.S(this, 9);
var width = Math.Max(0, Width - padX - UiTheme.S(this, 8));
if (_multiline)
Inner.SetBounds(padX, UiTheme.S(this, 8), width, Math.Max(0, Height - UiTheme.S(this, 16)));
else
{
var height = Math.Min(Inner.PreferredHeight, Math.Max(0, Height - UiTheme.S(this, 6)));
Inner.SetBounds(padX, Math.Max(0, (Height - height) / 2), width, height);
}
UpdateMultilineScrollBars();
}
protected override void OnPaint(PaintEventArgs e)
{
var graphics = e.Graphics;
UiTheme.Configure(graphics);
graphics.Clear(Parent?.BackColor ?? UiTheme.Card);
var bounds = new Rectangle(0, 0, Math.Max(1, Width - 1), Math.Max(1, Height - 1));
var radius = UiTheme.S(this, 6);
var focused = Inner.Focused;
var enabled = Inner.Enabled;
UiTheme.FillRounded(graphics, bounds, radius, enabled ? UiTheme.Input : UiTheme.Background);
var border = !enabled ? UiTheme.BorderSoft
: focused ? UiTheme.Accent
: _hovered ? UiTheme.Border
: UiTheme.BorderSoft;
UiTheme.StrokeRounded(graphics, bounds, radius, border, UiTheme.SF(this, 1f));
}
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 (Inner.Enabled) Inner.Focus();
}
}
/// <summary>自绘勾选项:小圆角方框 + 文字,避免系统浅色复选框破坏深色整体。</summary>
internal sealed class CheckLine : Control
{
private bool _checked;
private bool _hovered;
public CheckLine()
{
SetStyle(ControlStyles.UserPaint | ControlStyles.AllPaintingInWmPaint |
ControlStyles.OptimizedDoubleBuffer | ControlStyles.ResizeRedraw |
ControlStyles.SupportsTransparentBackColor | ControlStyles.Selectable, true);
TabStop = true;
Cursor = Cursors.Hand;
AccessibleRole = AccessibleRole.CheckButton;
BackColor = Color.Transparent;
Font = UiTheme.Body;
Dock = DockStyle.Top;
Height = UiTheme.S(this, 26);
Margin = new Padding(0, 2, 0, 2);
}
[Browsable(false)]
[DesignerSerializationVisibility(DesignerSerializationVisibility.Hidden)]
public bool Checked
{
get => _checked;
set
{
if (_checked == value) return;
_checked = value;
Invalidate();
CheckedChanged?.Invoke(this, EventArgs.Empty);
}
}
public event EventHandler? CheckedChanged;
protected override void OnPaint(PaintEventArgs e)
{
var graphics = e.Graphics;
UiTheme.Configure(graphics);
graphics.Clear(Parent?.BackColor ?? UiTheme.Card);
var box = UiTheme.S(this, 16);
var top = (Height - box) / 2;
var rect = new Rectangle(0, Math.Max(0, top), box, box);
var radius = UiTheme.S(this, 4);
if (_checked)
{
UiTheme.FillRounded(graphics, rect, radius, Enabled ? (_hovered ? UiTheme.AccentSoft : UiTheme.Accent) : UiTheme.Paused);
using var pen = new Pen(UiTheme.Background, UiTheme.SF(this, 1.8f))
{
StartCap = LineCap.Round,
EndCap = LineCap.Round,
LineJoin = LineJoin.Round
};
var x = rect.X;
var y = rect.Y;
graphics.DrawLines(pen, new[]
{
new PointF(x + box * 0.26f, y + box * 0.53f),
new PointF(x + box * 0.43f, y + box * 0.70f),
new PointF(x + box * 0.75f, y + box * 0.32f)
});
}
else
{
UiTheme.FillRounded(graphics, rect, radius, Enabled ? UiTheme.Input : UiTheme.Background);
UiTheme.StrokeRounded(graphics, rect, radius,
!Enabled ? UiTheme.BorderSoft : _hovered ? UiTheme.Border : UiTheme.Border, UiTheme.SF(this, 1f));
}
var textBounds = new Rectangle(box + UiTheme.S(this, 9), 0, Math.Max(0, Width - box - UiTheme.S(this, 9)), Height);
UiTheme.DrawText(graphics, Text, Font ?? UiTheme.Body, Enabled ? UiTheme.Text : UiTheme.Faint,
textBounds, TextFormatFlags.Left | TextFormatFlags.VerticalCenter | TextFormatFlags.SingleLine |
TextFormatFlags.EndEllipsis);
if (Focused && ShowFocusCues)
ControlPaint.DrawFocusRectangle(graphics, Rectangle.Inflate(textBounds, 0, -UiTheme.S(this, 5)),
UiTheme.Text, Color.Transparent);
}
private void Toggle()
{
if (!Enabled) return;
Checked = !Checked;
}
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)) Toggle();
}
protected override bool IsInputKey(Keys keyData) =>
keyData is Keys.Space || base.IsInputKey(keyData);
protected override void OnKeyDown(KeyEventArgs e)
{
if (e.KeyCode == Keys.Space)
{
Toggle();
e.Handled = true;
return;
}
base.OnKeyDown(e);
}
protected override void OnGotFocus(EventArgs e)
{
Invalidate();
base.OnGotFocus(e);
}
protected override void OnLostFocus(EventArgs e)
{
Invalidate();
base.OnLostFocus(e);
}
}
internal sealed class WrapTextLabel : Label
@@ -197,23 +664,14 @@ internal sealed class WrapTextLabel : Label
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);
var bounds = new Rectangle(ClientRectangle.X + Padding.Left, ClientRectangle.Y + Padding.Top,
Math.Max(0, ClientRectangle.Width - Padding.Horizontal),
Math.Max(0, ClientRectangle.Height - Padding.Vertical));
// 与 RelayoutHelpLabels 的 TextRenderer.MeasureText 走同一条 GDI 路径,
// 否则“量出来 2 行、画出来 3 行”会把最后一行裁掉。
TextRenderer.DrawText(e.Graphics, Text, Font, bounds, ForeColor,
TextFormatFlags.WordBreak | TextFormatFlags.TextBoxControl | TextFormatFlags.NoPadding |
TextFormatFlags.Top | TextFormatFlags.Left);
}
protected override void OnTextChanged(EventArgs e)
@@ -254,16 +712,27 @@ internal sealed class ToggleSwitch : Control
public event EventHandler? UserToggled;
protected override void OnHandleCreated(EventArgs e)
{
base.OnHandleCreated(e);
// 句柄建好后 DeviceDpi 才是真值:按它把默认尺寸补成缩放后的值。
var target = new Size(UiTheme.S(this, 42), UiTheme.S(this, 22));
if (Size != target) Size = target;
}
protected override void OnPaint(PaintEventArgs e)
{
base.OnPaint(e);
UiTheme.Configure(e.Graphics);
var scale = UiTheme.Scale(this);
// 控件尺寸由宿主决定(可能已被 DPI 缩放),轨道永远不超过它,避免高 DPI 下被裁。
var trackWidth = Math.Min(38 * scale, Width);
var trackHeight = Math.Min(20 * scale, Height);
var track = new RectangleF(
(Width - 38 * scale) / 2f,
(Height - 20 * scale) / 2f,
38 * scale,
20 * scale);
(Width - trackWidth) / 2f,
(Height - trackHeight) / 2f,
trackWidth,
trackHeight);
var radius = track.Height / 2f;
var trackColor = !Enabled ? UiTheme.BorderSoft : _checked ? UiTheme.Accent : _hovered ? UiTheme.CardPressed : UiTheme.Border;
using (var path = new GraphicsPath())
@@ -449,7 +918,7 @@ internal sealed class CardPanel : Panel
ControlStyles.OptimizedDoubleBuffer | ControlStyles.ResizeRedraw, true);
BackColor = UiTheme.Card;
ForeColor = UiTheme.Text;
Padding = new Padding(14, 12, 14, 14);
Padding = new Padding(20, 16, 20, 18);
Margin = new Padding(0, 0, 0, 12);
}
@@ -458,7 +927,7 @@ internal sealed class CardPanel : Panel
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));
UiTheme.StrokeRounded(e.Graphics, bounds, UiTheme.S(this, 12), UiTheme.BorderSoft, UiTheme.SF(this, 1f));
}
}
@@ -660,7 +1129,8 @@ internal sealed class DarkComboBox : ComboBox
{
if (e.Index < 0 || e.Index >= Items.Count)
{
e.Graphics.FillRectangle(new SolidBrush(BackColor), e.Bounds);
using (var emptyBackground = new SolidBrush(BackColor))
e.Graphics.FillRectangle(emptyBackground, e.Bounds);
return;
}
@@ -685,6 +1155,7 @@ internal sealed class DarkScrollPanel : Panel
private readonly DarkScrollBar _bar = new();
private readonly List<Control> _stacks = [];
private int _offset;
private bool _forwardingWheel;
public DarkScrollPanel()
{
@@ -707,6 +1178,27 @@ internal sealed class DarkScrollPanel : Panel
public void ScrollByPixels(int delta) => SetOffset(_offset + delta);
/// <summary>
/// 一格滚轮(120)滚多少像素,文本框把滚轮让出来时按同一个步长走。
/// </summary>
public int StepPixels => UiTheme.S(this, 76);
/// <summary>
/// 把滚轮让给最近的页面容器(输入框自己滚不动时调用)。返回是否真的滚了。
/// </summary>
public static bool ForwardWheelToPage(Control? from, int notches)
{
if (notches == 0) return false;
for (var node = from?.Parent; node is not null; node = node.Parent)
{
if (node is not DarkScrollPanel page) continue;
if (page.ContentHeight <= page.ViewportHeight) return false; // 页面本来就不需要滚,交回上层。
page.ScrollByPixels(notches * page.StepPixels);
return true;
}
return false;
}
/// <summary>Page children are re-hosted into the offset content panel, preserving order.</summary>
protected override void OnControlAdded(ControlEventArgs e)
{
@@ -727,6 +1219,8 @@ internal sealed class DarkScrollPanel : Panel
protected override void OnLayout(LayoutEventArgs levent)
{
base.OnLayout(levent);
// 页面切换/重建会移除控件,留下已经 Dispose 的栈会让下面的 GetPreferredSize 抛异常。
_stacks.RemoveAll(stack => stack.IsDisposed || stack.Parent is null);
var width = ContentWidth;
var y = 0;
foreach (var stack in _stacks)
@@ -762,9 +1256,63 @@ internal sealed class DarkScrollPanel : Panel
private void ClampOffset() => SetOffset(_offset);
/// <summary>
/// 滚轮归属:鼠标停在“真的有竖条的多行输入框”上时先给它,其余情况才滚动整页;
/// 页面本身不需要滚动时不吃这个事件,交给父级处理。
/// </summary>
protected override void OnMouseWheel(MouseEventArgs e)
{
ScrollByPixels(-e.Delta / 120 * UiTheme.S(this, 76));
// 文本框滚到尽头时可能把消息再抛回父级,形成往返:转发期间直接丢弃。
if (_forwardingWheel) return;
if (FindFieldBox(HitTestDeep(this, e.Location)) is { AcceptsWheel: true } field)
{
_forwardingWheel = true;
try { ForwardWheel(field.Inner, e, PointToScreen(e.Location)); }
finally { _forwardingWheel = false; }
return;
}
if (ContentHeight <= ViewportHeight)
{
base.OnMouseWheel(e);
return;
}
ScrollByPixels(-e.Delta / 120 * StepPixels);
}
private static FieldBox? FindFieldBox(Control? control)
{
for (var node = control; node is not null; node = node.Parent)
if (node is FieldBox box) return box;
return null;
}
private static Control? HitTestDeep(Control parent, Point point)
{
for (var i = parent.Controls.Count - 1; i >= 0; i--)
{
var child = parent.Controls[i];
if (child is null || !child.Visible) continue;
var local = new Point(point.X - child.Left, point.Y - child.Top);
if (!child.ClientRectangle.Contains(local)) continue;
return HitTestDeep(child, local) ?? child;
}
return null;
}
private const int WmMouseWheel = 0x020A;
[DllImport("user32.dll", CharSet = CharSet.Auto)]
private static extern IntPtr SendMessage(IntPtr hWnd, int msg, IntPtr wParam, IntPtr lParam);
/// <summary>把滚轮消息转给内部 TextBox,让它在自己内部滚动而不是带动整页。</summary>
private static void ForwardWheel(TextBox target, MouseEventArgs e, Point screen)
{
if (!target.IsHandleCreated) return;
var coordinates = ((screen.Y & 0xFFFF) << 16) | (screen.X & 0xFFFF);
SendMessage(target.Handle, WmMouseWheel, (IntPtr)(e.Delta << 16), (IntPtr)coordinates);
}
}
@@ -852,6 +1400,9 @@ internal sealed class DarkScrollBar : Control
{
var jump = e.Y < ThumbBounds.Top ? -Viewport : Viewport;
Scrolled?.Invoke(this, Math.Clamp(Value + jump, 0, Maximum));
// 页跳之后基准要重置,否则接着拖动会从“跳之前”的位置算起,看起来像弹回去。
_dragStartY = e.Y;
_dragStartValue = Value;
}
Invalidate();
}
+154 -84
View File
@@ -16,10 +16,10 @@ internal sealed class SettingsForm : FramelessForm
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 CheckLine _preserveOriginalDirectDns = new();
private readonly CheckLine _directDnsAutomatic = new();
private readonly TextBox _directDnsServers = new();
private readonly CheckBox _useSocks = new();
private readonly CheckLine _useSocks = new();
private readonly TextBox _socksPort = new();
private readonly TextBox _testHosts = new();
private readonly TextBox _requiredTargets = new();
@@ -32,13 +32,13 @@ internal sealed class SettingsForm : FramelessForm
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 CheckLine _notifications = new();
private readonly CheckLine _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 PanelChip _closeButton = new() { Text = "✕", Ghost = true, CornerRadiusDip = 6 };
private readonly Label _message = new();
private readonly ToolTip _toolTip = new();
private readonly PanelChip[] _tabs = new PanelChip[3];
@@ -47,12 +47,16 @@ internal sealed class SettingsForm : FramelessForm
private readonly List<WrapTextLabel> _helpLabels = [];
private TableLayoutPanel _outer = null!;
private TableLayoutPanel _body = null!;
private TableLayoutPanel _footer = null!;
private Panel _pageHost = null!;
private Control _directDnsPolicy = null!;
private Control _directDnsServersField = null!;
private WrapTextLabel _preserveHint = null!;
private Control _socksPortField = null!;
private FieldBox _testHostsField = null!;
private FieldBox _directTestHostsField = null!;
private bool _saving;
private bool _closing;
private bool _refreshing;
private bool _dragging;
private Point _dragOffset;
@@ -67,8 +71,10 @@ internal sealed class SettingsForm : FramelessForm
Text = "设置 · 网关保活";
AutoScaleDimensions = new SizeF(96f, 96f);
AutoScaleMode = AutoScaleMode.Dpi;
StartPosition = FormStartPosition.CenterParent;
ClientSize = new Size(620, 560);
StartPosition = FormStartPosition.Manual;
// 设置是独立窗口,要在任务栏留一个按钮:托盘浮窗一收起来,用户还能点回来。
ShowInTaskbar = true;
ClientSize = new Size(720, 620);
BuildInterface();
FillValues();
}
@@ -125,7 +131,7 @@ internal sealed class SettingsForm : FramelessForm
BackColor = UiTheme.Background,
ColumnCount = 2,
RowCount = 1,
Padding = new Padding(18, 8, 16, 4)
Padding = new Padding(20, 10, 14, 6)
};
header.ColumnStyles.Add(new ColumnStyle(SizeType.Percent, 100f));
header.ColumnStyles.Add(new ColumnStyle(SizeType.AutoSize));
@@ -158,8 +164,13 @@ internal sealed class SettingsForm : FramelessForm
titles.Controls.Add(title, 0, 0);
titles.Controls.Add(subtitle, 0, 1);
_closeButton.Anchor = AnchorStyles.Right;
_closeButton.Margin = new Padding(8, 0, 0, 0);
_closeButton.Size = new Size(UiTheme.S(this, 30), UiTheme.S(this, 26));
_closeButton.AccessibleName = "关闭设置";
_toolTip.SetToolTip(_closeButton, "关闭设置(Esc)");
header.Controls.Add(titles, 0, 0);
header.Controls.Add(_closeButton, 1, 0);
header.Paint += (_, e) => DrawHairline(e.Graphics, header, header.ClientSize.Height - 1);
header.MouseDown += BeginDrag;
header.MouseMove += ContinueDrag;
@@ -181,22 +192,25 @@ internal sealed class SettingsForm : FramelessForm
ColumnCount = 1,
RowCount = 2,
BackColor = UiTheme.Background,
Padding = new Padding(14, 2, 14, 0)
Padding = new Padding(18, 6, 18, 0)
};
body.ColumnStyles.Add(new ColumnStyle(SizeType.Percent, 100f));
body.RowStyles.Add(new RowStyle(SizeType.Absolute, 42));
body.RowStyles.Add(new RowStyle(SizeType.Absolute, 40));
body.RowStyles.Add(new RowStyle(SizeType.Percent, 100f));
_body = body;
var tabRow = new TableLayoutPanel
{
Dock = DockStyle.Fill,
ColumnCount = 3,
ColumnCount = 4,
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.ColumnStyles.Add(new ColumnStyle(SizeType.AutoSize));
tabRow.ColumnStyles.Add(new ColumnStyle(SizeType.AutoSize));
tabRow.ColumnStyles.Add(new ColumnStyle(SizeType.AutoSize));
tabRow.ColumnStyles.Add(new ColumnStyle(SizeType.Percent, 100f));
tabRow.RowStyles.Add(new RowStyle(SizeType.Percent, 100f));
_pages[0] = MakePage();
@@ -213,11 +227,15 @@ internal sealed class SettingsForm : FramelessForm
var tab = new PanelChip
{
Text = names[i],
Font = UiTheme.BodyBold,
Dock = DockStyle.Fill,
Margin = new Padding(3, 2, 3, 4),
Font = UiTheme.Body,
Quiet = true,
CornerRadiusDip = 8,
Anchor = AnchorStyles.Left,
Margin = new Padding(0, 0, 8, 0),
AccessibleName = names[i]
};
var textWidth = TextRenderer.MeasureText(names[i], UiTheme.Body).Width;
tab.Size = new Size(Math.Max(UiTheme.S(this, 84), textWidth + UiTheme.S(this, 30)), UiTheme.S(this, 30));
tab.Click += (_, _) => SelectTab(index);
_tabs[i] = tab;
tabRow.Controls.Add(tab, i, 0);
@@ -230,6 +248,7 @@ internal sealed class SettingsForm : FramelessForm
Margin = new Padding(0)
};
foreach (var page in _pages) _pageHost.Controls.Add(page);
_pageHost.SizeChanged += (_, _) => RelayoutHelpLabels();
body.Controls.Add(tabRow, 0, 0);
body.Controls.Add(_pageHost, 0, 1);
return body;
@@ -257,6 +276,7 @@ internal sealed class SettingsForm : FramelessForm
_message.ForeColor = UiTheme.Muted;
_message.BackColor = UiTheme.BackgroundRaised;
_message.AutoEllipsis = true;
_footer = footer;
var actions = new FlowLayoutPanel
{
@@ -271,9 +291,19 @@ internal sealed class SettingsForm : FramelessForm
actions.Controls.Add(_saveButton);
footer.Controls.Add(_message, 0, 0);
footer.Controls.Add(actions, 1, 0);
footer.Paint += (_, e) => DrawHairline(e.Graphics, footer, 0);
return footer;
}
/// <summary>在宿主控件自己的一条边上画细线(底栏上沿、标题区下沿)。</summary>
private static void DrawHairline(Graphics graphics, Control host, int y)
{
if (y < 0) return;
UiTheme.Configure(graphics);
using var pen = new Pen(UiTheme.BorderSoft);
graphics.DrawLine(pen, 0, y, host.ClientSize.Width, y);
}
private DarkScrollPanel MakePage() => new()
{
Dock = DockStyle.Fill,
@@ -306,20 +336,20 @@ internal sealed class SettingsForm : FramelessForm
AddRow(page, MakeCard("网卡", "选择本机要管理的网络接口", Field("网络适配器", adapterRow)));
_toolTip.SetToolTip(_adapterBox, "只管理这里明确选择的网卡");
ConfigureText(_directGateway);
ConfigureText(_proxyGateway);
ConfigureText(_proxyDns);
var directGatewayField = ConfigureText(_directGateway);
var proxyGatewayField = ConfigureText(_proxyGateway);
var proxyDnsField = ConfigureText(_proxyDns);
AddRow(page, MakeCard("网关", "填写主路由和旁路由的 IPv4 地址",
Pair(Field("主路由网关", _directGateway), Field("旁路由网关", _proxyGateway)),
Field("旁路由 DNS", _proxyDns)));
Pair(Field("主路由网关", directGatewayField), Field("旁路由网关", proxyGatewayField)),
Field("旁路由 DNS", proxyDnsField)));
ConfigureCheck(_preserveOriginalDirectDns, "保留接管前的直连 DNS");
ConfigureCheck(_directDnsAutomatic, "直连时自动获取 DNS");
ConfigureText(_directDnsServers);
var directDnsServersField = ConfigureText(_directDnsServers);
_preserveHint = HelpLabel("恢复直连时使用接管前读取到、并在系统里生效的原始 DNS。");
_directDnsPolicy = NewPageStack();
AddRow((TableLayoutPanel)_directDnsPolicy, WrapControl(_directDnsAutomatic));
_directDnsServersField = Field("直连 DNS 服务器(逗号或空格分隔)", _directDnsServers);
_directDnsServersField = Field("直连 DNS 服务器(逗号或空格分隔)", directDnsServersField);
AddRow((TableLayoutPanel)_directDnsPolicy, _directDnsServersField);
var dnsCard = MakeCard("直连 DNS", "决定恢复主路由时使用的 DNS",
WrapControl(_preserveOriginalDirectDns), _preserveHint, _directDnsPolicy);
@@ -333,20 +363,20 @@ internal sealed class SettingsForm : FramelessForm
private Control BuildRulesPage()
{
var page = NewPageStack();
ConfigureText(_interval);
ConfigureText(_probeTimeout);
ConfigureText(_roundTimeout);
var intervalField = ConfigureText(_interval);
var probeTimeoutField = ConfigureText(_probeTimeout);
var roundTimeoutField = ConfigureText(_roundTimeout);
AddRow(page, MakeCard("检测节奏", "检测越频繁,故障发现越快",
Field("检测间隔(秒)", _interval),
Pair(Field("单次检查超时(毫秒)", _probeTimeout), Field("整轮检查预算(毫秒)", _roundTimeout))));
Field("检测间隔(秒)", intervalField),
Pair(Field("单次检查超时(毫秒)", probeTimeoutField), Field("整轮检查预算(毫秒)", roundTimeoutField))));
ConfigureText(_successThreshold);
ConfigureText(_failureThreshold);
ConfigureText(_recoveryCooldown);
ConfigureText(_failureCooldown);
var successThresholdField = ConfigureText(_successThreshold);
var failureThresholdField = ConfigureText(_failureThreshold);
var recoveryCooldownField = ConfigureText(_recoveryCooldown);
var failureCooldownField = ConfigureText(_failureCooldown);
AddRow(page, MakeCard("切换判定", "连续达到阈值并经过冷却后才会自动切换",
Pair(Field("连续成功几次后恢复旁路由", _successThreshold), Field("连续失败几次后切回主路由", _failureThreshold)),
Pair(Field("切回旁路由前的冷却(秒)", _recoveryCooldown), Field("切回主路由前的冷却(秒)", _failureCooldown))));
Pair(Field("连续成功几次后恢复旁路由", successThresholdField), Field("连续失败几次后切回主路由", failureThresholdField)),
Pair(Field("切回旁路由前的冷却(秒)", recoveryCooldownField), Field("切回主路由前的冷却(秒)", failureCooldownField))));
var note = HelpLabel("自动保活开启时才按这些规则切换。手动固定路径后,程序仍会继续监测,但不会自动改回另一条路径。");
AddRow(page, MakeCard("说明", string.Empty, note));
@@ -358,21 +388,20 @@ internal sealed class SettingsForm : FramelessForm
{
var page = NewPageStack();
ConfigureCheck(_useSocks, "同时检查 SOCKS5 可用性");
ConfigureText(_socksPort);
_socksPortField = Field("SOCKS5 端口", _socksPort);
_socksPortField = Field("SOCKS5 端口", ConfigureText(_socksPort));
AddRow(page, MakeCard("探测方式", "用于判断旁路由是否真的可用", WrapControl(_useSocks), _socksPortField));
ConfigureText(_testHosts, multiline: true);
ConfigureText(_requiredTargets);
_testHostsField = ConfigureText(_testHosts, multiline: true);
var requiredTargetsField = ConfigureText(_requiredTargets);
AddRow(page, MakeCard("旁路由探测目标", "走旁路由时验证这些网站",
Field("目标列表(每行一个,最多 5 个)", _testHosts),
Field("至少成功几个目标", _requiredTargets)));
Field("目标列表(每行一个,最多 5 个)", _testHostsField),
Field("至少成功几个目标", requiredTargetsField)));
ConfigureText(_directTestHosts, multiline: true);
ConfigureText(_requiredDirectTargets);
_directTestHostsField = ConfigureText(_directTestHosts, multiline: true);
var requiredDirectTargetsField = ConfigureText(_requiredDirectTargets);
AddRow(page, MakeCard("直连探测目标", "回退到主路由后验证这些网站",
Field("目标列表(每行一个,最多 5 个)", _directTestHosts),
Field("至少成功几个目标", _requiredDirectTargets)));
Field("目标列表(每行一个,最多 5 个)", _directTestHostsField),
Field("至少成功几个目标", requiredDirectTargetsField)));
ConfigureCheck(_notifications, "状态变化时发送通知");
ConfigureCheck(_restoreOnExit, "退出时恢复接管前的网络配置");
@@ -444,7 +473,7 @@ internal sealed class SettingsForm : FramelessForm
ColumnCount = 1,
RowCount = subtitle.Length == 0 ? 1 : 2,
BackColor = UiTheme.Card,
Margin = new Padding(0, 0, 0, 10)
Margin = new Padding(0, 0, 0, 14)
};
host.Controls.Add(new Label
{
@@ -461,8 +490,9 @@ internal sealed class SettingsForm : FramelessForm
Text = subtitle,
AutoSize = true,
Font = UiTheme.Small,
ForeColor = UiTheme.Faint,
BackColor = UiTheme.Card
ForeColor = UiTheme.Muted,
BackColor = UiTheme.Card,
Margin = new Padding(0, 2, 0, 0)
}, 0, 1);
}
return host;
@@ -477,7 +507,7 @@ internal sealed class SettingsForm : FramelessForm
AutoSizeMode = AutoSizeMode.GrowAndShrink,
ColumnCount = 1,
RowCount = 2,
Margin = new Padding(0, 0, 0, 8),
Margin = new Padding(0, 0, 0, 12),
BackColor = UiTheme.Card
};
stack.ColumnStyles.Add(new ColumnStyle(SizeType.Percent, 100f));
@@ -490,7 +520,7 @@ internal sealed class SettingsForm : FramelessForm
Font = UiTheme.Small,
ForeColor = UiTheme.Muted,
BackColor = UiTheme.Card,
Margin = new Padding(0, 0, 0, 3)
Margin = new Padding(0, 0, 0, 5)
}, 0, 0);
input.Margin = new Padding(0);
input.Dock = DockStyle.Top;
@@ -543,7 +573,7 @@ internal sealed class SettingsForm : FramelessForm
{
Text = text,
Dock = DockStyle.Top,
Height = 42,
Height = 22,
Font = UiTheme.Small,
ForeColor = UiTheme.Muted,
BackColor = UiTheme.Card,
@@ -559,13 +589,17 @@ internal sealed class SettingsForm : FramelessForm
_activeTab = index;
for (var i = 0; i < _tabs.Length; i++)
{
if (_tabs[i] is not null) _tabs[i].Selected = i == index;
if (_tabs[i] is not null)
{
_tabs[i].Selected = i == index;
_tabs[i].Invalidate();
}
if (_pages[i] is not null) _pages[i].Visible = i == index;
}
if (index >= 0 && index < _pages.Length && _pages[index].Visible)
_pages[index].BringToFront();
ApplyPageGutters();
RelayoutHelpLabels();
}
/// <summary>The page itself reserves the scrollbar gutter, so docked cards stay clear of it.</summary>
@@ -643,6 +677,7 @@ internal sealed class SettingsForm : FramelessForm
try
{
var adapters = await _controller.ListAdaptersAsync();
if (IsDisposed || Disposing) return;
FillAdapters(adapters, desired);
SetMessage(adapters.Count == 0 ? "没有找到可用网卡。" : "网卡列表已刷新,请确认选择。", adapters.Count == 0);
}
@@ -678,7 +713,10 @@ internal sealed class SettingsForm : FramelessForm
try
{
await _controller.SaveSettingsAsync(settings);
if (IsDisposed || Disposing) return;
SavedSettings = settings;
// 保存成功就关窗:_closing 让 OnFormClosing 放行这次由我们自己发起的关闭。
_closing = true;
DialogResult = DialogResult.OK;
Close();
}
@@ -798,13 +836,20 @@ internal sealed class SettingsForm : FramelessForm
private void SetMessage(string text, bool error)
{
if (IsDisposed || Disposing) return;
_message.Text = text;
_message.ForeColor = error ? UiTheme.Danger : UiTheme.Muted;
}
private void CloseWithCancel()
{
if (_saving) return;
if (_saving)
{
// 保存中不接受关闭,但要说清楚,别让人以为点了没反应。
SetMessage("正在保存,保存完成后窗口会自动关闭。", false);
return;
}
_closing = true;
DialogResult = DialogResult.Cancel;
Close();
}
@@ -813,7 +858,8 @@ internal sealed class SettingsForm : FramelessForm
protected override void OnFormClosing(FormClosingEventArgs e)
{
if (_saving && e.CloseReason == CloseReason.UserClosing)
// 保存中用户按 ✕ / Esc 一律拒绝;保存成功后由 SaveAsync 自己把 _closing 立起来再关。
if (_saving && !_closing && e.CloseReason == CloseReason.UserClosing)
{
e.Cancel = true;
return;
@@ -826,25 +872,60 @@ internal sealed class SettingsForm : FramelessForm
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));
var minimumWidth = Math.Min(UiTheme.S(this, 560), Math.Max(360, area.Width - margin * 2));
var minimumHeight = Math.Min(UiTheme.S(this, 440), Math.Max(320, 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));
Width = Math.Min(UiTheme.S(this, 720), Math.Max(minimumWidth, area.Width - margin * 2));
Height = Math.Min(UiTheme.S(this, 620), 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));
_outer.RowStyles[0].Height = UiTheme.S(this, 62);
_outer.RowStyles[2].Height = UiTheme.S(this, 64);
_body.RowStyles[0].Height = UiTheme.S(this, 44);
_testHostsField.Height = UiTheme.S(this, 96);
_directTestHostsField.Height = UiTheme.S(this, 96);
RelayoutHelpLabels();
// 与 ApplyPageGutters 保持一致:首屏和切页后的内容宽度不能差一个滚动条槽位。
foreach (var page in _pages) page.Padding = new Padding(0, UiTheme.S(this, 10), 0, UiTheme.S(this, 10));
ApplyPageGutters();
if (Owner is null)
Location = new Point(area.Left + (area.Width - Width) / 2, area.Top + (area.Height - Height) / 2);
Location = CenteredOn(area);
}
/// <summary>
/// 窗口一律居中到“鼠标所在的那块屏幕”。模态宿主可能只是托盘旁一块很小的浮窗,
/// 交给 CenterParent 会把设置窗塞到屏幕右下角、甚至压到浮窗下面点不到。
/// </summary>
protected override void OnShown(EventArgs e)
{
base.OnShown(e);
RelayoutHelpLabels();
var area = Screen.FromPoint(Cursor.Position).WorkingArea;
if (!area.Contains(Bounds)) Location = CenteredOn(area);
Activate();
BringToFront();
}
/// <summary>说明文字的换行行数取决于实际宽度,按实测高度收放,避免留白或截断。</summary>
private void RelayoutHelpLabels()
{
foreach (var label in _helpLabels)
{
var available = label.Parent is null
? label.Width
: label.Parent.ClientSize.Width - label.Margin.Horizontal;
if (available <= 0) continue;
var measured = TextRenderer.MeasureText(label.Text, label.Font,
new Size(available, int.MaxValue),
TextFormatFlags.WordBreak | TextFormatFlags.TextBoxControl | TextFormatFlags.NoPadding).Height;
var target = Math.Max(UiTheme.S(this, 20), measured + label.Padding.Vertical + UiTheme.S(this, 2));
if (label.Height != target) label.Height = target;
}
}
private Point CenteredOn(Rectangle area) => new(
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;
@@ -862,33 +943,22 @@ internal sealed class SettingsForm : FramelessForm
private void EndDrag(object? sender, MouseEventArgs e) => _dragging = false;
private static void ConfigureText(TextBox box, bool multiline = false)
private static FieldBox 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);
return new FieldBox(box, multiline) { AccessibleName = box.AccessibleName };
}
private static void ConfigureCheck(CheckBox check, string text)
private static CheckLine ConfigureCheck(CheckLine 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);
return check;
}
protected override void Dispose(bool disposing)
+17 -17
View File
@@ -8,17 +8,17 @@ 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 Background = Color.FromArgb(0x14, 0x17, 0x1C);
public static readonly Color BackgroundRaised = Color.FromArgb(0x19, 0x1D, 0x23);
public static readonly Color Card = Color.FromArgb(0x1C, 0x20, 0x27);
public static readonly Color CardHover = Color.FromArgb(0x23, 0x28, 0x31);
public static readonly Color CardPressed = Color.FromArgb(0x2B, 0x31, 0x3B);
public static readonly Color Input = Color.FromArgb(0x12, 0x15, 0x19);
public static readonly Color Border = Color.FromArgb(0x33, 0x3A, 0x45);
public static readonly Color BorderSoft = Color.FromArgb(0x26, 0x2C, 0x35);
public static readonly Color Text = Color.FromArgb(0xE9, 0xEE, 0xF6);
public static readonly Color Muted = Color.FromArgb(0x97, 0xA1, 0xAF);
public static readonly Color Faint = Color.FromArgb(0x6A, 0x74, 0x82);
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);
@@ -29,14 +29,14 @@ internal static class UiTheme
private static readonly FontFamily Family = PickFamily();
public static readonly Font Title = new(Family, 13.5f, FontStyle.Bold, GraphicsUnit.Point);
public static readonly Font Title = new(Family, 15f, 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 Heading = new(Family, 11.5f, FontStyle.Bold, GraphicsUnit.Point);
public static readonly Font Body = new(Family, 10f, FontStyle.Regular, GraphicsUnit.Point);
public static readonly Font BodyBold = new(Family, 10f, FontStyle.Bold, GraphicsUnit.Point);
public static readonly Font Small = new(Family, 9f, FontStyle.Regular, GraphicsUnit.Point);
public static readonly Font SmallBold = new(Family, 9f, 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);
@@ -1,3 +1,8 @@
<Project Sdk="Microsoft.NET.Sdk">
<ItemGroup><ProjectReference Include="../GatewayKeepalive.Core/GatewayKeepalive.Core.csproj" /></ItemGroup>
<ItemGroup>
<AssemblyAttribute Include="System.Runtime.CompilerServices.InternalsVisibleToAttribute">
<_Parameter1>GatewayKeepalive.Tests</_Parameter1>
</AssemblyAttribute>
</ItemGroup>
</Project>
+469 -20
View File
@@ -1,64 +1,488 @@
using System.Diagnostics;
using System.Globalization;
using System.Runtime.InteropServices;
using System.Security.Principal;
using System.Text;
using System.Xml;
using System.Xml.Linq;
namespace GatewayKeepalive.Windows;
/// <summary>
/// 登录自启:为当前用户注册一个“登录时触发 + 最高权限”的计划任务,登录后静默起托盘,不弹 UAC。
/// 注册主路径交给 schtasks.exe(它的参数解析与权限处理是系统自带实现,比手工拼 COM 参数稳),
/// 失败时回退到任务计划 COM 接口;两条路径都会写入日志,便于按日志定位。
/// 读回状态用 COM 查询(不依赖控制台编码),注册后一定读回校验,拒绝“报成功但实际没生效”。
/// </summary>
public sealed class LoginStartup
{
private static string UserSid { get { using var identity = WindowsIdentity.GetCurrent(); return identity.User!.Value; } }
private static readonly XNamespace TaskNamespace = "http://schemas.microsoft.com/windows/2004/02/mit/task";
private readonly Action<string>? _log;
private string? _lastFailureSignature;
public LoginStartup(Action<string>? log = null) => _log = log;
private static string UserSid
{
get
{
using var identity = WindowsIdentity.GetCurrent();
return identity.User!.Value;
}
}
private static string TaskName => "GatewayKeepalive-" + UserSid;
public bool IsEnabled
{
get
{
object? service = null, root = null, task = null;
try
{
service = Connect(); root = ((dynamic)service).GetFolder("\\"); task = ((dynamic)root).GetTask(TaskName);
return (bool)((dynamic)task).Enabled;
var enabled = QueryEnabled();
_lastFailureSignature = null;
return enabled;
}
catch (Exception exception)
{
LogFailure(exception);
return false;
}
catch (COMException e) when ((uint)e.HResult == 0x80070002 || (uint)e.HResult == 0x8004130f) { return false; }
finally { Release(task); Release(root); Release(service); }
}
}
/// <summary>
/// “任务不存在”。晚绑定的 COM 抛出的并不总是 <see cref="COMException"/>:CLR 会按 HRESULT 翻译,
/// 0x80070002 变成 <see cref="FileNotFoundException"/>、0x80070003 变成 <see cref="DirectoryNotFoundException"/>。
/// 早先按类型抓 0x80070002,于是一次都没抓到——关自启时读回失败、界面报“找不到文件或目录”。
/// 所以这里只认 HResult,不认类型。
/// </summary>
internal static bool IsTaskMissing(Exception exception)
{
var code = (uint)exception.HResult;
return code is 0x80070002 or 0x80070003 or 0x8004130f;
}
/// <summary>同一个失败签名只记一次,避免每个检查周期都往日志里刷同一条。</summary>
private void LogFailure(Exception exception)
{
var signature = exception.GetType().FullName + "|" + exception.HResult.ToString("X8", CultureInfo.InvariantCulture);
if (signature == _lastFailureSignature) return;
_lastFailureSignature = signature;
Log($"读取登录自启状态失败:{exception.GetType().Name} 0x{(uint)exception.HResult:X8} {exception.Message}");
}
public void SetEnabled(bool enabled)
{
if (!new WindowsNetworkBackend().IsAdministrator) throw new UnauthorizedAccessException("更改最高权限登录任务需要管理员权限。");
var isAdministrator = new WindowsNetworkBackend().IsAdministrator;
Log($"登录自启写入请求:启用={(enabled ? "是" : "否")} 管理员={isAdministrator} 进程={Environment.ProcessPath}");
if (!isAdministrator)
throw new UnauthorizedAccessException("更改最高权限登录任务需要管理员权限。");
var executable = Environment.ProcessPath ?? throw new InvalidOperationException("无法确定程序路径。");
if (Path.GetFileNameWithoutExtension(executable).Equals("dotnet", StringComparison.OrdinalIgnoreCase)) throw new InvalidOperationException("请从发布后的 GatewayKeepalive.exe 配置登录启动。");
object? service = null, root = null, definition = null;
if (Path.GetFileNameWithoutExtension(executable).Equals("dotnet", StringComparison.OrdinalIgnoreCase))
throw new InvalidOperationException("请从发布后的 GatewayKeepalive.exe 配置登录启动。");
if (!enabled)
{
RemoveTask();
if (QueryEnabled()) throw new InvalidOperationException("登录自启任务仍然存在,删除没有生效。");
Log("已取消登录自启。");
return;
}
var failures = new List<string>();
try
{
service = Connect(); root = ((dynamic)service).GetFolder("\\");
if (!enabled)
CreateWithSchtasks(executable);
if (QueryEnabled())
{
try { ((dynamic)root).DeleteTask(TaskName, 0); }
catch (COMException e) when ((uint)e.HResult == 0x80070002) { }
Log($"已创建登录自启任务:{TaskName} → {executable} --minimized");
return;
}
failures.Add("schtasks 注册后读回任务未启用");
}
catch (Exception exception)
{
Log("schtasks 注册登录自启失败:" + exception.Message);
failures.Add("schtasks:" + exception.Message);
}
try
{
CreateWithCom(executable);
if (QueryEnabled())
{
Log($"已通过任务计划接口创建登录自启任务:{TaskName} → {executable} --minimized");
return;
}
failures.Add("任务计划接口注册后读回任务未启用");
}
catch (Exception exception)
{
Log("任务计划接口注册登录自启失败:" + exception.Message);
failures.Add("任务计划接口:" + exception.Message);
}
throw new InvalidOperationException("两条注册路径都没有成功(" + string.Join(";", failures) + ")。");
}
/// <summary>任务里登记的可执行文件;任务不存在时返回 null。</summary>
public string? RegisteredExecutable => ReadRegisteredAction()?.Executable;
private sealed record RegisteredTask(string Executable, string Arguments);
/// <summary>任务里登记的动作(可执行文件 + 参数);任务不存在时返回 null。</summary>
private RegisteredTask? ReadRegisteredAction()
{
object? service = null, root = null, task = null, definition = null, actions = null, action = null;
try
{
service = Connect();
root = ((dynamic)service).GetFolder("\\");
task = ((dynamic)root).GetTask(TaskName);
definition = ((dynamic)task).Definition;
actions = ((dynamic)definition).Actions;
if ((int)((dynamic)actions).Count == 0) return null;
action = ((dynamic)actions).Item(1);
var path = (string?)((dynamic)action).Path;
if (string.IsNullOrEmpty(path)) return null;
var arguments = (string?)((dynamic)action).Arguments ?? string.Empty;
return new RegisteredTask(path, arguments);
}
catch (Exception exception) when (IsTaskMissing(exception))
{
return null;
}
catch (Exception exception)
{
Log("读取登录自启任务动作失败:" + exception.Message);
return null;
}
finally
{
Release(action);
Release(actions);
Release(definition);
Release(task);
Release(root);
Release(service);
}
}
/// <summary>
/// 程序换了位置(重新发布、换目录)之后,旧任务会“显示已开启却起不来”。
/// 启动时若发现任务指向的不是当前可执行文件,就把它改写到当前路径。
/// </summary>
public void RepairIfStale()
{
var executable = Environment.ProcessPath;
if (string.IsNullOrEmpty(executable)) return;
if (Path.GetFileNameWithoutExtension(executable).Equals("dotnet", StringComparison.OrdinalIgnoreCase)) return;
try
{
if (!IsEnabled) return;
var registered = ReadRegisteredAction();
if (registered is null) return;
var samePath = string.Equals(registered.Executable, executable, StringComparison.OrdinalIgnoreCase);
var hasMinimized = registered.Arguments.Contains("--minimized", StringComparison.OrdinalIgnoreCase);
if (samePath && hasMinimized) return;
if (!new WindowsNetworkBackend().IsAdministrator)
{
Log($"登录自启任务指向 {registered.Executable} {registered.Arguments},与当前程序 {executable} --minimized 不一致;需要管理员权限才能改写。");
return;
}
SetEnabled(true);
Log($"登录自启任务原为 {registered.Executable} {registered.Arguments},已改写为 {executable} --minimized。");
}
catch (Exception exception)
{
Log("校正登录自启任务失败:" + exception.Message);
}
}
/// <summary>把任务定义写成 XML 交给 schtasks.exe,避免手工拼 /TR 时的引号转义问题。</summary>
private void CreateWithSchtasks(string executable)
{
var file = Path.Combine(Path.GetTempPath(), "gatewaykeepalive-autostart-" + Guid.NewGuid().ToString("N") + ".xml");
try
{
WriteTaskXml(file, executable);
var info = new ProcessStartInfo("schtasks.exe")
{
UseShellExecute = false,
CreateNoWindow = true,
RedirectStandardOutput = true,
RedirectStandardError = true
};
foreach (var argument in new[] { "/Create", "/F", "/TN", TaskName, "/XML", file }) info.ArgumentList.Add(argument);
info.StandardOutputEncoding = ConsoleEncoding();
info.StandardErrorEncoding = ConsoleEncoding();
using var process = Process.Start(info) ?? throw new InvalidOperationException("无法启动 schtasks.exe。");
// 两条流并发读完再等退出:顺序 ReadToEnd 会被写满的 stderr 缓冲区卡住,20 秒超时就形同虚设。
var outputTask = process.StandardOutput.ReadToEndAsync();
var errorTask = process.StandardError.ReadToEndAsync();
if (!process.WaitForExit(20000))
{
try { process.Kill(entireProcessTree: true); } catch (Exception killException) when (killException is InvalidOperationException or System.ComponentModel.Win32Exception) { }
throw new TimeoutException("schtasks.exe 创建任务超时。");
}
var output = outputTask.GetAwaiter().GetResult();
var error = errorTask.GetAwaiter().GetResult();
if (process.ExitCode != 0)
{
var detail = string.Join(" ", new[] { error, output }
.Select(x => x.Trim())
.Where(x => x.Length > 0));
throw new InvalidOperationException($"schtasks 返回 {process.ExitCode}:{Ui(Text(detail))}");
}
}
finally
{
try { if (File.Exists(file)) File.Delete(file); }
catch (Exception cleanupException) when (cleanupException is IOException or UnauthorizedAccessException) { }
}
}
private static void WriteTaskXml(string path, string executable)
{
var settings = new XmlWriterSettings { Encoding = Encoding.Unicode, Indent = true };
using var writer = XmlWriter.Create(path, settings);
TaskDocument(executable).Save(writer);
}
/// <summary>任务 XML 的文本形式(离线可验证,不需要真的注册任务)。</summary>
internal static string BuildTaskXml(string executable) => TaskDocument(executable).ToString();
private static XDocument TaskDocument(string executable)
{
var workingDirectory = Path.GetDirectoryName(executable) ?? string.Empty;
return new XDocument(
new XDeclaration("1.0", "UTF-16", null),
new XElement(TaskNamespace + "Task",
new XAttribute("version", "1.2"),
new XElement(TaskNamespace + "RegistrationInfo",
new XElement(TaskNamespace + "Description", "当前用户登录后启动网关保活托盘工具(最高权限,免 UAC 提示)。")),
new XElement(TaskNamespace + "Triggers",
new XElement(TaskNamespace + "LogonTrigger",
new XElement(TaskNamespace + "Enabled", "true"),
new XElement(TaskNamespace + "UserId", UserSid))),
new XElement(TaskNamespace + "Principals",
new XElement(TaskNamespace + "Principal",
new XAttribute("id", "Author"),
new XElement(TaskNamespace + "UserId", UserSid),
new XElement(TaskNamespace + "LogonType", "InteractiveToken"),
new XElement(TaskNamespace + "RunLevel", "HighestAvailable"))),
new XElement(TaskNamespace + "Settings",
new XElement(TaskNamespace + "MultipleInstancesPolicy", "IgnoreNew"),
new XElement(TaskNamespace + "DisallowStartIfOnBatteries", "false"),
new XElement(TaskNamespace + "StopIfGoingOnBatteries", "false"),
new XElement(TaskNamespace + "AllowHardTerminate", "true"),
new XElement(TaskNamespace + "StartWhenAvailable", "true"),
new XElement(TaskNamespace + "RunOnlyIfNetworkAvailable", "false"),
new XElement(TaskNamespace + "IdleSettings",
new XElement(TaskNamespace + "StopOnIdleEnd", "false"),
new XElement(TaskNamespace + "RestartOnIdle", "false")),
new XElement(TaskNamespace + "AllowStartOnDemand", "true"),
new XElement(TaskNamespace + "ExecutionTimeLimit", "PT0S"),
new XElement(TaskNamespace + "Enabled", "true"),
new XElement(TaskNamespace + "Hidden", "false"),
new XElement(TaskNamespace + "RunOnlyIfIdle", "false"),
new XElement(TaskNamespace + "WakeToRun", "false"),
new XElement(TaskNamespace + "Priority", "7")),
new XElement(TaskNamespace + "Actions",
new XAttribute("Context", "Author"),
new XElement(TaskNamespace + "Exec",
new XElement(TaskNamespace + "Command", executable),
new XElement(TaskNamespace + "Arguments", "--minimized"),
new XElement(TaskNamespace + "WorkingDirectory", workingDirectory)))));
}
private void CreateWithCom(string executable)
{
object? service = null, root = null, definition = null, registered = null;
try
{
service = Connect();
root = ((dynamic)service).GetFolder("\\");
definition = ((dynamic)service).NewTask(0);
dynamic task = definition;
task.RegistrationInfo.Description = "当前用户登录后启动网关保活托盘工具。";
task.Principal.UserId = UserSid;
task.Principal.LogonType = 3; // TASK_LOGON_INTERACTIVE_TOKEN: show tray in this user's session.
task.Principal.LogonType = 3; // TASK_LOGON_INTERACTIVE_TOKEN:在本用户会话里显示托盘。
task.Principal.RunLevel = 1; // TASK_RUNLEVEL_HIGHEST
task.Settings.DisallowStartIfOnBatteries = false;
task.Settings.StopIfGoingOnBatteries = false;
task.Settings.StartWhenAvailable = true;
task.Settings.ExecutionTimeLimit = "PT0S";
task.Settings.MultipleInstances = 2;
dynamic trigger = task.Triggers.Create(9);
dynamic trigger = task.Triggers.Create(9); // TASK_TRIGGER_LOGON
trigger.UserId = UserSid;
dynamic action = task.Actions.Create(0);
dynamic action = task.Actions.Create(0); // TASK_ACTION_EXEC
action.Path = executable;
action.Arguments = "--minimized";
action.WorkingDirectory = Path.GetDirectoryName(executable);
object registered = ((dynamic)root).RegisterTaskDefinition(TaskName, task, 6, UserSid, null, 3, null);
Release(registered); Release(action); Release(trigger);
registered = ((dynamic)root).RegisterTaskDefinition(TaskName, task, 6, UserSid, null, 3, null);
Release(action);
Release(trigger);
}
finally
{
Release(registered);
Release(definition);
Release(root);
Release(service);
}
}
private void RemoveTask()
{
var failures = new List<string>();
try
{
var info = new ProcessStartInfo("schtasks.exe")
{
UseShellExecute = false,
CreateNoWindow = true,
RedirectStandardOutput = true,
RedirectStandardError = true
};
foreach (var argument in new[] { "/Delete", "/F", "/TN", TaskName }) info.ArgumentList.Add(argument);
info.StandardOutputEncoding = ConsoleEncoding();
info.StandardErrorEncoding = ConsoleEncoding();
using var process = Process.Start(info) ?? throw new InvalidOperationException("无法启动 schtasks.exe。");
process.StandardOutput.ReadToEnd();
process.StandardError.ReadToEnd();
if (!process.WaitForExit(20000))
{
try { process.Kill(entireProcessTree: true); } catch (Exception killException) when (killException is InvalidOperationException or System.ComponentModel.Win32Exception) { }
failures.Add("schtasks 删除任务超时");
}
else if (process.ExitCode != 0 && QueryEnabled())
{
failures.Add($"schtasks 返回 {process.ExitCode}");
}
}
catch (Exception exception)
{
failures.Add("schtasks:" + exception.Message);
}
if (failures.Count > 0)
{
object? service = null, root = null;
try
{
service = Connect();
root = ((dynamic)service).GetFolder("\\");
((dynamic)root).DeleteTask(TaskName, 0);
failures.Clear();
}
catch (Exception exception) when (IsTaskMissing(exception))
{
failures.Clear(); // 任务本来就不存在,等同于删除成功。
}
catch (Exception exception)
{
Log("任务计划接口删除登录自启失败:" + exception.Message);
}
finally
{
Release(root);
Release(service);
}
}
if (failures.Count > 0) throw new InvalidOperationException("删除登录自启任务失败(" + string.Join(";", failures) + ")。");
}
/// <summary>
/// 读回任务状态。任务计划 COM 偶尔读不到“刚建好/刚改过”的任务(同一台机器上 schtasks 却看得见),
/// 所以 COM 读不到时用 schtasks 复核一次:宁可多花十几毫秒,也不能把状态读错——
/// 读错会让界面把“已启用”显示成关闭,用户再点一次又会重复注册任务。
/// </summary>
private static bool QueryEnabled()
{
var viaCom = QueryViaCom(out var comError);
if (viaCom == true) return true;
var viaSchtasks = QueryViaSchtasks();
if (viaSchtasks is not null) return viaSchtasks.Value;
if (comError is not null) throw comError; // 两条路都读不到,才向上报错。
return false; // 任务确实不存在。
}
private static bool? QueryViaCom(out Exception? error)
{
error = null;
object? service = null, root = null, task = null;
try
{
service = Connect();
root = ((dynamic)service).GetFolder("\\");
task = ((dynamic)root).GetTask(TaskName);
return (bool)((dynamic)task).Enabled;
}
catch (Exception exception) when (IsTaskMissing(exception))
{
return null;
}
catch (Exception exception)
{
error = exception;
return null;
}
finally
{
Release(task);
Release(root);
Release(service);
}
}
/// <summary>用 schtasks /Query /XML 复核任务是否存在且启用;null 表示问不出来。</summary>
private static bool? QueryViaSchtasks()
{
try
{
var info = new ProcessStartInfo("schtasks.exe")
{
UseShellExecute = false,
CreateNoWindow = true,
RedirectStandardOutput = true,
RedirectStandardError = true,
StandardOutputEncoding = ConsoleEncoding(),
StandardErrorEncoding = ConsoleEncoding()
};
foreach (var argument in new[] { "/Query", "/TN", TaskName, "/XML" }) info.ArgumentList.Add(argument);
using var process = Process.Start(info);
if (process is null) return null;
var output = process.StandardOutput.ReadToEndAsync();
_ = process.StandardError.ReadToEndAsync();
if (!process.WaitForExit(10000))
{
try { process.Kill(entireProcessTree: true); } catch (Exception killException) when (killException is InvalidOperationException or System.ComponentModel.Win32Exception) { }
return null;
}
if (process.ExitCode != 0) return null;
var document = XDocument.Parse(output.GetAwaiter().GetResult());
var enabled = document.Root?
.Element(TaskNamespace + "Settings")?
.Element(TaskNamespace + "Enabled")?
.Value;
return enabled is null || !enabled.Equals("false", StringComparison.OrdinalIgnoreCase);
}
catch (Exception exception) when (exception is not OutOfMemoryException)
{
return null;
}
finally { Release(definition); Release(root); Release(service); }
}
private static object Connect()
@@ -68,5 +492,30 @@ public sealed class LoginStartup
service.Connect();
return service;
}
private static void Release(object? value) { if (value is not null && Marshal.IsComObject(value)) Marshal.ReleaseComObject(value); }
private static void Release(object? value)
{
if (value is not null && Marshal.IsComObject(value)) Marshal.ReleaseComObject(value);
}
private static string Text(string value) => string.IsNullOrWhiteSpace(value) ? "无输出。" : value.Replace("\r", " ").Replace("\n", " ").Trim();
private static readonly Lazy<Encoding> ConsoleText = new(() =>
{
try
{
Encoding.RegisterProvider(CodePagesEncodingProvider.Instance);
return Encoding.GetEncoding(CultureInfo.CurrentCulture.TextInfo.OEMCodePage);
}
catch (Exception exception) when (exception is ArgumentException or NotSupportedException)
{
return Encoding.UTF8;
}
});
private static Encoding ConsoleEncoding() => ConsoleText.Value;
private static string Ui(string value) => value;
private void Log(string message) => _log?.Invoke(message);
}