mirror of
https://github.com/microsoft/PowerToys.git
synced 2026-04-03 17:56:44 +02:00
## Summary of the Pull Request This PR fixes a crash caused by unsynchronized access to a context menu: - Fixes `System.ArgumentException: Destination array was not long enough` crash in `CommandItemViewModel.AllCommands` caused by `List<T>.AddRange()` racing with background `BuildAndInitMoreCommands` mutations - Replaces mutable `List<T>` public surfaces with immutable array snapshots protected by a `Lock`; writers hold the lock, mutate the backing list, then atomically publish new snapshots via `volatile` fields that readers access lock-free - Applies the same snapshot pattern to `ContentPageViewModel`, using a bundled `CommandSnapshot` object for atomic publication (since `PrimaryCommand` drives command invocation there, not just UI hints) - Narrows `IContextMenuContext.MoreCommands` and `AllCommands` from `List<T>`/`IEnumerable<T>` to `IReadOnlyList<T>` to prevent consumers from casting back and mutating - Moves `SafeCleanup()` calls outside locks in cleanup paths to avoid holding the lock during cross-process RPC calls <!-- Please review the items on the PR checklist before submitting--> ## PR Checklist - [x] Closes: #45975 <!-- - [ ] Closes: #yyy (add separate lines for additional resolved issues) --> - [ ] **Communication:** I've discussed this with core contributors already. If the 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 <!-- Describe how you validated the behavior. Add automated tests wherever possible, but list manual validation steps taken as well --> ## Validation Steps Performed
193 lines
6.1 KiB
C#
193 lines
6.1 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.ComponentModel;
|
|
using CommunityToolkit.Mvvm.ComponentModel;
|
|
using CommunityToolkit.Mvvm.Messaging;
|
|
using CommunityToolkit.WinUI;
|
|
using Microsoft.CmdPal.UI.ViewModels.Messages;
|
|
using Microsoft.CommandPalette.Extensions.Toolkit;
|
|
using Windows.System;
|
|
using DispatcherQueue = Microsoft.UI.Dispatching.DispatcherQueue;
|
|
using DispatcherQueueTimer = Microsoft.UI.Dispatching.DispatcherQueueTimer;
|
|
|
|
namespace Microsoft.CmdPal.UI.ViewModels;
|
|
|
|
public sealed partial class CommandBarViewModel : ObservableObject,
|
|
IRecipient<UpdateCommandBarMessage>
|
|
{
|
|
private readonly DispatcherQueueTimer _debounceTimer;
|
|
|
|
private volatile ICommandBarContext? _pendingSelectedItem;
|
|
|
|
public ICommandBarContext? SelectedItem
|
|
{
|
|
get;
|
|
set
|
|
{
|
|
// TODO: verify if we can safely return early
|
|
// if (ReferenceEquals(field, value))
|
|
// {
|
|
// return;
|
|
// }
|
|
if (field is not null)
|
|
{
|
|
field.PropertyChanged -= SelectedItemPropertyChanged;
|
|
}
|
|
|
|
field = value;
|
|
|
|
if (field is not null)
|
|
{
|
|
PrimaryCommand = field.PrimaryCommand;
|
|
field.PropertyChanged += SelectedItemPropertyChanged;
|
|
}
|
|
else
|
|
{
|
|
PrimaryCommand = null;
|
|
}
|
|
|
|
UpdateContextItems();
|
|
OnPropertyChanged();
|
|
}
|
|
}
|
|
|
|
[ObservableProperty]
|
|
[NotifyPropertyChangedFor(nameof(HasPrimaryCommand))]
|
|
public partial CommandItemViewModel? PrimaryCommand { get; set; }
|
|
|
|
// TODO: PrimaryCommand.ShouldBeVisible is not observed, if it changes the bar won't refresh;
|
|
// but at this moment CommandItemViewModel won't raise INPC for ShouldBeVisible anyway.
|
|
public bool HasPrimaryCommand => PrimaryCommand is not null && PrimaryCommand.ShouldBeVisible;
|
|
|
|
[ObservableProperty]
|
|
[NotifyPropertyChangedFor(nameof(HasSecondaryCommand))]
|
|
public partial CommandItemViewModel? SecondaryCommand { get; set; }
|
|
|
|
public bool HasSecondaryCommand => SecondaryCommand is not null;
|
|
|
|
[ObservableProperty]
|
|
public partial bool ShouldShowContextMenu { get; set; } = false;
|
|
|
|
[ObservableProperty]
|
|
public partial PageViewModel? CurrentPage { get; set; }
|
|
|
|
public CommandBarViewModel()
|
|
{
|
|
var dispatcherQueue = DispatcherQueue.GetForCurrentThread();
|
|
if (dispatcherQueue is null)
|
|
{
|
|
throw new InvalidOperationException("DispatcherQueue is not available for the current thread.");
|
|
}
|
|
|
|
_debounceTimer = dispatcherQueue.CreateTimer();
|
|
WeakReferenceMessenger.Default.Register<UpdateCommandBarMessage>(this);
|
|
}
|
|
|
|
public void Receive(UpdateCommandBarMessage message)
|
|
{
|
|
_pendingSelectedItem = message.ViewModel;
|
|
|
|
// immediate: false is intentional — the timer tick always fires on the
|
|
// dispatcher queue thread, which guarantees ApplyPendingSelectedItem
|
|
// runs on the UI thread even if Receive is called from a background
|
|
// thread. Using immediate: true would invoke the delegate synchronously
|
|
// on the calling thread, bypassing the dispatcher.
|
|
_debounceTimer.Debounce(ApplyPendingSelectedItem, TimeSpan.FromMilliseconds(50));
|
|
}
|
|
|
|
private void ApplyPendingSelectedItem()
|
|
{
|
|
SelectedItem = _pendingSelectedItem;
|
|
}
|
|
|
|
private void SelectedItemPropertyChanged(object? sender, System.ComponentModel.PropertyChangedEventArgs e)
|
|
{
|
|
switch (e.PropertyName)
|
|
{
|
|
case nameof(SelectedItem.HasMoreCommands):
|
|
UpdateContextItems();
|
|
break;
|
|
}
|
|
}
|
|
|
|
private void UpdateContextItems()
|
|
{
|
|
if (SelectedItem is null)
|
|
{
|
|
SecondaryCommand = null;
|
|
ShouldShowContextMenu = false;
|
|
return;
|
|
}
|
|
|
|
SecondaryCommand = SelectedItem.SecondaryCommand;
|
|
var moreCommands = SelectedItem.MoreCommands;
|
|
|
|
ShouldShowContextMenu = moreCommands.Count > 1 && SelectedItem.HasMoreCommands;
|
|
|
|
OnPropertyChanged(nameof(HasSecondaryCommand));
|
|
OnPropertyChanged(nameof(SecondaryCommand));
|
|
OnPropertyChanged(nameof(ShouldShowContextMenu));
|
|
}
|
|
|
|
// InvokeItemCommand is what this will be in Xaml due to source generator
|
|
// this comes in when an item in the list is tapped
|
|
// [RelayCommand]
|
|
public ContextKeybindingResult InvokeItem(CommandContextItemViewModel item) =>
|
|
PerformCommand(item);
|
|
|
|
// this comes in when the primary button is tapped
|
|
public void InvokePrimaryCommand()
|
|
{
|
|
PerformCommand(PrimaryCommand);
|
|
}
|
|
|
|
// this comes in when the secondary button is tapped
|
|
public void InvokeSecondaryCommand()
|
|
{
|
|
PerformCommand(SecondaryCommand);
|
|
}
|
|
|
|
public ContextKeybindingResult CheckKeybinding(bool ctrl, bool alt, bool shift, bool win, VirtualKey key)
|
|
{
|
|
var keybindings = SelectedItem?.Keybindings();
|
|
if (keybindings is not null)
|
|
{
|
|
// Does the pressed key match any of the keybindings?
|
|
var pressedKeyChord = KeyChordHelpers.FromModifiers(ctrl, alt, shift, win, key, 0);
|
|
if (keybindings.TryGetValue(pressedKeyChord, out var matchedItem))
|
|
{
|
|
return matchedItem is not null ? PerformCommand(matchedItem) : ContextKeybindingResult.Unhandled;
|
|
}
|
|
}
|
|
|
|
return ContextKeybindingResult.Unhandled;
|
|
}
|
|
|
|
private ContextKeybindingResult PerformCommand(CommandItemViewModel? command)
|
|
{
|
|
if (command is null)
|
|
{
|
|
return ContextKeybindingResult.Unhandled;
|
|
}
|
|
|
|
WeakReferenceMessenger.Default.Send<PerformCommandMessage>(new(command.Command.Model, command.Model));
|
|
if (command.HasMoreCommands)
|
|
{
|
|
return ContextKeybindingResult.KeepOpen;
|
|
}
|
|
else
|
|
{
|
|
return ContextKeybindingResult.Hide;
|
|
}
|
|
}
|
|
}
|
|
|
|
public enum ContextKeybindingResult
|
|
{
|
|
Unhandled,
|
|
Hide,
|
|
KeepOpen,
|
|
}
|