Files
PowerToys/src/modules/cmdpal/Microsoft.CmdPal.UI.ViewModels/CommandBarViewModel.cs
Jiří Polášek 90131e35d9 CmdPal: Prevent unsynchornized access to More commands (#46020)
## 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
2026-03-09 17:34:44 -05:00

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,
}