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
## 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