From 309582795cca5f6a8f49acc3f59906daa5ee7a10 Mon Sep 17 00:00:00 2001 From: Davide Giacometti <25966642+davidegiacometti@users.noreply.github.com> Date: Thu, 5 Jun 2025 22:29:18 +0200 Subject: [PATCH] [CmdPal] Add system tray menu (#39155) ## Summary of the Pull Request image ## PR Checklist - [x] **Closes:** #38303 - [ ] **Communication:** I've discussed this with core contributors already. If work hasn't been agreed, this work might be rejected - [ ] **Tests:** Added/updated and all pass - [ ] **Localization:** All end user facing strings can be localized - [ ] **Dev docs:** Added/updated - [ ] **New binaries:** Added on the required places - [ ] [JSON for signing](https://github.com/microsoft/PowerToys/blob/main/.pipelines/ESRPSigning_core.json) for new binaries - [ ] [WXS for installer](https://github.com/microsoft/PowerToys/blob/main/installer/PowerToysSetup/Product.wxs) for new binaries and localization folder - [ ] [YML for CI pipeline](https://github.com/microsoft/PowerToys/blob/main/.pipelines/ci/templates/build-powertoys-steps.yml) for new test projects - [ ] [YML for signed pipeline](https://github.com/microsoft/PowerToys/blob/main/.pipelines/release.yml) - [ ] **Documentation updated:** If checked, please file a pull request on [our docs repo](https://github.com/MicrosoftDocs/windows-uwp/tree/docs/hub/powertoys) and link it here: #xxx ## Detailed Description of the Pull Request / Additional comments - Move all system tray related code from main window code behind to a dedicated service - Add system tray menu ## Validation Steps Performed Manually tested: - Started CmdPal with tray icon enabled - Started CmdPal with tray icon disabled - Enabled/Disabled tray icon - Tested tray menu commands - Verified that the tray icon is visible after restarting explorer.exe --- .github/actions/spell-check/expect.txt | 1 + .../cmdpal/Microsoft.CmdPal.UI/App.xaml.cs | 2 + .../Helpers/TrayIconService.cs | 212 ++++++++++++++++++ .../Microsoft.CmdPal.UI/MainWindow.xaml.cs | 121 +--------- .../Microsoft.CmdPal.UI/NativeMethods.txt | 5 + .../Strings/en-us/Resources.resw | 6 + 6 files changed, 231 insertions(+), 116 deletions(-) create mode 100644 src/modules/cmdpal/Microsoft.CmdPal.UI/Helpers/TrayIconService.cs diff --git a/.github/actions/spell-check/expect.txt b/.github/actions/spell-check/expect.txt index 569cdb93db..ea369d57af 100644 --- a/.github/actions/spell-check/expect.txt +++ b/.github/actions/spell-check/expect.txt @@ -789,6 +789,7 @@ LCIDTo Lclean Ldone Ldr +LEFTALIGN LEFTSCROLLBAR LEFTTEXT LError diff --git a/src/modules/cmdpal/Microsoft.CmdPal.UI/App.xaml.cs b/src/modules/cmdpal/Microsoft.CmdPal.UI/App.xaml.cs index 4917dc5f0e..9670c8645f 100644 --- a/src/modules/cmdpal/Microsoft.CmdPal.UI/App.xaml.cs +++ b/src/modules/cmdpal/Microsoft.CmdPal.UI/App.xaml.cs @@ -20,6 +20,7 @@ using Microsoft.CmdPal.Ext.WindowsSettings; using Microsoft.CmdPal.Ext.WindowsTerminal; using Microsoft.CmdPal.Ext.WindowWalker; using Microsoft.CmdPal.Ext.WinGet; +using Microsoft.CmdPal.UI.Helpers; using Microsoft.CmdPal.UI.ViewModels; using Microsoft.CmdPal.UI.ViewModels.BuiltinCommands; using Microsoft.CmdPal.UI.ViewModels.Models; @@ -140,6 +141,7 @@ public partial class App : Application var state = AppStateModel.LoadState(); services.AddSingleton(state); services.AddSingleton(); + services.AddSingleton(); // ViewModels services.AddSingleton(); diff --git a/src/modules/cmdpal/Microsoft.CmdPal.UI/Helpers/TrayIconService.cs b/src/modules/cmdpal/Microsoft.CmdPal.UI/Helpers/TrayIconService.cs new file mode 100644 index 0000000000..3aae253512 --- /dev/null +++ b/src/modules/cmdpal/Microsoft.CmdPal.UI/Helpers/TrayIconService.cs @@ -0,0 +1,212 @@ +// Copyright (c) Microsoft Corporation +// The Microsoft Corporation licenses this file to you under the MIT license. +// See the LICENSE file in the project root for more information. + +using System.Diagnostics.CodeAnalysis; +using System.Runtime.InteropServices; +using CommunityToolkit.Mvvm.Messaging; +using Microsoft.CmdPal.UI.ViewModels; +using Microsoft.CmdPal.UI.ViewModels.Messages; +using Microsoft.UI.Xaml; +using Windows.Win32; +using Windows.Win32.Foundation; +using Windows.Win32.UI.Shell; +using Windows.Win32.UI.WindowsAndMessaging; +using WinRT.Interop; +using RS_ = Microsoft.CmdPal.UI.Helpers.ResourceLoaderInstance; + +namespace Microsoft.CmdPal.UI.Helpers; + +[SuppressMessage("StyleCop.CSharp.NamingRules", "SA1310:Field names should not contain underscore", Justification = "Stylistically, window messages are WM_*")] +[SuppressMessage("StyleCop.CSharp.NamingRules", "SA1306:Field names should begin with lower-case letter", Justification = "Stylistically, window messages are WM_*")] +internal sealed partial class TrayIconService +{ + private const uint MY_NOTIFY_ID = 1000; + private const uint WM_TRAY_ICON = PInvoke.WM_USER + 1; + + private readonly SettingsModel _settingsModel; + private readonly uint WM_TASKBAR_RESTART; + + private Window? _window; + private HWND _hwnd; + private WNDPROC? _originalWndProc; + private WNDPROC? _trayWndProc; + private NOTIFYICONDATAW? _trayIconData; + private DestroyIconSafeHandle? _largeIcon; + private DestroyMenuSafeHandle? _popupMenu; + + public TrayIconService(SettingsModel settingsModel) + { + _settingsModel = settingsModel; + + // TaskbarCreated is the message that's broadcast when explorer.exe + // restarts. We need to know when that happens to be able to bring our + // notification area icon back + WM_TASKBAR_RESTART = PInvoke.RegisterWindowMessage("TaskbarCreated"); + } + + public void SetupTrayIcon(bool? showSystemTrayIcon = null) + { + if (showSystemTrayIcon ?? _settingsModel.ShowSystemTrayIcon) + { + if (_window == null) + { + _window = new Window(); + _hwnd = new HWND(WindowNative.GetWindowHandle(_window)); + + // LOAD BEARING: If you don't stick the pointer to HotKeyPrc into a + // member (and instead like, use a local), then the pointer we marshal + // into the WindowLongPtr will be useless after we leave this function, + // and our **WindProc will explode**. + _trayWndProc = WindowProc; + var hotKeyPrcPointer = Marshal.GetFunctionPointerForDelegate(_trayWndProc); + _originalWndProc = Marshal.GetDelegateForFunctionPointer(PInvoke.SetWindowLongPtr(_hwnd, WINDOW_LONG_PTR_INDEX.GWL_WNDPROC, hotKeyPrcPointer)); + } + + if (_trayIconData == null) + { + // We need to stash this handle, so it doesn't clean itself up. If + // explorer restarts, we'll come back through here, and we don't + // really need to re-load the icon in that case. We can just use + // the handle from the first time. + _largeIcon = GetAppIconHandle(); + _trayIconData = new NOTIFYICONDATAW() + { + cbSize = (uint)Marshal.SizeOf(typeof(NOTIFYICONDATAW)), + hWnd = _hwnd, + uID = MY_NOTIFY_ID, + uFlags = NOTIFY_ICON_DATA_FLAGS.NIF_MESSAGE | NOTIFY_ICON_DATA_FLAGS.NIF_ICON | NOTIFY_ICON_DATA_FLAGS.NIF_TIP, + uCallbackMessage = WM_TRAY_ICON, + hIcon = (HICON)_largeIcon.DangerousGetHandle(), + szTip = RS_.GetString("AppStoreName"), + }; + } + + var d = (NOTIFYICONDATAW)_trayIconData; + + // Add the notification icon + PInvoke.Shell_NotifyIcon(NOTIFY_ICON_MESSAGE.NIM_ADD, in d); + + if (_popupMenu == null) + { + _popupMenu = PInvoke.CreatePopupMenu_SafeHandle(); + PInvoke.InsertMenu(_popupMenu, 0, MENU_ITEM_FLAGS.MF_BYPOSITION | MENU_ITEM_FLAGS.MF_STRING, PInvoke.WM_USER + 1, RS_.GetString("TrayMenu_Settings")); + PInvoke.InsertMenu(_popupMenu, 1, MENU_ITEM_FLAGS.MF_BYPOSITION | MENU_ITEM_FLAGS.MF_STRING, PInvoke.WM_USER + 2, RS_.GetString("TrayMenu_Exit")); + } + } + else + { + Destroy(); + } + } + + public void Destroy() + { + if (_trayIconData != null) + { + var d = (NOTIFYICONDATAW)_trayIconData; + if (PInvoke.Shell_NotifyIcon(NOTIFY_ICON_MESSAGE.NIM_DELETE, in d)) + { + _trayIconData = null; + } + } + + if (_popupMenu != null) + { + _popupMenu.Close(); + _popupMenu = null; + } + + if (_largeIcon != null) + { + _largeIcon.Close(); + _largeIcon = null; + } + + if (_window != null) + { + _window.Close(); + _window = null; + _hwnd = HWND.Null; + } + } + + private DestroyIconSafeHandle GetAppIconHandle() + { + var exePath = System.Reflection.Assembly.GetExecutingAssembly().Location; + DestroyIconSafeHandle largeIcon; + PInvoke.ExtractIconEx(exePath, 0, out largeIcon, out _, 1); + return largeIcon; + } + + private LRESULT WindowProc( + HWND hwnd, + uint uMsg, + WPARAM wParam, + LPARAM lParam) + { + switch (uMsg) + { + case PInvoke.WM_COMMAND: + { + if (wParam == PInvoke.WM_USER + 1) + { + WeakReferenceMessenger.Default.Send(); + } + else if (wParam == PInvoke.WM_USER + 2) + { + WeakReferenceMessenger.Default.Send(); + } + } + + break; + + // Shell_NotifyIcon can fail when we invoke it during the time explorer.exe isn't present/ready to handle it. + // We'll also never receive WM_TASKBAR_RESTART message if the first call to Shell_NotifyIcon failed, so we use + // WM_WINDOWPOSCHANGING which is always received on explorer startup sequence. + case PInvoke.WM_WINDOWPOSCHANGING: + { + if (_trayIconData == null) + { + SetupTrayIcon(); + } + } + + break; + default: + // WM_TASKBAR_RESTART isn't a compile-time constant, so we can't + // use it in a case label + if (uMsg == WM_TASKBAR_RESTART) + { + // Handle the case where explorer.exe restarts. + // Even if we created it before, do it again + SetupTrayIcon(); + } + else if (uMsg == WM_TRAY_ICON) + { + switch ((uint)lParam.Value) + { + case PInvoke.WM_RBUTTONUP: + { + if (_popupMenu != null) + { + PInvoke.GetCursorPos(out var cursorPos); + PInvoke.SetForegroundWindow(_hwnd); + PInvoke.TrackPopupMenuEx(_popupMenu, (uint)TRACK_POPUP_MENU_FLAGS.TPM_LEFTALIGN | (uint)TRACK_POPUP_MENU_FLAGS.TPM_BOTTOMALIGN, cursorPos.X, cursorPos.Y, _hwnd, null); + } + } + + break; + case PInvoke.WM_LBUTTONUP: + case PInvoke.WM_LBUTTONDBLCLK: + WeakReferenceMessenger.Default.Send(new(string.Empty, HWND.Null)); + break; + } + } + + break; + } + + return PInvoke.CallWindowProc(_originalWndProc, hwnd, uMsg, wParam, lParam); + } +} diff --git a/src/modules/cmdpal/Microsoft.CmdPal.UI/MainWindow.xaml.cs b/src/modules/cmdpal/Microsoft.CmdPal.UI/MainWindow.xaml.cs index d4d868b581..f66ddf5445 100644 --- a/src/modules/cmdpal/Microsoft.CmdPal.UI/MainWindow.xaml.cs +++ b/src/modules/cmdpal/Microsoft.CmdPal.UI/MainWindow.xaml.cs @@ -29,7 +29,6 @@ using Windows.Win32; using Windows.Win32.Foundation; using Windows.Win32.Graphics.Dwm; using Windows.Win32.UI.Input.KeyboardAndMouse; -using Windows.Win32.UI.Shell; using Windows.Win32.UI.WindowsAndMessaging; using WinRT; using WinUIEx; @@ -47,22 +46,8 @@ public sealed partial class MainWindow : WindowEx, private readonly WNDPROC? _hotkeyWndProc; private readonly WNDPROC? _originalWndProc; private readonly List _hotkeys = []; - private bool _ignoreHotKeyWhenFullScreen = true; - - // Stylistically, window messages are WM_* -#pragma warning disable SA1310 // Field names should not contain underscore -#pragma warning disable SA1306 // Field names should begin with lower-case letter - private const uint MY_NOTIFY_ID = 1000; - private const uint WM_TRAY_ICON = PInvoke.WM_USER + 1; - private readonly uint WM_TASKBAR_RESTART; -#pragma warning restore SA1306 // Field names should begin with lower-case letter -#pragma warning restore SA1310 // Field names should not contain underscore - private readonly KeyboardListener _keyboardListener; - - // Notification Area ("Tray") icon data - private NOTIFYICONDATAW? _trayIconData; - private DestroyIconSafeHandle? _largeIcon; + private bool _ignoreHotKeyWhenFullScreen = true; private DesktopAcrylicController? _acrylicController; private SystemBackdropConfiguration? _configurationSource; @@ -79,11 +64,6 @@ public sealed partial class MainWindow : WindowEx, _keyboardListener.SetProcessCommand(new CmdPalKeyboardService.ProcessCommand(HandleSummon)); - // TaskbarCreated is the message that's broadcast when explorer.exe - // restarts. We need to know when that happens to be able to bring our - // notification area icon back - WM_TASKBAR_RESTART = PInvoke.RegisterWindowMessage("TaskbarCreated"); - this.SetIcon(); AppWindow.Title = RS_.GetString("AppName"); PositionCentered(); @@ -159,7 +139,7 @@ public sealed partial class MainWindow : WindowEx, var settings = App.Current.Services.GetService()!; SetupHotkey(settings); - SetupTrayIcon(settings.ShowSystemTrayIcon); + App.Current.Services.GetService()!.SetupTrayIcon(settings.ShowSystemTrayIcon); _ignoreHotKeyWhenFullScreen = settings.IgnoreShortcutWhenFullscreen; @@ -219,7 +199,7 @@ public sealed partial class MainWindow : WindowEx, private void ShowHwnd(IntPtr hwndValue, MonitorBehavior target) { - var hwnd = new HWND(hwndValue); + var hwnd = new HWND(hwndValue != 0 ? hwndValue : _hwnd); // Remember, IsIconic == "minimized", which is entirely different state // from "show/hide" @@ -332,7 +312,7 @@ public sealed partial class MainWindow : WindowEx, var extensionService = serviceProvider.GetService()!; extensionService.SignalStopExtensionsAsync(); - RemoveTrayIcon(); + App.Current.Services.GetService()!.Destroy(); // WinUI bug is causing a crash on shutdown when FailFastOnErrors is set to true (#51773592). // Workaround by turning it off before shutdown. @@ -340,6 +320,7 @@ public sealed partial class MainWindow : WindowEx, DisposeAcrylic(); _keyboardListener.Stop(); + Environment.Exit(0); } private void DisposeAcrylic() @@ -606,100 +587,8 @@ public sealed partial class MainWindow : WindowEx, return (LRESULT)IntPtr.Zero; } - - // Shell_NotifyIcon can fail when we invoke it during the time explorer.exe isn't present/ready to handle it. - // We'll also never receive WM_TASKBAR_RESTART message if the first call to Shell_NotifyIcon failed, so we use - // WM_WINDOWPOSCHANGING which is always received on explorer startup sequence. - case PInvoke.WM_WINDOWPOSCHANGING: - { - if (_trayIconData == null) - { - SetupTrayIcon(); - } - } - - break; - default: - // WM_TASKBAR_RESTART isn't a compile-time constant, so we can't - // use it in a case label - if (uMsg == WM_TASKBAR_RESTART) - { - // Handle the case where explorer.exe restarts. - // Even if we created it before, do it again - SetupTrayIcon(); - } - else if (uMsg == WM_TRAY_ICON) - { - switch ((uint)lParam.Value) - { - case PInvoke.WM_RBUTTONUP: - case PInvoke.WM_LBUTTONUP: - case PInvoke.WM_LBUTTONDBLCLK: - Summon(string.Empty); - break; - } - } - - break; } return PInvoke.CallWindowProc(_originalWndProc, hwnd, uMsg, wParam, lParam); } - - private void SetupTrayIcon(bool? showSystemTrayIcon = null) - { - if (showSystemTrayIcon ?? App.Current.Services.GetService()!.ShowSystemTrayIcon) - { - // We only need to build the tray data once. - if (_trayIconData == null) - { - // We need to stash this handle, so it doesn't clean itself up. If - // explorer restarts, we'll come back through here, and we don't - // really need to re-load the icon in that case. We can just use - // the handle from the first time. - _largeIcon = GetAppIconHandle(); - _trayIconData = new NOTIFYICONDATAW() - { - cbSize = (uint)Marshal.SizeOf(typeof(NOTIFYICONDATAW)), - hWnd = _hwnd, - uID = MY_NOTIFY_ID, - uFlags = NOTIFY_ICON_DATA_FLAGS.NIF_MESSAGE | NOTIFY_ICON_DATA_FLAGS.NIF_ICON | NOTIFY_ICON_DATA_FLAGS.NIF_TIP, - uCallbackMessage = WM_TRAY_ICON, - hIcon = (HICON)_largeIcon.DangerousGetHandle(), - szTip = RS_.GetString("AppStoreName"), - }; - } - - var d = (NOTIFYICONDATAW)_trayIconData; - - // Add the notification icon - PInvoke.Shell_NotifyIcon(NOTIFY_ICON_MESSAGE.NIM_ADD, in d); - } - else - { - RemoveTrayIcon(); - } - } - - private void RemoveTrayIcon() - { - if (_trayIconData != null) - { - var d = (NOTIFYICONDATAW)_trayIconData; - if (PInvoke.Shell_NotifyIcon(NOTIFY_ICON_MESSAGE.NIM_DELETE, in d)) - { - _trayIconData = null; - } - } - - _largeIcon?.Close(); - } - - private DestroyIconSafeHandle GetAppIconHandle() - { - var exePath = System.Reflection.Assembly.GetExecutingAssembly().Location; - DestroyIconSafeHandle largeIcon; - PInvoke.ExtractIconEx(exePath, 0, out largeIcon, out _, 1); - return largeIcon; - } } diff --git a/src/modules/cmdpal/Microsoft.CmdPal.UI/NativeMethods.txt b/src/modules/cmdpal/Microsoft.CmdPal.UI/NativeMethods.txt index c39ed38300..427742f95f 100644 --- a/src/modules/cmdpal/Microsoft.CmdPal.UI/NativeMethods.txt +++ b/src/modules/cmdpal/Microsoft.CmdPal.UI/NativeMethods.txt @@ -35,9 +35,14 @@ WM_WINDOWPOSCHANGING RegisterWindowMessageW GetModuleHandleW ExtractIconEx +TRACK_POPUP_MENU_FLAGS +WM_COMMAND WM_RBUTTONUP WM_LBUTTONUP WM_LBUTTONDBLCLK +CreatePopupMenu +TrackPopupMenuEx +InsertMenu MessageBox DwmGetWindowAttribute diff --git a/src/modules/cmdpal/Microsoft.CmdPal.UI/Strings/en-us/Resources.resw b/src/modules/cmdpal/Microsoft.CmdPal.UI/Strings/en-us/Resources.resw index 6c63eeff16..efaf667230 100644 --- a/src/modules/cmdpal/Microsoft.CmdPal.UI/Strings/en-us/Resources.resw +++ b/src/modules/cmdpal/Microsoft.CmdPal.UI/Strings/en-us/Resources.resw @@ -412,4 +412,10 @@ Right-click to remove the key combination, thereby deactivating the shortcut. More + + Settings + + + Exit + \ No newline at end of file