From 92beb46f222a9a0cf69cb3879a7c5fcb65a065ef Mon Sep 17 00:00:00 2001 From: home-PC Date: Sat, 9 May 2026 23:07:44 +0800 Subject: [PATCH] =?UTF-8?q?Phase=204:=20=E5=85=A8=E5=B1=80=E5=BF=AB?= =?UTF-8?q?=E6=8D=B7=E9=94=AE=E6=8B=A6=E6=88=AA=20(Win32=20API)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - 新增 HotKeyManager (Helpers/), 使用 user32.dll RegisterHotKey/UnregisterHotKey 注册系统级快捷键 - 支持 Ctrl/Alt/Shift/Win 修饰键组合, 单字符键和命名键(F1-F12等)解析 - MainWindow.OnSourceInitialized 挂载 HwndSource.AddHook 拦截 WM_HOTKEY 消息 - 启动时自动注册所有含快捷键的工具, 工具增删改后自动重新注册 - 快捷键冲突时记录 Win32 错误码, 无效格式打印警告 - 新增 12 个 HotKeyManager 单元测试 (73 tests total) --- .../Helpers/HotKeyManagerTests.cs | 135 ++++++++++++++ .../ViewModels/MainViewModelTests.cs | 6 +- PersonalToolBox/App.xaml.cs | 1 + PersonalToolBox/Helpers/HotKeyManager.cs | 174 ++++++++++++++++++ PersonalToolBox/ViewModels/MainViewModel.cs | 18 +- PersonalToolBox/Views/MainWindow.xaml.cs | 32 +++- 6 files changed, 362 insertions(+), 4 deletions(-) create mode 100644 PersonalToolBox.Tests/Helpers/HotKeyManagerTests.cs create mode 100644 PersonalToolBox/Helpers/HotKeyManager.cs diff --git a/PersonalToolBox.Tests/Helpers/HotKeyManagerTests.cs b/PersonalToolBox.Tests/Helpers/HotKeyManagerTests.cs new file mode 100644 index 0000000..749c54a --- /dev/null +++ b/PersonalToolBox.Tests/Helpers/HotKeyManagerTests.cs @@ -0,0 +1,135 @@ +using Moq; +using PersonalToolBox.Helpers; +using PersonalToolBox.Models; +using PersonalToolBox.Services; + +namespace PersonalToolBox.Tests.Helpers; + +public class HotKeyManagerTests +{ + [Fact] + public void TryParseHotKey_ValidCtrlAltT_ReturnsCorrectModifiersAndVk() + { + var result = HotKeyManager.TryParseHotKey("Ctrl+Alt+T", out uint mod, out uint vk); + + Assert.True(result); + Assert.NotEqual(0u, mod); + // 'T' → VK = 0x54 + Assert.Equal((uint)'T', vk); + } + + [Fact] + public void TryParseHotKey_ValidCtrlShiftF1_ReturnsCorrectVk() + { + var result = HotKeyManager.TryParseHotKey("Ctrl+Shift+F1", out uint mod, out uint vk); + + Assert.True(result); + Assert.NotEqual(0u, mod); + // F1 → VK_F1 = 0x70 + Assert.Equal(0x70u, vk); + } + + [Fact] + public void TryParseHotKey_OnlyModifier_ReturnsFalse() + { + var result = HotKeyManager.TryParseHotKey("Ctrl", out _, out _); + + Assert.False(result); + } + + [Fact] + public void TryParseHotKey_OnlyKey_ReturnsFalse() + { + var result = HotKeyManager.TryParseHotKey("T", out _, out _); + + Assert.False(result); + } + + [Fact] + public void TryParseHotKey_Empty_ReturnsFalse() + { + var result = HotKeyManager.TryParseHotKey("", out _, out _); + Assert.False(result); + } + + [Fact] + public void TryParseHotKey_Null_ReturnsFalse() + { + var result = HotKeyManager.TryParseHotKey(null!, out _, out _); + Assert.False(result); + } + + [Fact] + public void TryParseHotKey_Whitespace_ReturnsFalse() + { + var result = HotKeyManager.TryParseHotKey(" ", out _, out _); + Assert.False(result); + } + + [Fact] + public void TryParseHotKey_WinKey_B_ReturnsCorrectVk() + { + var result = HotKeyManager.TryParseHotKey("Win+B", out uint mod, out uint vk); + + Assert.True(result); + Assert.Equal((uint)'B', vk); + } + + [Fact] + public void TryParseHotKey_UnknownModifier_ReturnsFalse() + { + var result = HotKeyManager.TryParseHotKey("Foo+T", out _, out _); + + Assert.False(result); + } + + [Fact] + public void RegisterAll_SkipsEmptyHotKeys() + { + var logMock = new Mock(); + var processMock = new Mock(); + var manager = new HotKeyManager(logMock.Object, processMock.Object); + + var tools = new[] + { + new ToolItem { Name = "NoHotkey", HotKey = "" }, + new ToolItem { Name = "BlankHotkey", HotKey = " " } + }; + + // Should not throw + manager.RegisterAll(tools); + } + + [Fact] + public void RegisterAll_InvalidFormat_LogsWarning() + { + var logMock = new Mock(); + var processMock = new Mock(); + var manager = new HotKeyManager(logMock.Object, processMock.Object); + + // Initialize needs a hwnd, but RegisterAll calls Register which checks _hwnd + // So calling RegisterAll without Initialize should be a no-op + var tools = new[] + { + new ToolItem { Name = "Bad", HotKey = "not-a-valid-key" } + }; + + // hwnd is IntPtr.Zero, so should skip silently + manager.RegisterAll(tools); + + // hwnd is zero, so Register is skipped entirely + logMock.Verify(x => x.Warning(It.IsAny()), Times.Never); + } + + [Fact] + public void TryHandleMessage_NonWMHOTKEY_ReturnsFalse() + { + var logMock = new Mock(); + var processMock = new Mock(); + var manager = new HotKeyManager(logMock.Object, processMock.Object); + + var handled = manager.TryHandleMessage(9999, IntPtr.Zero, IntPtr.Zero); + + Assert.False(handled); + } +} diff --git a/PersonalToolBox.Tests/ViewModels/MainViewModelTests.cs b/PersonalToolBox.Tests/ViewModels/MainViewModelTests.cs index dc2a060..b888ff7 100644 --- a/PersonalToolBox.Tests/ViewModels/MainViewModelTests.cs +++ b/PersonalToolBox.Tests/ViewModels/MainViewModelTests.cs @@ -1,5 +1,6 @@ using Moq; using Microsoft.Extensions.DependencyInjection; +using PersonalToolBox.Helpers; using PersonalToolBox.Models; using PersonalToolBox.Services; using PersonalToolBox.ViewModels; @@ -12,6 +13,7 @@ public class MainViewModelTests private readonly Mock _dataServiceMock = new(); private readonly Mock _processServiceMock = new(); private readonly Mock _serviceProviderMock = new(); + private readonly Mock _hotKeyManagerMock; private readonly AppConfig _config; public MainViewModelTests() @@ -33,11 +35,13 @@ public class MainViewModelTests ] }; + _hotKeyManagerMock = new Mock(_logServiceMock.Object, _processServiceMock.Object); _dataServiceMock.Setup(d => d.Config).Returns(_config); } private MainViewModel CreateViewModel() => - new(_logServiceMock.Object, _dataServiceMock.Object, _processServiceMock.Object, _serviceProviderMock.Object); + new(_logServiceMock.Object, _dataServiceMock.Object, _processServiceMock.Object, + _serviceProviderMock.Object, _hotKeyManagerMock.Object); [Fact] public void Constructor_LoadsCategories_IncludingAll() diff --git a/PersonalToolBox/App.xaml.cs b/PersonalToolBox/App.xaml.cs index b70e649..53db601 100644 --- a/PersonalToolBox/App.xaml.cs +++ b/PersonalToolBox/App.xaml.cs @@ -68,6 +68,7 @@ public partial class App : Application services.AddSingleton(); services.AddSingleton(); services.AddSingleton(); + services.AddSingleton(); services.AddSingleton(); services.AddTransient(); services.AddTransient(); diff --git a/PersonalToolBox/Helpers/HotKeyManager.cs b/PersonalToolBox/Helpers/HotKeyManager.cs new file mode 100644 index 0000000..00a4bd7 --- /dev/null +++ b/PersonalToolBox/Helpers/HotKeyManager.cs @@ -0,0 +1,174 @@ +using System.Runtime.InteropServices; +using System.Windows.Input; +using PersonalToolBox.Models; +using PersonalToolBox.Services; + +namespace PersonalToolBox.Helpers; + +/// +/// 全局快捷键管理器,使用 Win32 API RegisterHotKey / UnregisterHotKey +/// +public class HotKeyManager +{ + private IntPtr _hwnd; + private int _nextId = 1; + private readonly Dictionary _hotkeyMap = new(); + private readonly ILogService _logService; + private readonly IProcessExecutionService _processService; + + // ──────────────── Win32 API ──────────────── + + private const int WM_HOTKEY = 0x0312; + private const uint MOD_ALT = 0x0001; + private const uint MOD_CONTROL = 0x0002; + private const uint MOD_SHIFT = 0x0004; + private const uint MOD_WIN = 0x0008; + + [DllImport("user32.dll", SetLastError = true)] + private static extern bool RegisterHotKey(IntPtr hWnd, int id, uint fsModifiers, uint vk); + + [DllImport("user32.dll", SetLastError = true)] + private static extern bool UnregisterHotKey(IntPtr hWnd, int id); + + // ──────────────── 构造函数 ──────────────── + + public HotKeyManager(ILogService logService, IProcessExecutionService processService) + { + _logService = logService; + _processService = processService; + } + + /// + /// 设置窗口句柄(在 MainWindow.OnSourceInitialized 中调用) + /// + public void Initialize(IntPtr hwnd) + { + _hwnd = hwnd; + } + + // ──────────────── 注册/注销 ──────────────── + + /// + /// 批量注册所有工具的快捷键 + /// + public void RegisterAll(IEnumerable tools) + { + UnregisterAll(); + foreach (var tool in tools.Where(t => !string.IsNullOrWhiteSpace(t.HotKey))) + Register(tool); + } + + /// + /// 为单个工具注册快捷键 + /// + public void Register(ToolItem tool) + { + if (_hwnd == IntPtr.Zero) return; + if (string.IsNullOrWhiteSpace(tool.HotKey)) return; + + if (!TryParseHotKey(tool.HotKey, out uint modifiers, out uint vk)) + { + _logService.Warning($"快捷键格式无效: {tool.HotKey}"); + return; + } + + int id = _nextId++; + if (RegisterHotKey(_hwnd, id, modifiers, vk)) + { + _hotkeyMap[id] = tool; + _logService.Info($"已注册快捷键: {tool.HotKey} → {tool.Name}"); + } + else + { + int err = Marshal.GetLastWin32Error(); + _logService.Error($"快捷键注册失败: {tool.HotKey} (错误码: {err},可能与其他程序冲突)"); + } + } + + /// + /// 注销所有已注册的快捷键 + /// + private void UnregisterAll() + { + foreach (var kvp in _hotkeyMap) + UnregisterHotKey(_hwnd, kvp.Key); + _hotkeyMap.Clear(); + } + + // ──────────────── 消息处理 ──────────────── + + /// + /// 由 WndProc 钩子调用,处理 WM_HOTKEY 消息 + /// + public bool TryHandleMessage(int msg, IntPtr wParam, IntPtr lParam) + { + if (msg != WM_HOTKEY) return false; + + int id = wParam.ToInt32(); + if (_hotkeyMap.TryGetValue(id, out var tool)) + { + _logService.Info($"通过快捷键启动: {tool.Name}"); + _processService.Execute(tool); + } + return true; + } + + // ──────────────── 快捷键解析 ──────────────── + + /// + /// 解析快捷键字符串(如 "Ctrl+Alt+T")为修饰键和虚拟键码 + /// + public static bool TryParseHotKey(string hotkey, out uint modifiers, out uint vk) + { + modifiers = 0; + vk = 0; + + if (string.IsNullOrWhiteSpace(hotkey)) return false; + + var parts = hotkey.Split('+', StringSplitOptions.TrimEntries); + if (parts.Length < 2) return false; + + // 解析修饰键(最后一段之前的部分) + foreach (var part in parts.Take(parts.Length - 1)) + { + uint mod = part.ToUpperInvariant() switch + { + "CTRL" or "CONTROL" => MOD_CONTROL, + "ALT" => MOD_ALT, + "SHIFT" => MOD_SHIFT, + "WIN" or "WINDOWS" => MOD_WIN, + _ => 0 + }; + + if (mod == 0) return false; + modifiers |= mod; + } + + if (modifiers == 0) return false; + + // 解析按键(最后一段) + var keyStr = parts.Last(); + if (keyStr.Length == 1) + { + vk = char.ToUpperInvariant(keyStr[0]); + } + else if (Enum.TryParse(keyStr, true, out var key)) + { + vk = (uint)KeyInterop.VirtualKeyFromKey(key); + } + else + { + return false; + } + + return true; + } + + /// + /// 程序退出时清理所有快捷键 + /// + public void Dispose() + { + UnregisterAll(); + } +} diff --git a/PersonalToolBox/ViewModels/MainViewModel.cs b/PersonalToolBox/ViewModels/MainViewModel.cs index c9760e6..65f335a 100644 --- a/PersonalToolBox/ViewModels/MainViewModel.cs +++ b/PersonalToolBox/ViewModels/MainViewModel.cs @@ -20,6 +20,7 @@ public partial class MainViewModel : ObservableObject private readonly IDataService _dataService; private readonly IProcessExecutionService _processService; private readonly IServiceProvider _serviceProvider; + private readonly HotKeyManager _hotKeyManager; /// /// 全部分类的虚拟对象 @@ -30,12 +31,14 @@ public partial class MainViewModel : ObservableObject ILogService logService, IDataService dataService, IProcessExecutionService processService, - IServiceProvider serviceProvider) + IServiceProvider serviceProvider, + HotKeyManager hotKeyManager) { _logService = logService; _dataService = dataService; _processService = processService; _serviceProvider = serviceProvider; + _hotKeyManager = hotKeyManager; LoadData(); } @@ -201,9 +204,20 @@ public partial class MainViewModel : ObservableObject // 恢复之前选中的分类(若仍存在),否则选中"全部" SelectedCategory = Categories.FirstOrDefault(c => c.Id == previousCategoryId) ?? AllCategory; ApplyFilter(); + + // 工具列表变更后重新注册快捷键 + RegisterAllHotKeys(); } - // ───────────────────────────── 初始化 ───────────────────────────── + // ───────────────────────────── 快捷键 ───────────────────────────── + + /// + /// 注册所有工具的全局快捷键(由 MainWindow.OnSourceInitialized 及数据变更后调用) + /// + public void RegisterAllHotKeys() + { + _hotKeyManager.RegisterAll(Tools); + } private void LoadData() { diff --git a/PersonalToolBox/Views/MainWindow.xaml.cs b/PersonalToolBox/Views/MainWindow.xaml.cs index df18eed..0de5930 100644 --- a/PersonalToolBox/Views/MainWindow.xaml.cs +++ b/PersonalToolBox/Views/MainWindow.xaml.cs @@ -3,6 +3,8 @@ using System.Collections.Specialized; using System.Globalization; using System.Windows; using System.Windows.Data; +using System.Windows.Interop; +using PersonalToolBox.Helpers; using PersonalToolBox.ViewModels; namespace PersonalToolBox.Views; @@ -10,17 +12,45 @@ namespace PersonalToolBox.Views; public partial class MainWindow : Window { private readonly MainViewModel _viewModel; + private readonly HotKeyManager _hotKeyManager; - public MainWindow(MainViewModel viewModel) + public MainWindow(MainViewModel viewModel, HotKeyManager hotKeyManager) { InitializeComponent(); _viewModel = viewModel; + _hotKeyManager = hotKeyManager; DataContext = viewModel; viewModel.Logs.CollectionChanged += OnLogsCollectionChanged; } + /// + /// 窗口句柄就绪后注册全局快捷键 + /// + protected override void OnSourceInitialized(EventArgs e) + { + base.OnSourceInitialized(e); + + var hwndSource = (HwndSource)PresentationSource.FromVisual(this)!; + _hotKeyManager.Initialize(hwndSource.Handle); + + // 挂载 WndProc 钩子,拦截 WM_HOTKEY 消息 + hwndSource.AddHook(WndProcHook); + + // 注册所有已配置快捷键的工具 + _viewModel.RegisterAllHotKeys(); + } + + /// + /// 窗口消息钩子 + /// + private IntPtr WndProcHook(IntPtr hwnd, int msg, IntPtr wParam, IntPtr lParam, ref bool handled) + { + handled = _hotKeyManager.TryHandleMessage(msg, wParam, lParam); + return IntPtr.Zero; + } + private void OnLogsCollectionChanged(object? sender, NotifyCollectionChangedEventArgs e) { if (e.Action == NotifyCollectionChangedAction.Add && LogListBox.Items.Count > 0)