mirror of
https://github.com/microsoft/PowerToys.git
synced 2026-04-03 09:46:54 +02:00
## Summary This PR refactors CmdPal settings/state to be immutable end-to-end. ### Core changes - Convert model types to immutable records / init-only properties: - `SettingsModel` - `AppStateModel` - `ProviderSettings` - `DockSettings` - `RecentCommandsManager` - supporting settings types (fallback/hotkey/alias/top-level hotkey/history items, etc.) - Replace mutable collections with immutable equivalents where appropriate: - `ImmutableDictionary<,>` - `ImmutableList<>` - Move mutation flow to atomic service updates: - `ISettingsService.UpdateSettings(Func<SettingsModel, SettingsModel>)` - `IAppStateService.UpdateState(Func<AppStateModel, AppStateModel>)` - Update ViewModels/managers/services to use copy-on-write (`with`) patterns instead of in-place mutation. - Update serialization context + tests for immutable model graph compatibility. ## Why Issue #46437 is caused by mutable shared state being updated from different execution paths/threads, leading to race-prone behavior during persistence/serialization. By making settings/app state immutable and using atomic swap/update patterns, we remove in-place mutation and eliminate this class of concurrency bug. ## Validation - Built successfully: - `Microsoft.CmdPal.UI.ViewModels` - `Microsoft.CmdPal.UI` - `Microsoft.CmdPal.UI.ViewModels.UnitTests` - Updated unit tests for immutable update patterns. Fixes #46437 --------- Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>
1364 lines
49 KiB
C#
1364 lines
49 KiB
C#
// 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;
|
|
using System.Runtime.InteropServices;
|
|
using CmdPalKeyboardService;
|
|
using CommunityToolkit.Mvvm.Messaging;
|
|
using ManagedCommon;
|
|
using Microsoft.CmdPal.Common.Helpers;
|
|
using Microsoft.CmdPal.Common.Services;
|
|
using Microsoft.CmdPal.Ext.ClipboardHistory.Messages;
|
|
using Microsoft.CmdPal.UI.Controls;
|
|
using Microsoft.CmdPal.UI.Dock;
|
|
using Microsoft.CmdPal.UI.Events;
|
|
using Microsoft.CmdPal.UI.Helpers;
|
|
using Microsoft.CmdPal.UI.Messages;
|
|
using Microsoft.CmdPal.UI.Services;
|
|
using Microsoft.CmdPal.UI.ViewModels;
|
|
using Microsoft.CmdPal.UI.ViewModels.Messages;
|
|
using Microsoft.CmdPal.UI.ViewModels.Services;
|
|
using Microsoft.CmdPal.ViewModels.Messages;
|
|
using Microsoft.Extensions.DependencyInjection;
|
|
using Microsoft.PowerToys.Telemetry;
|
|
using Microsoft.UI.Composition;
|
|
using Microsoft.UI.Composition.SystemBackdrops;
|
|
using Microsoft.UI.Input;
|
|
using Microsoft.UI.Windowing;
|
|
using Microsoft.UI.Xaml;
|
|
using Microsoft.Windows.AppLifecycle;
|
|
using Windows.ApplicationModel.Activation;
|
|
using Windows.Foundation;
|
|
using Windows.Graphics;
|
|
using Windows.System;
|
|
using Windows.UI;
|
|
using Windows.Win32;
|
|
using Windows.Win32.Foundation;
|
|
using Windows.Win32.Graphics.Dwm;
|
|
using Windows.Win32.UI.Input.KeyboardAndMouse;
|
|
using Windows.Win32.UI.WindowsAndMessaging;
|
|
using WinRT;
|
|
using WinUIEx;
|
|
using RS_ = Microsoft.CmdPal.UI.Helpers.ResourceLoaderInstance;
|
|
|
|
namespace Microsoft.CmdPal.UI;
|
|
|
|
public sealed partial class MainWindow : WindowEx,
|
|
IRecipient<DismissMessage>,
|
|
IRecipient<ShowWindowMessage>,
|
|
IRecipient<ShowPaletteAtMessage>,
|
|
IRecipient<HideWindowMessage>,
|
|
IRecipient<QuitMessage>,
|
|
IRecipient<NavigateToPageMessage>,
|
|
IRecipient<NavigationDepthMessage>,
|
|
IRecipient<SearchQueryMessage>,
|
|
IRecipient<ErrorOccurredMessage>,
|
|
IRecipient<DragStartedMessage>,
|
|
IRecipient<DragCompletedMessage>,
|
|
IRecipient<ToggleDevRibbonMessage>,
|
|
IDisposable,
|
|
IHostWindow
|
|
{
|
|
[System.Diagnostics.CodeAnalysis.SuppressMessage("StyleCop.CSharp.NamingRules", "SA1310:Field names should not contain underscore", Justification = "Stylistically, window messages are WM_")]
|
|
[System.Diagnostics.CodeAnalysis.SuppressMessage("StyleCop.CSharp.NamingRules", "SA1306:Field names should begin with lower-case letter", Justification = "Stylistically, window messages are WM_")]
|
|
private readonly uint WM_TASKBAR_RESTART;
|
|
private readonly HWND _hwnd;
|
|
private readonly DispatcherTimer _autoGoHomeTimer;
|
|
private readonly WNDPROC? _hotkeyWndProc;
|
|
private readonly WNDPROC? _originalWndProc;
|
|
private readonly List<TopLevelHotkey> _hotkeys = [];
|
|
private readonly KeyboardListener _keyboardListener;
|
|
private readonly LocalKeyboardListener _localKeyboardListener;
|
|
private readonly HiddenOwnerWindowBehavior _hiddenOwnerBehavior = new();
|
|
private readonly IThemeService _themeService;
|
|
private readonly WindowThemeSynchronizer _windowThemeSynchronizer;
|
|
private bool _ignoreHotKeyWhenFullScreen = true;
|
|
private bool _suppressDpiChange;
|
|
private bool _themeServiceInitialized;
|
|
|
|
// Session tracking for telemetry
|
|
private Stopwatch? _sessionStopwatch;
|
|
private int _sessionCommandsExecuted;
|
|
private int _sessionPagesVisited;
|
|
private int _sessionSearchQueriesCount;
|
|
private int _sessionMaxNavigationDepth;
|
|
private int _sessionErrorCount;
|
|
|
|
private DesktopAcrylicController? _acrylicController;
|
|
private MicaController? _micaController;
|
|
private SystemBackdropConfiguration? _configurationSource;
|
|
private bool _isUpdatingBackdrop;
|
|
private TimeSpan _autoGoHomeInterval = Timeout.InfiniteTimeSpan;
|
|
|
|
private WindowPosition _currentWindowPosition = new();
|
|
|
|
private bool _preventHideWhenDeactivated;
|
|
private bool _isLoadedFromDock;
|
|
|
|
private DevRibbon? _devRibbon;
|
|
|
|
private MainWindowViewModel ViewModel { get; }
|
|
|
|
public bool IsVisibleToUser { get; private set; } = true;
|
|
|
|
public MainWindow()
|
|
{
|
|
InitializeComponent();
|
|
|
|
ViewModel = App.Current.Services.GetService<MainWindowViewModel>()!;
|
|
|
|
_autoGoHomeTimer = new DispatcherTimer();
|
|
_autoGoHomeTimer.Tick += OnAutoGoHomeTimerOnTick;
|
|
|
|
_themeService = App.Current.Services.GetRequiredService<IThemeService>();
|
|
_themeService.ThemeChanged += ThemeServiceOnThemeChanged;
|
|
_windowThemeSynchronizer = new WindowThemeSynchronizer(_themeService, this);
|
|
|
|
_hwnd = new HWND(WinRT.Interop.WindowNative.GetWindowHandle(this).ToInt32());
|
|
|
|
unsafe
|
|
{
|
|
CommandPaletteHost.SetHostHwnd((ulong)_hwnd.Value);
|
|
}
|
|
|
|
InitializeBackdropSupport();
|
|
|
|
_hiddenOwnerBehavior.ShowInTaskbar(this, Debugger.IsAttached);
|
|
|
|
_keyboardListener = new KeyboardListener();
|
|
_keyboardListener.Start();
|
|
|
|
_keyboardListener.SetProcessCommand(new CmdPalKeyboardService.ProcessCommand(HandleSummon));
|
|
|
|
WM_TASKBAR_RESTART = PInvoke.RegisterWindowMessage("TaskbarCreated");
|
|
|
|
// 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**.
|
|
_hotkeyWndProc = HotKeyPrc;
|
|
var hotKeyPrcPointer = Marshal.GetFunctionPointerForDelegate(_hotkeyWndProc);
|
|
_originalWndProc = Marshal.GetDelegateForFunctionPointer<WNDPROC>(PInvoke.SetWindowLongPtr(_hwnd, WINDOW_LONG_PTR_INDEX.GWL_WNDPROC, hotKeyPrcPointer));
|
|
|
|
this.SetIcon();
|
|
AppWindow.Title = RS_.GetString("AppName");
|
|
RestoreWindowPositionFromSavedSettings();
|
|
UpdateWindowPositionInMemory();
|
|
|
|
WeakReferenceMessenger.Default.Register<DismissMessage>(this);
|
|
WeakReferenceMessenger.Default.Register<QuitMessage>(this);
|
|
WeakReferenceMessenger.Default.Register<ShowWindowMessage>(this);
|
|
WeakReferenceMessenger.Default.Register<ShowPaletteAtMessage>(this);
|
|
WeakReferenceMessenger.Default.Register<HideWindowMessage>(this);
|
|
WeakReferenceMessenger.Default.Register<NavigateToPageMessage>(this);
|
|
WeakReferenceMessenger.Default.Register<NavigationDepthMessage>(this);
|
|
WeakReferenceMessenger.Default.Register<SearchQueryMessage>(this);
|
|
WeakReferenceMessenger.Default.Register<ErrorOccurredMessage>(this);
|
|
WeakReferenceMessenger.Default.Register<DragStartedMessage>(this);
|
|
WeakReferenceMessenger.Default.Register<DragCompletedMessage>(this);
|
|
WeakReferenceMessenger.Default.Register<ToggleDevRibbonMessage>(this);
|
|
|
|
// Hide our titlebar.
|
|
// We need to both ExtendsContentIntoTitleBar, then set the height to Collapsed
|
|
// to hide the old caption buttons. Then, in UpdateRegionsForCustomTitleBar,
|
|
// we'll make the top drag-able again. (after our content loads)
|
|
ExtendsContentIntoTitleBar = true;
|
|
AppWindow.TitleBar.PreferredHeightOption = TitleBarHeightOption.Collapsed;
|
|
SizeChanged += WindowSizeChanged;
|
|
RootElement.Loaded += RootElementLoaded;
|
|
|
|
// Load our settings, and then also wire up a settings changed handler
|
|
HotReloadSettings();
|
|
App.Current.Services.GetRequiredService<ISettingsService>().SettingsChanged += SettingsChangedHandler;
|
|
|
|
// Make sure that we update the acrylic theme when the OS theme changes
|
|
RootElement.ActualThemeChanged += (s, e) => DispatcherQueue.TryEnqueue(UpdateBackdrop);
|
|
|
|
// Hardcoding event name to avoid bringing in the PowerToys.interop dependency. Event name must match CMDPAL_SHOW_EVENT from shared_constants.h
|
|
NativeEventWaiter.WaitForEventLoop("Local\\PowerToysCmdPal-ShowEvent-62336fcd-8611-4023-9b30-091a6af4cc5a", () =>
|
|
{
|
|
Summon(string.Empty);
|
|
});
|
|
|
|
_localKeyboardListener = new LocalKeyboardListener();
|
|
_localKeyboardListener.KeyPressed += LocalKeyboardListener_OnKeyPressed;
|
|
_localKeyboardListener.Start();
|
|
|
|
// Force window to be created, and then cloaked. This will offset initial animation when the window is shown.
|
|
HideWindow();
|
|
}
|
|
|
|
private void OnAutoGoHomeTimerOnTick(object? s, object e)
|
|
{
|
|
_autoGoHomeTimer.Stop();
|
|
|
|
// BEAR LOADING: Focus Search must be suppressed here; otherwise it may steal focus (for example, from the system tray icon)
|
|
// and prevent the user from opening its context menu.
|
|
WeakReferenceMessenger.Default.Send(new GoHomeMessage(WithAnimation: false, FocusSearch: false));
|
|
}
|
|
|
|
private void ThemeServiceOnThemeChanged(object? sender, ThemeChangedEventArgs e)
|
|
{
|
|
UpdateBackdrop();
|
|
}
|
|
|
|
private static void LocalKeyboardListener_OnKeyPressed(object? sender, LocalKeyboardListenerKeyPressedEventArgs e)
|
|
{
|
|
if (e.Key == VirtualKey.GoBack)
|
|
{
|
|
WeakReferenceMessenger.Default.Send(new GoBackMessage());
|
|
}
|
|
}
|
|
|
|
private void SettingsChangedHandler(ISettingsService sender, SettingsModel args)
|
|
{
|
|
DispatcherQueue.TryEnqueue(HotReloadSettings);
|
|
}
|
|
|
|
private void RootElementLoaded(object sender, RoutedEventArgs e)
|
|
{
|
|
// Now that our content has loaded, we can update our draggable regions
|
|
UpdateRegionsForCustomTitleBar();
|
|
|
|
// Also update regions when DPI changes. SizeChanged only fires when the logical
|
|
// (DIP) size changes — a DPI change that scales the physical size while preserving
|
|
// the DIP size won't trigger it, leaving drag regions at the old physical coordinates.
|
|
RootElement.XamlRoot.Changed += XamlRoot_Changed;
|
|
|
|
// Add dev ribbon if enabled
|
|
if (!BuildInfo.IsCiBuild)
|
|
{
|
|
_devRibbon = new DevRibbon { Margin = new Thickness(-1, -1, 120, -1) };
|
|
RootElement.Children.Add(_devRibbon);
|
|
}
|
|
}
|
|
|
|
private void XamlRoot_Changed(XamlRoot sender, XamlRootChangedEventArgs args) => UpdateRegionsForCustomTitleBar();
|
|
|
|
private void WindowSizeChanged(object sender, WindowSizeChangedEventArgs args) => UpdateRegionsForCustomTitleBar();
|
|
|
|
private void PositionCentered()
|
|
{
|
|
var displayArea = DisplayArea.GetFromWindowId(AppWindow.Id, DisplayAreaFallback.Nearest);
|
|
PositionCentered(displayArea);
|
|
}
|
|
|
|
private void PositionCentered(DisplayArea displayArea)
|
|
{
|
|
// Use the saved window size when available so that a dock-resized HWND
|
|
// (hidden but not destroyed) doesn't dictate the size on normal reopen.
|
|
SizeInt32 windowSize;
|
|
int windowDpi;
|
|
|
|
if (_currentWindowPosition.IsSizeValid)
|
|
{
|
|
windowSize = new SizeInt32(_currentWindowPosition.Width, _currentWindowPosition.Height);
|
|
windowDpi = _currentWindowPosition.Dpi;
|
|
}
|
|
else
|
|
{
|
|
windowSize = AppWindow.Size;
|
|
windowDpi = (int)this.GetDpiForWindow();
|
|
}
|
|
|
|
var rect = WindowPositionHelper.CenterOnDisplay(
|
|
displayArea,
|
|
windowSize,
|
|
windowDpi);
|
|
|
|
if (rect is not null)
|
|
{
|
|
MoveAndResizeDpiAware(rect.Value);
|
|
}
|
|
}
|
|
|
|
private void RestoreWindowPosition(WindowPosition? savedPosition)
|
|
{
|
|
if (savedPosition?.IsSizeValid != true)
|
|
{
|
|
// don't try to restore if the saved position is invalid, just recenter
|
|
PositionCentered();
|
|
return;
|
|
}
|
|
|
|
var newRect = WindowPositionHelper.AdjustRectForVisibility(
|
|
savedPosition.ToPhysicalWindowRectangle(),
|
|
new SizeInt32(savedPosition.ScreenWidth, savedPosition.ScreenHeight),
|
|
savedPosition.Dpi);
|
|
|
|
MoveAndResizeDpiAware(newRect);
|
|
}
|
|
|
|
private void RestoreWindowPositionFromSavedSettings()
|
|
{
|
|
var settings = App.Current.Services.GetRequiredService<ISettingsService>().Settings;
|
|
RestoreWindowPosition(settings?.LastWindowPosition);
|
|
}
|
|
|
|
private void RestoreWindowPositionFromMemory()
|
|
{
|
|
RestoreWindowPosition(_currentWindowPosition);
|
|
}
|
|
|
|
/// <summary>
|
|
/// Moves and resizes the window while suppressing WM_DPICHANGED.
|
|
/// The caller is expected to provide a rect already scaled for the target display's DPI.
|
|
/// Without suppression, the framework would apply its own DPI scaling on top, double-scaling the window.
|
|
/// </summary>
|
|
private void MoveAndResizeDpiAware(RectInt32 rect)
|
|
{
|
|
var originalMinHeight = MinHeight;
|
|
var originalMinWidth = MinWidth;
|
|
|
|
_suppressDpiChange = true;
|
|
|
|
try
|
|
{
|
|
// WindowEx is uses current DPI to calculate the minimum window size
|
|
MinHeight = 0;
|
|
MinWidth = 0;
|
|
AppWindow.MoveAndResize(rect);
|
|
}
|
|
finally
|
|
{
|
|
MinHeight = originalMinHeight;
|
|
MinWidth = originalMinWidth;
|
|
_suppressDpiChange = false;
|
|
}
|
|
}
|
|
|
|
private void UpdateWindowPositionInMemory()
|
|
{
|
|
var placement = new WINDOWPLACEMENT { length = (uint)Marshal.SizeOf<WINDOWPLACEMENT>() };
|
|
if (!PInvoke.GetWindowPlacement(_hwnd, ref placement))
|
|
{
|
|
return;
|
|
}
|
|
|
|
var rect = placement.rcNormalPosition;
|
|
var displayArea = DisplayArea.GetFromWindowId(AppWindow.Id, DisplayAreaFallback.Nearest) ?? DisplayArea.Primary;
|
|
|
|
// GetWindowPlacement returns rcNormalPosition in workspace coordinates for
|
|
// normal windows, but in screen coordinates for tool windows (WS_EX_TOOLWINDOW).
|
|
// HiddenOwnerWindowBehavior applies WS_EX_TOOLWINDOW to hide from taskbar/Alt+Tab,
|
|
// so we must check the current style before converting coordinates.
|
|
//
|
|
// To be on the safe side, we should consider the possibility that setting
|
|
// WS_EX_TOOLWINDOW failed or isn't applied while debugging.
|
|
var workArea = displayArea.WorkArea;
|
|
var isToolWindow = this.HasExtendedStyle(WINDOW_EX_STYLE.WS_EX_TOOLWINDOW);
|
|
|
|
_currentWindowPosition = new WindowPosition
|
|
{
|
|
X = rect.X + (isToolWindow ? 0 : workArea.X),
|
|
Y = rect.Y + (isToolWindow ? 0 : workArea.Y),
|
|
Width = rect.Width,
|
|
Height = rect.Height,
|
|
Dpi = (int)this.GetDpiForWindow(),
|
|
ScreenWidth = workArea.Width,
|
|
ScreenHeight = workArea.Height,
|
|
};
|
|
}
|
|
|
|
private void HotReloadSettings()
|
|
{
|
|
var settings = App.Current.Services.GetRequiredService<ISettingsService>().Settings;
|
|
|
|
SetupHotkey(settings);
|
|
App.Current.Services.GetService<TrayIconService>()!.SetupTrayIcon(settings.ShowSystemTrayIcon);
|
|
|
|
_ignoreHotKeyWhenFullScreen = settings.IgnoreShortcutWhenFullscreen;
|
|
|
|
_autoGoHomeInterval = settings.AutoGoHomeInterval;
|
|
_autoGoHomeTimer.Interval = _autoGoHomeInterval;
|
|
}
|
|
|
|
private void InitializeBackdropSupport()
|
|
{
|
|
if (DesktopAcrylicController.IsSupported() || MicaController.IsSupported())
|
|
{
|
|
_configurationSource = new SystemBackdropConfiguration
|
|
{
|
|
IsInputActive = true,
|
|
};
|
|
}
|
|
}
|
|
|
|
private void UpdateBackdrop()
|
|
{
|
|
// Prevent re-entrance when backdrop changes trigger ActualThemeChanged
|
|
if (_isUpdatingBackdrop)
|
|
{
|
|
return;
|
|
}
|
|
|
|
_isUpdatingBackdrop = true;
|
|
|
|
var backdrop = _themeService.Current.BackdropParameters;
|
|
var isImageMode = ViewModel.ShowBackgroundImage;
|
|
var config = BackdropStyles.Get(backdrop.Style);
|
|
|
|
try
|
|
{
|
|
switch (config.ControllerKind)
|
|
{
|
|
case BackdropControllerKind.Solid:
|
|
CleanupBackdropControllers();
|
|
var tintColor = Color.FromArgb(
|
|
(byte)(backdrop.EffectiveOpacity * 255),
|
|
backdrop.TintColor.R,
|
|
backdrop.TintColor.G,
|
|
backdrop.TintColor.B);
|
|
SetupTransparentBackdrop(tintColor);
|
|
break;
|
|
|
|
case BackdropControllerKind.Mica:
|
|
case BackdropControllerKind.MicaAlt:
|
|
SetupMica(backdrop, isImageMode, config.ControllerKind);
|
|
break;
|
|
|
|
case BackdropControllerKind.Acrylic:
|
|
case BackdropControllerKind.AcrylicThin:
|
|
default:
|
|
SetupDesktopAcrylic(backdrop, isImageMode, config.ControllerKind);
|
|
break;
|
|
}
|
|
}
|
|
catch (Exception ex)
|
|
{
|
|
Logger.LogError("Failed to update backdrop", ex);
|
|
}
|
|
finally
|
|
{
|
|
_isUpdatingBackdrop = false;
|
|
}
|
|
}
|
|
|
|
private void SetupTransparentBackdrop(Color tintColor)
|
|
{
|
|
if (SystemBackdrop is TransparentTintBackdrop existingBackdrop)
|
|
{
|
|
existingBackdrop.TintColor = tintColor;
|
|
}
|
|
else
|
|
{
|
|
SystemBackdrop = new TransparentTintBackdrop { TintColor = tintColor };
|
|
}
|
|
}
|
|
|
|
private void CleanupBackdropControllers()
|
|
{
|
|
if (_acrylicController is not null)
|
|
{
|
|
_acrylicController.RemoveAllSystemBackdropTargets();
|
|
_acrylicController.Dispose();
|
|
_acrylicController = null;
|
|
}
|
|
|
|
if (_micaController is not null)
|
|
{
|
|
_micaController.RemoveAllSystemBackdropTargets();
|
|
_micaController.Dispose();
|
|
_micaController = null;
|
|
}
|
|
}
|
|
|
|
private void SetupDesktopAcrylic(BackdropParameters backdrop, bool isImageMode, BackdropControllerKind kind)
|
|
{
|
|
CleanupBackdropControllers();
|
|
|
|
// Fall back to solid color if acrylic not supported
|
|
if (_configurationSource is null || !DesktopAcrylicController.IsSupported())
|
|
{
|
|
SetupTransparentBackdrop(backdrop.FallbackColor);
|
|
return;
|
|
}
|
|
|
|
// DesktopAcrylicController and SystemBackdrop can't be active simultaneously
|
|
SystemBackdrop = null;
|
|
|
|
// Image mode: no tint here, BlurImageControl handles it (avoids double-tinting)
|
|
var effectiveTintOpacity = isImageMode
|
|
? 0.0f
|
|
: backdrop.EffectiveOpacity;
|
|
|
|
_acrylicController = new DesktopAcrylicController
|
|
{
|
|
Kind = kind == BackdropControllerKind.AcrylicThin
|
|
? DesktopAcrylicKind.Thin
|
|
: DesktopAcrylicKind.Default,
|
|
TintColor = backdrop.TintColor,
|
|
TintOpacity = effectiveTintOpacity,
|
|
FallbackColor = backdrop.FallbackColor,
|
|
LuminosityOpacity = backdrop.EffectiveLuminosityOpacity,
|
|
};
|
|
|
|
// Requires "using WinRT;" for Window.As<>()
|
|
_acrylicController.AddSystemBackdropTarget(this.As<ICompositionSupportsSystemBackdrop>());
|
|
_acrylicController.SetSystemBackdropConfiguration(_configurationSource);
|
|
}
|
|
|
|
private void SetupMica(BackdropParameters backdrop, bool isImageMode, BackdropControllerKind kind)
|
|
{
|
|
CleanupBackdropControllers();
|
|
|
|
// Fall back to solid color if Mica not supported
|
|
if (_configurationSource is null || !MicaController.IsSupported())
|
|
{
|
|
SetupTransparentBackdrop(backdrop.FallbackColor);
|
|
return;
|
|
}
|
|
|
|
// MicaController and SystemBackdrop can't be active simultaneously
|
|
SystemBackdrop = null;
|
|
_configurationSource.Theme = _themeService.Current.Theme == ElementTheme.Dark
|
|
? SystemBackdropTheme.Dark
|
|
: SystemBackdropTheme.Light;
|
|
|
|
var hasColorization = _themeService.Current.HasColorization || isImageMode;
|
|
|
|
_micaController = new MicaController
|
|
{
|
|
Kind = kind == BackdropControllerKind.MicaAlt
|
|
? MicaKind.BaseAlt
|
|
: MicaKind.Base,
|
|
};
|
|
|
|
// Only set tint properties when colorization is active
|
|
// Otherwise let system handle light/dark theme defaults automatically
|
|
if (hasColorization)
|
|
{
|
|
// Image mode: no tint here, BlurImageControl handles it (avoids double-tinting)
|
|
_micaController.TintColor = backdrop.TintColor;
|
|
_micaController.TintOpacity = isImageMode ? 0.0f : backdrop.EffectiveOpacity;
|
|
_micaController.FallbackColor = backdrop.FallbackColor;
|
|
_micaController.LuminosityOpacity = backdrop.EffectiveLuminosityOpacity;
|
|
}
|
|
|
|
_micaController.AddSystemBackdropTarget(this.As<ICompositionSupportsSystemBackdrop>());
|
|
_micaController.SetSystemBackdropConfiguration(_configurationSource);
|
|
}
|
|
|
|
private void ShowHwnd(IntPtr hwndValue, MonitorBehavior target)
|
|
{
|
|
var positionWindowForTargetMonitor = (HWND hwnd) =>
|
|
{
|
|
if (target == MonitorBehavior.ToLast)
|
|
{
|
|
var originalScreen = new SizeInt32(_currentWindowPosition.ScreenWidth, _currentWindowPosition.ScreenHeight);
|
|
var newRect = WindowPositionHelper.AdjustRectForVisibility(_currentWindowPosition.ToPhysicalWindowRectangle(), originalScreen, _currentWindowPosition.Dpi);
|
|
MoveAndResizeDpiAware(newRect);
|
|
}
|
|
else
|
|
{
|
|
var display = GetScreen(hwnd, target);
|
|
PositionCentered(display);
|
|
}
|
|
};
|
|
ShowHwnd(hwndValue, positionWindowForTargetMonitor);
|
|
}
|
|
|
|
private void ShowHwnd(IntPtr hwndValue, Point anchorInPixels, AnchorPoint anchorCorner)
|
|
{
|
|
var positionWindowForAnchor = (HWND hwnd) =>
|
|
{
|
|
PInvoke.GetWindowRect(hwnd, out var bounds);
|
|
var swpFlags = SET_WINDOW_POS_FLAGS.SWP_NOSIZE | SET_WINDOW_POS_FLAGS.SWP_NOACTIVATE | SET_WINDOW_POS_FLAGS.SWP_NOZORDER;
|
|
switch (anchorCorner)
|
|
{
|
|
case AnchorPoint.TopLeft:
|
|
PInvoke.SetWindowPos(
|
|
hwnd,
|
|
HWND.HWND_TOP,
|
|
(int)anchorInPixels.X,
|
|
(int)anchorInPixels.Y,
|
|
0,
|
|
0,
|
|
swpFlags);
|
|
break;
|
|
case AnchorPoint.TopRight:
|
|
PInvoke.SetWindowPos(
|
|
hwnd,
|
|
HWND.HWND_TOP,
|
|
(int)(anchorInPixels.X - bounds.Width),
|
|
(int)anchorInPixels.Y,
|
|
0,
|
|
0,
|
|
swpFlags);
|
|
break;
|
|
case AnchorPoint.BottomLeft:
|
|
PInvoke.SetWindowPos(
|
|
hwnd,
|
|
HWND.HWND_TOP,
|
|
(int)anchorInPixels.X,
|
|
(int)(anchorInPixels.Y - bounds.Height),
|
|
0,
|
|
0,
|
|
swpFlags);
|
|
break;
|
|
case AnchorPoint.BottomRight:
|
|
PInvoke.SetWindowPos(
|
|
hwnd,
|
|
HWND.HWND_TOP,
|
|
(int)(anchorInPixels.X - bounds.Width),
|
|
(int)(anchorInPixels.Y - bounds.Height),
|
|
0,
|
|
0,
|
|
swpFlags);
|
|
break;
|
|
}
|
|
};
|
|
ShowHwnd(hwndValue, positionWindowForAnchor);
|
|
}
|
|
|
|
private void ShowHwnd(IntPtr hwndValue, Action<HWND>? positionWindow)
|
|
{
|
|
StopAutoGoHome();
|
|
|
|
var hwnd = new HWND(hwndValue != 0 ? hwndValue : _hwnd);
|
|
|
|
// Remember, IsIconic == "minimized", which is entirely different state
|
|
// from "show/hide"
|
|
// If we're currently minimized, restore us first, before we reveal
|
|
// our window. Otherwise, we'd just be showing a minimized window -
|
|
// which would remain not visible to the user.
|
|
if (PInvoke.IsIconic(hwnd))
|
|
{
|
|
// Make sure our HWND is cloaked before any possible window manipulations
|
|
Cloak();
|
|
|
|
PInvoke.ShowWindow(hwnd, SHOW_WINDOW_CMD.SW_RESTORE);
|
|
}
|
|
|
|
if (positionWindow is not null)
|
|
{
|
|
positionWindow(hwnd);
|
|
}
|
|
|
|
// Check if the debugger is attached. If it is, we don't want to apply the tool window style,
|
|
// because that would make it hard to debug the app
|
|
if (Debugger.IsAttached)
|
|
{
|
|
_hiddenOwnerBehavior.ShowInTaskbar(this, true);
|
|
}
|
|
|
|
// Just to be sure, SHOW our hwnd.
|
|
PInvoke.ShowWindow(hwnd, SHOW_WINDOW_CMD.SW_SHOW);
|
|
|
|
// Once we're done, uncloak to avoid all animations
|
|
Uncloak();
|
|
|
|
PInvoke.SetForegroundWindow(hwnd);
|
|
PInvoke.SetActiveWindow(hwnd);
|
|
|
|
// Push our window to the top of the Z-order and make it the topmost, so that it appears above all other windows.
|
|
// We want to remove the topmost status when we hide the window (because we cloak it instead of hiding it).
|
|
PInvoke.SetWindowPos(hwnd, HWND.HWND_TOPMOST, 0, 0, 0, 0, SET_WINDOW_POS_FLAGS.SWP_NOMOVE | SET_WINDOW_POS_FLAGS.SWP_NOSIZE);
|
|
}
|
|
|
|
private static DisplayArea GetScreen(HWND currentHwnd, MonitorBehavior target)
|
|
{
|
|
// Leaving a note here, in case we ever need it:
|
|
// https://github.com/microsoft/microsoft-ui-xaml/issues/6454
|
|
// If we need to ever FindAll, we'll need to iterate manually
|
|
var displayAreas = Microsoft.UI.Windowing.DisplayArea.FindAll();
|
|
switch (target)
|
|
{
|
|
case MonitorBehavior.InPlace:
|
|
if (PInvoke.GetWindowRect(currentHwnd, out var bounds))
|
|
{
|
|
RectInt32 converted = new(bounds.X, bounds.Y, bounds.Width, bounds.Height);
|
|
return DisplayArea.GetFromRect(converted, DisplayAreaFallback.Nearest);
|
|
}
|
|
|
|
break;
|
|
|
|
case MonitorBehavior.ToFocusedWindow:
|
|
var foregroundWindowHandle = PInvoke.GetForegroundWindow();
|
|
if (foregroundWindowHandle != IntPtr.Zero)
|
|
{
|
|
if (PInvoke.GetWindowRect(foregroundWindowHandle, out var fgBounds))
|
|
{
|
|
RectInt32 converted = new(fgBounds.X, fgBounds.Y, fgBounds.Width, fgBounds.Height);
|
|
return DisplayArea.GetFromRect(converted, DisplayAreaFallback.Nearest);
|
|
}
|
|
}
|
|
|
|
break;
|
|
|
|
case MonitorBehavior.ToPrimary:
|
|
return DisplayArea.Primary;
|
|
|
|
case MonitorBehavior.ToMouse:
|
|
default:
|
|
if (PInvoke.GetCursorPos(out var cursorPos))
|
|
{
|
|
return DisplayArea.GetFromPoint(new PointInt32(cursorPos.X, cursorPos.Y), DisplayAreaFallback.Nearest);
|
|
}
|
|
|
|
break;
|
|
}
|
|
|
|
return DisplayArea.Primary;
|
|
}
|
|
|
|
public void Receive(ShowWindowMessage message)
|
|
{
|
|
_isLoadedFromDock = false;
|
|
|
|
var settings = App.Current.Services.GetRequiredService<ISettingsService>().Settings;
|
|
|
|
// Start session tracking
|
|
_sessionStopwatch = Stopwatch.StartNew();
|
|
_sessionCommandsExecuted = 0;
|
|
_sessionPagesVisited = 0;
|
|
|
|
ShowHwnd(message.Hwnd, settings.SummonOn);
|
|
}
|
|
|
|
internal void Receive(ShowPaletteAtMessage message)
|
|
{
|
|
_isLoadedFromDock = true;
|
|
|
|
// Reset the size in case users have resized a dock window.
|
|
// Ideally in the future, we'll have defined sizes that opening
|
|
// a dock window will adhere to, but alas, that's the future.
|
|
RestoreWindowPositionFromMemory();
|
|
|
|
ShowHwnd(HWND.Null, message.PosPixels, message.Anchor);
|
|
}
|
|
|
|
public void Receive(HideWindowMessage message)
|
|
{
|
|
// This might come in off the UI thread. Make sure to hop back.
|
|
DispatcherQueue.TryEnqueue(() =>
|
|
{
|
|
EndSession("Hide");
|
|
HideWindow();
|
|
});
|
|
}
|
|
|
|
public void Receive(QuitMessage message) =>
|
|
|
|
// This might come in on a background thread
|
|
DispatcherQueue.TryEnqueue(() => Close());
|
|
|
|
public void Receive(DismissMessage message)
|
|
{
|
|
if (message.ForceGoHome)
|
|
{
|
|
WeakReferenceMessenger.Default.Send(new GoHomeMessage(false, false));
|
|
}
|
|
|
|
// This might come in off the UI thread. Make sure to hop back.
|
|
DispatcherQueue.TryEnqueue(() =>
|
|
{
|
|
EndSession("Dismiss");
|
|
HideWindow();
|
|
});
|
|
}
|
|
|
|
// Session telemetry: Track metrics during the Command Palette session
|
|
// These receivers increment counters that are sent when EndSession is called
|
|
public void Receive(NavigateToPageMessage message)
|
|
{
|
|
_sessionPagesVisited++;
|
|
}
|
|
|
|
public void Receive(NavigationDepthMessage message)
|
|
{
|
|
if (message.Depth > _sessionMaxNavigationDepth)
|
|
{
|
|
_sessionMaxNavigationDepth = message.Depth;
|
|
}
|
|
}
|
|
|
|
public void Receive(SearchQueryMessage message)
|
|
{
|
|
_sessionSearchQueriesCount++;
|
|
}
|
|
|
|
public void Receive(ErrorOccurredMessage message)
|
|
{
|
|
_sessionErrorCount++;
|
|
}
|
|
|
|
/// <summary>
|
|
/// Ends the current telemetry session and emits the CmdPal_SessionDuration event.
|
|
/// Aggregates all session metrics collected since ShowWindow and sends them to telemetry.
|
|
/// </summary>
|
|
/// <param name="dismissalReason">The reason the session ended (e.g., Dismiss, Hide, LostFocus).</param>
|
|
private void EndSession(string dismissalReason)
|
|
{
|
|
if (_sessionStopwatch is not null)
|
|
{
|
|
_sessionStopwatch.Stop();
|
|
TelemetryForwarder.LogSessionDuration(
|
|
(ulong)_sessionStopwatch.ElapsedMilliseconds,
|
|
_sessionCommandsExecuted,
|
|
_sessionPagesVisited,
|
|
dismissalReason,
|
|
_sessionSearchQueriesCount,
|
|
_sessionMaxNavigationDepth,
|
|
_sessionErrorCount);
|
|
_sessionStopwatch = null;
|
|
}
|
|
}
|
|
|
|
/// <summary>
|
|
/// Increments the session commands executed counter for telemetry.
|
|
/// Called by TelemetryForwarder when an extension command is invoked.
|
|
/// </summary>
|
|
internal void IncrementCommandsExecuted()
|
|
{
|
|
_sessionCommandsExecuted++;
|
|
}
|
|
|
|
private void HideWindow()
|
|
{
|
|
// Cloak our HWND to avoid all animations.
|
|
var cloaked = Cloak();
|
|
|
|
// Then hide our HWND, to make sure that the OS gives the FG / focus back to another app
|
|
// (there's no way for us to guess what the right hwnd might be, only the OS can do it right)
|
|
PInvoke.ShowWindow(_hwnd, SHOW_WINDOW_CMD.SW_HIDE);
|
|
|
|
if (cloaked)
|
|
{
|
|
// TRICKY: show our HWND again. This will trick XAML into painting our
|
|
// HWND again, so that we avoid the "flicker" caused by a WinUI3 app
|
|
// window being first shown
|
|
// SW_SHOWNA will prevent us for trying to fight the focus back
|
|
PInvoke.ShowWindow(_hwnd, SHOW_WINDOW_CMD.SW_SHOWNA);
|
|
|
|
// Intentionally leave the window cloaked. So our window is "visible",
|
|
// but also cloaked, so you can't see it.
|
|
|
|
// If the window was not cloaked, then leave it hidden.
|
|
// Sure, it's not ideal, but at least it's not visible.
|
|
}
|
|
|
|
WeakReferenceMessenger.Default.Send(new WindowHiddenMessage());
|
|
|
|
// Start auto-go-home timer
|
|
RestartAutoGoHome();
|
|
}
|
|
|
|
private void StopAutoGoHome()
|
|
{
|
|
_autoGoHomeTimer.Stop();
|
|
}
|
|
|
|
private void RestartAutoGoHome()
|
|
{
|
|
if (_autoGoHomeInterval == Timeout.InfiniteTimeSpan)
|
|
{
|
|
return;
|
|
}
|
|
|
|
_autoGoHomeTimer.Stop();
|
|
_autoGoHomeTimer.Start();
|
|
}
|
|
|
|
private bool Cloak()
|
|
{
|
|
bool wasCloaked;
|
|
unsafe
|
|
{
|
|
BOOL value = true;
|
|
var hr = PInvoke.DwmSetWindowAttribute(_hwnd, DWMWINDOWATTRIBUTE.DWMWA_CLOAK, &value, (uint)sizeof(BOOL));
|
|
if (hr.Failed)
|
|
{
|
|
Logger.LogWarning($"DWM cloaking of the main window failed. HRESULT: {hr.Value}.");
|
|
}
|
|
else
|
|
{
|
|
IsVisibleToUser = false;
|
|
}
|
|
|
|
wasCloaked = hr.Succeeded;
|
|
}
|
|
|
|
return wasCloaked;
|
|
}
|
|
|
|
private void Uncloak()
|
|
{
|
|
unsafe
|
|
{
|
|
BOOL value = false;
|
|
PInvoke.DwmSetWindowAttribute(_hwnd, DWMWINDOWATTRIBUTE.DWMWA_CLOAK, &value, (uint)sizeof(BOOL));
|
|
IsVisibleToUser = true;
|
|
}
|
|
}
|
|
|
|
internal void MainWindow_Closed(object sender, WindowEventArgs args)
|
|
{
|
|
var serviceProvider = App.Current.Services;
|
|
|
|
if (!_isLoadedFromDock)
|
|
{
|
|
UpdateWindowPositionInMemory();
|
|
}
|
|
|
|
var settingsService = serviceProvider.GetRequiredService<ISettingsService>();
|
|
var settings = settingsService.Settings;
|
|
if (settings is not null)
|
|
{
|
|
// If we were last shown from the dock, _currentWindowPosition still holds
|
|
// the last non-dock placement because dock sessions intentionally skip updates.
|
|
if (_currentWindowPosition.IsSizeValid)
|
|
{
|
|
settingsService.UpdateSettings(s => s with { LastWindowPosition = _currentWindowPosition });
|
|
}
|
|
}
|
|
|
|
var extensionService = serviceProvider.GetService<IExtensionService>()!;
|
|
extensionService.SignalStopExtensionsAsync();
|
|
|
|
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.
|
|
App.Current.DebugSettings.FailFastOnErrors = false;
|
|
_localKeyboardListener.Dispose();
|
|
DisposeAcrylic();
|
|
|
|
_keyboardListener.Stop();
|
|
Environment.Exit(0);
|
|
}
|
|
|
|
private void DisposeAcrylic()
|
|
{
|
|
CleanupBackdropControllers();
|
|
_configurationSource = null!;
|
|
}
|
|
|
|
// Updates our window s.t. the top of the window is draggable.
|
|
private void UpdateRegionsForCustomTitleBar()
|
|
{
|
|
var xamlRoot = RootElement.XamlRoot;
|
|
if (xamlRoot is null)
|
|
{
|
|
return;
|
|
}
|
|
|
|
// Specify the interactive regions of the title bar.
|
|
var scaleAdjustment = xamlRoot.RasterizationScale;
|
|
|
|
// Get the rectangle around our XAML content. We're going to mark this
|
|
// rectangle as "Passthrough", so that the normal window operations
|
|
// (resizing, dragging) don't apply in this space.
|
|
var transform = RootElement.TransformToVisual(null);
|
|
|
|
// Reserve 16px of space at the top for dragging.
|
|
var topHeight = 16;
|
|
var bounds = transform.TransformBounds(new Rect(
|
|
0,
|
|
topHeight,
|
|
RootElement.ActualWidth,
|
|
RootElement.ActualHeight));
|
|
var contentRect = GetRect(bounds, scaleAdjustment);
|
|
var rectArray = new RectInt32[] { contentRect };
|
|
var nonClientInputSrc = InputNonClientPointerSource.GetForWindowId(this.AppWindow.Id);
|
|
nonClientInputSrc.SetRegionRects(NonClientRegionKind.Passthrough, rectArray);
|
|
|
|
// Add a drag-able region on top
|
|
var w = RootElement.ActualWidth;
|
|
_ = RootElement.ActualHeight;
|
|
var dragSides = new RectInt32[]
|
|
{
|
|
GetRect(new Rect(0, 0, w, topHeight), scaleAdjustment), // the top, {topHeight=16} tall
|
|
};
|
|
nonClientInputSrc.SetRegionRects(NonClientRegionKind.Caption, dragSides);
|
|
}
|
|
|
|
private static RectInt32 GetRect(Rect bounds, double scale)
|
|
{
|
|
return new RectInt32(
|
|
_X: (int)Math.Round(bounds.X * scale),
|
|
_Y: (int)Math.Round(bounds.Y * scale),
|
|
_Width: (int)Math.Round(bounds.Width * scale),
|
|
_Height: (int)Math.Round(bounds.Height * scale));
|
|
}
|
|
|
|
internal void MainWindow_Activated(object sender, WindowActivatedEventArgs args)
|
|
{
|
|
if (!_themeServiceInitialized && args.WindowActivationState != WindowActivationState.Deactivated)
|
|
{
|
|
try
|
|
{
|
|
_themeService.Initialize();
|
|
_themeServiceInitialized = true;
|
|
}
|
|
catch (Exception ex)
|
|
{
|
|
Logger.LogError("Failed to initialize ThemeService", ex);
|
|
}
|
|
}
|
|
|
|
if (args.WindowActivationState == WindowActivationState.Deactivated)
|
|
{
|
|
// Save the current window position before hiding the window
|
|
// but not when opened from dock — preserve the pre-dock size.
|
|
if (!_isLoadedFromDock)
|
|
{
|
|
UpdateWindowPositionInMemory();
|
|
}
|
|
|
|
// If there's a debugger attached...
|
|
if (System.Diagnostics.Debugger.IsAttached)
|
|
{
|
|
// ... then don't hide the window when it loses focus.
|
|
return;
|
|
}
|
|
|
|
// Are we disabled? If we are, then we don't want to dismiss on focus lost.
|
|
// This can happen if an extension wanted to show a modal dialog on top of our
|
|
// window i.e. in the case of an MSAL auth window.
|
|
if (PInvoke.IsWindowEnabled(_hwnd) == 0)
|
|
{
|
|
return;
|
|
}
|
|
|
|
// We're doing something that requires us to lose focus, but we don't want to hide the window
|
|
if (_preventHideWhenDeactivated)
|
|
{
|
|
return;
|
|
}
|
|
|
|
// This will DWM cloak our window:
|
|
EndSession("LostFocus");
|
|
HideWindow();
|
|
|
|
PowerToysTelemetry.Log.WriteEvent(new CmdPalDismissedOnLostFocus());
|
|
}
|
|
|
|
if (_configurationSource is not null)
|
|
{
|
|
_configurationSource.IsInputActive = args.WindowActivationState != WindowActivationState.Deactivated;
|
|
}
|
|
}
|
|
|
|
public void HandleLaunchNonUI(AppActivationArguments? activatedEventArgs)
|
|
{
|
|
// LOAD BEARING
|
|
// Any reading and processing of the activation arguments must be done
|
|
// synchronously in this method, before it returns. The sending instance
|
|
// remains blocked until this returns; afterward it may quit, causing
|
|
// the activation arguments to be lost.
|
|
if (activatedEventArgs is null)
|
|
{
|
|
Summon(string.Empty);
|
|
return;
|
|
}
|
|
|
|
try
|
|
{
|
|
if (activatedEventArgs.Kind == ExtendedActivationKind.StartupTask)
|
|
{
|
|
return;
|
|
}
|
|
|
|
if (activatedEventArgs.Kind == ExtendedActivationKind.Protocol)
|
|
{
|
|
if (activatedEventArgs.Data is IProtocolActivatedEventArgs protocolArgs)
|
|
{
|
|
if (protocolArgs.Uri.ToString() is string uri)
|
|
{
|
|
// was the URI "x-cmdpal://background" ?
|
|
if (uri.StartsWith("x-cmdpal://background", StringComparison.OrdinalIgnoreCase))
|
|
{
|
|
// we're running, we don't want to activate our window. bail
|
|
return;
|
|
}
|
|
else if (uri.StartsWith("x-cmdpal://settings", StringComparison.OrdinalIgnoreCase))
|
|
{
|
|
WeakReferenceMessenger.Default.Send<OpenSettingsMessage>(new());
|
|
return;
|
|
}
|
|
else if (uri.StartsWith("x-cmdpal://reload", StringComparison.OrdinalIgnoreCase))
|
|
{
|
|
var settings = App.Current.Services.GetRequiredService<ISettingsService>().Settings;
|
|
if (settings?.AllowExternalReload == true)
|
|
{
|
|
Logger.LogInfo("External Reload triggered");
|
|
WeakReferenceMessenger.Default.Send<ReloadCommandsMessage>(new());
|
|
}
|
|
else
|
|
{
|
|
Logger.LogInfo("External Reload is disabled");
|
|
}
|
|
|
|
return;
|
|
}
|
|
}
|
|
}
|
|
}
|
|
}
|
|
catch (COMException ex)
|
|
{
|
|
// https://learn.microsoft.com/en-us/windows/win32/rpc/rpc-return-values
|
|
const int RPC_S_SERVER_UNAVAILABLE = -2147023174;
|
|
const int RPC_S_CALL_FAILED = 2147023170;
|
|
|
|
// Accessing properties activatedEventArgs.Kind and activatedEventArgs.Data might cause COMException
|
|
// if the args are not valid or not passed correctly.
|
|
if (ex.HResult is RPC_S_SERVER_UNAVAILABLE or RPC_S_CALL_FAILED)
|
|
{
|
|
Logger.LogWarning(
|
|
$"COM exception (HRESULT {ex.HResult}) when accessing activation arguments. " +
|
|
$"This might be due to the calling application not passing them correctly or exiting before we could read them. " +
|
|
$"The application will continue running and fall back to showing the Command Palette window.");
|
|
}
|
|
else
|
|
{
|
|
Logger.LogError(
|
|
$"COM exception (HRESULT {ex.HResult}) when activating the application. " +
|
|
$"The application will continue running and fall back to showing the Command Palette window.",
|
|
ex);
|
|
}
|
|
}
|
|
|
|
Summon(string.Empty);
|
|
}
|
|
|
|
public void Summon(string commandId) =>
|
|
|
|
// The actual showing and hiding of the window will be done by the
|
|
// ShellPage. This is because we don't want to show the window if the
|
|
// user bound a hotkey to just an invokable command, which we can't
|
|
// know till the message is being handled.
|
|
WeakReferenceMessenger.Default.Send<HotkeySummonMessage>(new(commandId, _hwnd));
|
|
|
|
private void UnregisterHotkeys()
|
|
{
|
|
_keyboardListener.ClearHotkeys();
|
|
|
|
while (_hotkeys.Count > 0)
|
|
{
|
|
PInvoke.UnregisterHotKey(_hwnd, _hotkeys.Count - 1);
|
|
_hotkeys.RemoveAt(_hotkeys.Count - 1);
|
|
}
|
|
}
|
|
|
|
private void SetupHotkey(SettingsModel settings)
|
|
{
|
|
UnregisterHotkeys();
|
|
|
|
var globalHotkey = settings.Hotkey;
|
|
if (globalHotkey is not null)
|
|
{
|
|
if (settings.UseLowLevelGlobalHotkey)
|
|
{
|
|
_keyboardListener.SetHotkeyAction(globalHotkey.Win, globalHotkey.Ctrl, globalHotkey.Shift, globalHotkey.Alt, (byte)globalHotkey.Code, string.Empty);
|
|
|
|
_hotkeys.Add(new(globalHotkey, string.Empty));
|
|
}
|
|
else
|
|
{
|
|
var vk = globalHotkey.Code;
|
|
var modifiers =
|
|
(globalHotkey.Alt ? HOT_KEY_MODIFIERS.MOD_ALT : 0) |
|
|
(globalHotkey.Ctrl ? HOT_KEY_MODIFIERS.MOD_CONTROL : 0) |
|
|
(globalHotkey.Shift ? HOT_KEY_MODIFIERS.MOD_SHIFT : 0) |
|
|
(globalHotkey.Win ? HOT_KEY_MODIFIERS.MOD_WIN : 0)
|
|
;
|
|
|
|
var success = PInvoke.RegisterHotKey(_hwnd, _hotkeys.Count, modifiers, (uint)vk);
|
|
if (success)
|
|
{
|
|
_hotkeys.Add(new(globalHotkey, string.Empty));
|
|
}
|
|
}
|
|
}
|
|
|
|
foreach (var commandHotkey in settings.CommandHotkeys)
|
|
{
|
|
var key = commandHotkey.Hotkey;
|
|
|
|
if (key is not null)
|
|
{
|
|
if (settings.UseLowLevelGlobalHotkey)
|
|
{
|
|
_keyboardListener.SetHotkeyAction(key.Win, key.Ctrl, key.Shift, key.Alt, (byte)key.Code, commandHotkey.CommandId);
|
|
|
|
_hotkeys.Add(new(globalHotkey, string.Empty));
|
|
}
|
|
else
|
|
{
|
|
var vk = key.Code;
|
|
var modifiers =
|
|
(key.Alt ? HOT_KEY_MODIFIERS.MOD_ALT : 0) |
|
|
(key.Ctrl ? HOT_KEY_MODIFIERS.MOD_CONTROL : 0) |
|
|
(key.Shift ? HOT_KEY_MODIFIERS.MOD_SHIFT : 0) |
|
|
(key.Win ? HOT_KEY_MODIFIERS.MOD_WIN : 0)
|
|
;
|
|
|
|
var success = PInvoke.RegisterHotKey(_hwnd, _hotkeys.Count, modifiers, (uint)vk);
|
|
if (success)
|
|
{
|
|
_hotkeys.Add(commandHotkey);
|
|
}
|
|
}
|
|
}
|
|
}
|
|
}
|
|
|
|
private void HandleSummon(string commandId)
|
|
{
|
|
if (_ignoreHotKeyWhenFullScreen)
|
|
{
|
|
// If we're in full screen mode, ignore the hotkey
|
|
if (WindowHelper.IsWindowFullscreen())
|
|
{
|
|
return;
|
|
}
|
|
}
|
|
|
|
HandleSummonCore(commandId);
|
|
}
|
|
|
|
private void HandleSummonCore(string commandId)
|
|
{
|
|
var isRootHotkey = string.IsNullOrEmpty(commandId);
|
|
PowerToysTelemetry.Log.WriteEvent(new CmdPalHotkeySummoned(isRootHotkey));
|
|
|
|
var isVisible = this.Visible;
|
|
|
|
unsafe
|
|
{
|
|
// We need to check if our window is cloaked or not. A cloaked window is still
|
|
// technically visible, because SHOW/HIDE != iconic (minimized) != cloaked
|
|
// (these are all separate states)
|
|
long attr = 0;
|
|
PInvoke.DwmGetWindowAttribute(_hwnd, DWMWINDOWATTRIBUTE.DWMWA_CLOAKED, &attr, sizeof(long));
|
|
if (attr == 1 /* DWM_CLOAKED_APP */)
|
|
{
|
|
isVisible = false;
|
|
}
|
|
}
|
|
|
|
// Note to future us: the wParam will have the index of the hotkey we registered.
|
|
// We can use that in the future to differentiate the hotkeys we've pressed
|
|
// so that we can bind hotkeys to individual commands
|
|
if (!isVisible || !isRootHotkey)
|
|
{
|
|
Summon(commandId);
|
|
}
|
|
else if (isRootHotkey)
|
|
{
|
|
// If there's a debugger attached...
|
|
if (System.Diagnostics.Debugger.IsAttached)
|
|
{
|
|
// ... then manually hide our window. When debugged, we won't get the cool cloaking,
|
|
// but that's the price to pay for having the HWND not light-dismiss while we're debugging.
|
|
Cloak();
|
|
this.Hide();
|
|
WeakReferenceMessenger.Default.Send(new WindowHiddenMessage());
|
|
|
|
return;
|
|
}
|
|
|
|
HideWindow();
|
|
}
|
|
}
|
|
|
|
private LRESULT HotKeyPrc(
|
|
HWND hwnd,
|
|
uint uMsg,
|
|
WPARAM wParam,
|
|
LPARAM lParam)
|
|
{
|
|
switch (uMsg)
|
|
{
|
|
// Prevent the window from maximizing when double-clicking the title bar area
|
|
case PInvoke.WM_NCLBUTTONDBLCLK:
|
|
return (LRESULT)IntPtr.Zero;
|
|
|
|
// When restoring a saved position across monitors with different DPIs,
|
|
// MoveAndResize already sets the correctly-scaled size. Suppress the
|
|
// framework's automatic DPI resize to avoid double-scaling.
|
|
case PInvoke.WM_DPICHANGED when _suppressDpiChange:
|
|
return (LRESULT)IntPtr.Zero;
|
|
|
|
case PInvoke.WM_HOTKEY:
|
|
{
|
|
var hotkeyIndex = (int)wParam.Value;
|
|
if (hotkeyIndex < _hotkeys.Count)
|
|
{
|
|
var hotkey = _hotkeys[hotkeyIndex];
|
|
HandleSummon(hotkey.CommandId);
|
|
}
|
|
|
|
return (LRESULT)IntPtr.Zero;
|
|
}
|
|
|
|
default:
|
|
if (uMsg == WM_TASKBAR_RESTART)
|
|
{
|
|
HotReloadSettings();
|
|
}
|
|
|
|
break;
|
|
}
|
|
|
|
return PInvoke.CallWindowProc(_originalWndProc, hwnd, uMsg, wParam, lParam);
|
|
}
|
|
|
|
public void Dispose()
|
|
{
|
|
_localKeyboardListener.Dispose();
|
|
_windowThemeSynchronizer.Dispose();
|
|
DisposeAcrylic();
|
|
}
|
|
|
|
void IRecipient<ShowPaletteAtMessage>.Receive(ShowPaletteAtMessage message) => Receive(message);
|
|
|
|
public void Receive(ToggleDevRibbonMessage message)
|
|
{
|
|
_devRibbon?.Visibility = _devRibbon.Visibility == Visibility.Visible ? Visibility.Collapsed : Visibility.Visible;
|
|
}
|
|
|
|
public void Receive(DragStartedMessage message)
|
|
{
|
|
_preventHideWhenDeactivated = true;
|
|
}
|
|
|
|
public void Receive(DragCompletedMessage message)
|
|
{
|
|
_preventHideWhenDeactivated = false;
|
|
Task.Delay(200).ContinueWith(_ =>
|
|
{
|
|
DispatcherQueue.TryEnqueue(StealForeground);
|
|
});
|
|
}
|
|
|
|
private unsafe void StealForeground()
|
|
{
|
|
var foregroundWindow = PInvoke.GetForegroundWindow();
|
|
if (foregroundWindow == _hwnd)
|
|
{
|
|
return;
|
|
}
|
|
|
|
// This is bad, evil, and I'll have to forgo today's dinner dessert to punish myself
|
|
// for writing this. But there's no way to make this work without it.
|
|
// If the window is not reactivated, the UX breaks down: a deactivated window has to
|
|
// be activated and then deactivated again to hide.
|
|
var currentThreadId = PInvoke.GetCurrentThreadId();
|
|
var foregroundThreadId = PInvoke.GetWindowThreadProcessId(foregroundWindow, null);
|
|
if (foregroundThreadId != currentThreadId)
|
|
{
|
|
PInvoke.AttachThreadInput(currentThreadId, foregroundThreadId, true);
|
|
PInvoke.SetForegroundWindow(_hwnd);
|
|
PInvoke.AttachThreadInput(currentThreadId, foregroundThreadId, false);
|
|
}
|
|
else
|
|
{
|
|
PInvoke.SetForegroundWindow(_hwnd);
|
|
}
|
|
}
|
|
}
|