[CmdPal] Add system tray menu (#39155)

<!-- Enter a brief description/summary of your PR here. What does it
fix/what does it change/how was it tested (even manually, if necessary)?
-->
## Summary of the Pull Request

<img width="151" alt="image"
src="https://github.com/user-attachments/assets/994c06d0-e28f-4fa4-a8fe-043ec179ee82"
/>

<!-- Please review the items on the PR checklist before submitting-->
## 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

<!-- Provide a more detailed description of the PR, other things fixed
or any additional comments/features here -->
## 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

<!-- Describe how you validated the behavior. Add automated tests
wherever possible, but list manual validation steps taken as well -->
## 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
This commit is contained in:
Davide Giacometti
2025-06-05 22:29:18 +02:00
committed by GitHub
parent 2aad949c9e
commit 309582795c
6 changed files with 231 additions and 116 deletions

View File

@@ -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<TopLevelHotkey> _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<SettingsModel>()!;
SetupHotkey(settings);
SetupTrayIcon(settings.ShowSystemTrayIcon);
App.Current.Services.GetService<TrayIconService>()!.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<IExtensionService>()!;
extensionService.SignalStopExtensionsAsync();
RemoveTrayIcon();
App.Current.Services.GetService<TrayIconService>()!.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<SettingsModel>()!.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;
}
}