mirror of
https://github.com/microsoft/PowerToys.git
synced 2025-12-15 19:27:56 +01:00
[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:
committed by
GitHub
parent
2aad949c9e
commit
309582795c
1
.github/actions/spell-check/expect.txt
vendored
1
.github/actions/spell-check/expect.txt
vendored
@@ -789,6 +789,7 @@ LCIDTo
|
||||
Lclean
|
||||
Ldone
|
||||
Ldr
|
||||
LEFTALIGN
|
||||
LEFTSCROLLBAR
|
||||
LEFTTEXT
|
||||
LError
|
||||
|
||||
@@ -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<IExtensionService, ExtensionService>();
|
||||
services.AddSingleton<TrayIconService>();
|
||||
|
||||
// ViewModels
|
||||
services.AddSingleton<ShellViewModel>();
|
||||
|
||||
@@ -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<WNDPROC>(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<OpenSettingsMessage>();
|
||||
}
|
||||
else if (wParam == PInvoke.WM_USER + 2)
|
||||
{
|
||||
WeakReferenceMessenger.Default.Send<QuitMessage>();
|
||||
}
|
||||
}
|
||||
|
||||
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<HotkeySummonMessage>(new(string.Empty, HWND.Null));
|
||||
break;
|
||||
}
|
||||
}
|
||||
|
||||
break;
|
||||
}
|
||||
|
||||
return PInvoke.CallWindowProc(_originalWndProc, hwnd, uMsg, wParam, lParam);
|
||||
}
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -412,4 +412,10 @@ Right-click to remove the key combination, thereby deactivating the shortcut.</v
|
||||
<data name="MoreCommandsButton.[using:Microsoft.UI.Xaml.Automation]AutomationProperties.Name" xml:space="preserve">
|
||||
<value>More</value>
|
||||
</data>
|
||||
<data name="TrayMenu_Settings" xml:space="preserve">
|
||||
<value>Settings</value>
|
||||
</data>
|
||||
<data name="TrayMenu_Exit" xml:space="preserve">
|
||||
<value>Exit</value>
|
||||
</data>
|
||||
</root>
|
||||
Reference in New Issue
Block a user