重做设置界面并修复登录自启:页签单选、滚轮归属、任务缺失判定、发布去掉自带运行时
设置窗口 - 改为独立非模态窗口(进任务栏、可最小化),不再被托盘浮窗挡住导致点不到 - 位置改为按屏幕可用区域居中裁剪,不再默认落在右下角 - 三个页签重构为卡片式布局:统一卡片内边距、页签行独立成行、正文左右留白、 帮助文字按实际换行测量高度、标题栏与底栏加分隔线 - 直连 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:
@@ -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()
|
||||
{
|
||||
|
||||
@@ -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();
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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();
|
||||
}
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user