CmdPal: Make it easier to add APIs in the future (#41056)

We learned a lot about adding interfaces in WinRT this week. I figured
I'd send a PR to write it all down.
This commit is contained in:
Mike Griese
2025-08-21 16:53:00 -05:00
committed by GitHub
parent 56aa9acfb4
commit e842621036
15 changed files with 334 additions and 13 deletions

View File

@@ -29,6 +29,15 @@ public partial class CommandViewModel : ExtensionObjectViewModel
public IconInfoViewModel Icon { get; private set; }
// UNDER NO CIRCUMSTANCES MAY SOMEONE WRITE TO THIS DICTIONARY.
// This is our copy of the data from the extension.
// Adding values to it does not add to the extension.
// Modifying it will not modify the extension
// (except it might, if the dictionary was passed by ref)
private Dictionary<string, ExtensionObject<object>>? _properties;
public IReadOnlyDictionary<string, ExtensionObject<object>>? Properties => _properties?.AsReadOnly();
public CommandViewModel(ICommand? command, WeakReference<IPageContext> pageContext)
: base(pageContext)
{
@@ -80,6 +89,11 @@ public partial class CommandViewModel : ExtensionObjectViewModel
UpdateProperty(nameof(Icon));
}
if (model is IExtendedAttributesProvider command2)
{
UpdatePropertiesFromExtension(command2);
}
model.PropChanged += Model_PropChanged;
}
@@ -130,4 +144,26 @@ public partial class CommandViewModel : ExtensionObjectViewModel
model.PropChanged -= Model_PropChanged;
}
}
private void UpdatePropertiesFromExtension(IExtendedAttributesProvider? model)
{
var propertiesFromExtension = model?.GetProperties();
if (propertiesFromExtension == null)
{
_properties = null;
return;
}
_properties = [];
// COPY the properties into us.
// The IDictionary that was passed to us may be marshalled by-ref or by-value, we _don't know_.
//
// If it's by-ref, the values are arbitrary objects that are out-of-proc.
// If it's bu-value, then everything is in-proc, and we can't mutate the data.
foreach (var property in propertiesFromExtension)
{
_properties.Add(property.Key, new(property.Value));
}
}
}

View File

@@ -27,6 +27,8 @@ public partial class IconDataViewModel : ObservableObject, IIconData
IRandomAccessStreamReference? IIconData.Data => Data.Unsafe;
public string? FontFamily { get; private set; }
public IconDataViewModel(IIconData? icon)
{
_model = new(icon);
@@ -43,5 +45,22 @@ public partial class IconDataViewModel : ObservableObject, IIconData
Icon = model.Icon;
Data = new(model.Data);
if (model is IExtendedAttributesProvider icon2)
{
var props = icon2.GetProperties();
// From Raymond Chen:
// Make sure you don't try do do something like
// icon2.GetProperties().TryGetValue("awesomeKey", out var awesomeValue);
// icon2.GetProperties().TryGetValue("slackerKey", out var slackerValue);
// because each call to GetProperties() is a cross process hop, and if you
// marshal-by-value the property set, then you don't want to throw it away and
// re-marshal it for every property. MAKE SURE YOU CACHE IT.
if (props?.TryGetValue("FontFamily", out var family) ?? false)
{
FontFamily = family as string;
}
}
}
}

View File

@@ -153,6 +153,11 @@ public sealed class CommandProviderWrapper
// On a BG thread here
fallbacks = model.FallbackCommands();
if (model is ICommandProvider2 two)
{
UnsafePreCacheApiAdditions(two);
}
Id = model.Id;
DisplayName = model.DisplayName;
Icon = new(model.Icon);
@@ -203,6 +208,19 @@ public sealed class CommandProviderWrapper
}
}
private void UnsafePreCacheApiAdditions(ICommandProvider2 provider)
{
var apiExtensions = provider.GetApiExtensionStubs();
Logger.LogDebug($"Provider supports {apiExtensions.Length} extensions");
foreach (var a in apiExtensions)
{
if (a is IExtendedAttributesProvider command2)
{
Logger.LogDebug($"{ProviderId}: Found an IExtendedAttributesProvider");
}
}
}
public override bool Equals(object? obj) => obj is CommandProviderWrapper wrapper && isValid == wrapper.isValid;
public override int GetHashCode() => _commandProvider.GetHashCode();

View File

@@ -25,7 +25,7 @@ public sealed class IconCacheService(DispatcherQueue dispatcherQueue)
{
if (!string.IsNullOrEmpty(icon.Icon))
{
var source = IconPathConverter.IconSourceMUX(icon.Icon, false);
var source = IconPathConverter.IconSourceMUX(icon.Icon, false, icon.FontFamily);
return source;
}
else if (icon.Data is not null)

View File

@@ -158,7 +158,7 @@ namespace winrt::Microsoft::Terminal::UI::implementation
// Return Value:
// - An IconElement with its IconSource set, if possible.
template<typename TIconSource>
TIconSource _getIconSource(const winrt::hstring& iconPath, bool monochrome, const int targetSize)
TIconSource _getIconSource(const winrt::hstring& iconPath, bool monochrome, const winrt::hstring& fontFamily, const int targetSize)
{
TIconSource iconSource{ nullptr };
@@ -187,6 +187,11 @@ namespace winrt::Microsoft::Terminal::UI::implementation
{
icon.FontFamily(winrt::Microsoft::UI::Xaml::Media::FontFamily{ L"Segoe Fluent Icons, Segoe MDL2 Assets" });
}
else if (!fontFamily.empty())
{
icon.FontFamily(winrt::Microsoft::UI::Xaml::Media::FontFamily{ fontFamily });
}
else
{
// Note: you _do_ need to manually set the font here.
@@ -225,9 +230,9 @@ namespace winrt::Microsoft::Terminal::UI::implementation
// return _getIconSource<Windows::UI::Xaml::Controls::IconSource>(path, false);
// }
static Microsoft::UI::Xaml::Controls::IconSource _IconSourceMUX(const hstring& path, bool monochrome, const int targetSize)
static Microsoft::UI::Xaml::Controls::IconSource _IconSourceMUX(const hstring& path, bool monochrome, const winrt::hstring& fontFamily, const int targetSize)
{
return _getIconSource<Microsoft::UI::Xaml::Controls::IconSource>(path, monochrome, targetSize);
return _getIconSource<Microsoft::UI::Xaml::Controls::IconSource>(path, monochrome, fontFamily, targetSize);
}
static SoftwareBitmap _convertToSoftwareBitmap(HICON hicon,
@@ -343,13 +348,14 @@ namespace winrt::Microsoft::Terminal::UI::implementation
MUX::Controls::IconSource IconPathConverter::IconSourceMUX(const winrt::hstring& iconPath,
const bool monochrome,
const winrt::hstring& fontFamily,
const int targetSize)
{
std::wstring_view iconPathWithoutIndex;
const auto indexOpt = _getIconIndex(iconPath, iconPathWithoutIndex);
if (!indexOpt.has_value())
{
return _IconSourceMUX(iconPath, monochrome, targetSize);
return _IconSourceMUX(iconPath, monochrome, fontFamily, targetSize);
}
const auto bitmapSource = _getImageIconSourceForBinary(iconPathWithoutIndex, indexOpt.value(), targetSize);
@@ -369,7 +375,7 @@ namespace winrt::Microsoft::Terminal::UI::implementation
const auto indexOpt = _getIconIndex(iconPath, iconPathWithoutIndex);
if (!indexOpt.has_value())
{
auto source = IconSourceMUX(iconPath, false, targetSize);
auto source = IconSourceMUX(iconPath, false, L"", targetSize);
Microsoft::UI::Xaml::Controls::IconSourceElement icon;
icon.IconSource(source);
return icon;

View File

@@ -10,7 +10,7 @@ namespace winrt::Microsoft::Terminal::UI::implementation
//static Windows::UI::Xaml::Controls::IconElement IconWUX(const winrt::hstring& iconPath);
//static Windows::UI::Xaml::Controls::IconSource IconSourceWUX(const winrt::hstring& iconPath);
static Microsoft::UI::Xaml::Controls::IconSource IconSourceMUX(const winrt::hstring& iconPath, bool convertToGrayscale, const int targetSize=24);
static Microsoft::UI::Xaml::Controls::IconSource IconSourceMUX(const winrt::hstring& iconPath, bool convertToGrayscale, const winrt::hstring& fontFamily, const int targetSize=24);
static Microsoft::UI::Xaml::Controls::IconElement IconMUX(const winrt::hstring& iconPath);
static Microsoft::UI::Xaml::Controls::IconElement IconMUX(const winrt::hstring& iconPath, const int targetSize);

View File

@@ -7,7 +7,7 @@ namespace Microsoft.Terminal.UI
{
// static Windows.UI.Xaml.Controls.IconElement IconWUX(String path);
// static Windows.UI.Xaml.Controls.IconSource IconSourceWUX(String path);
static Microsoft.UI.Xaml.Controls.IconSource IconSourceMUX(String path, Boolean convertToGrayscale);
static Microsoft.UI.Xaml.Controls.IconSource IconSourceMUX(String path, Boolean convertToGrayscale, String fontFamily);
static Microsoft.UI.Xaml.Controls.IconElement IconMUX(String path);
static Microsoft.UI.Xaml.Controls.IconElement IconMUX(String path, Int32 targetSize);
};

View File

@@ -1,7 +1,7 @@
---
author: Mike Griese
created on: 2024-07-19
last updated: 2025-03-10
last updated: 2025-08-08
issue id: n/a
---
@@ -1410,8 +1410,8 @@ interface IDetailsLink requires IDetailsData {
Windows.Foundation.Uri Link { get; };
String Text { get; };
}
interface IDetailsCommand requires IDetailsData {
ICommand Command { get; };
interface IDetailsCommands requires IDetailsData {
ICommand[] Commands { get; };
}
[uuid("58070392-02bb-4e89-9beb-47ceb8c3d741")]
interface IDetailsSeparator requires IDetailsData {}
@@ -1936,6 +1936,115 @@ When displaying a page:
* The title will be `IPage.Title ?? ICommand.Name`
* The icon will be `ICommand.Icon`
## Addenda I: API additions (ICommandProvider2)
In experiments with extending our API, we've found some quirks with the way
that we use WinRT's metadata-based marshalling (MBM). Typically, you'd add
another contract version, add the new runtimeclass under the new contract
version, and then have the client app just check if that contract is available.
However, we're not using `runtimeclass`es that are exposed from the extensions.
Everything is being transferred over MBM, based on the
`Microsoft.CommandPalette.Extensions.winmd`. And out-of-proc MBM has some
limitations. You can essentially only have a linear chain of requires for
extension interfaces.
> E.g. if it implements `IWidget2` and `IWidget2 requires IWidget`, and the object's `GetRuntimeClassName` gives `IWidget2`, we know to look at `IWidget2` directly and `IWidget` due to requires.
>
> The unfortunate thing for the developer experience when authoring an extension with cppwinrt/CsWinRT implementations of interfaces, is they implement each interface separately. So the `IInspectable::GetRuntimeClassName` method inherited by `Interface1` gives `"Interface1"` and the method inherited by `Interface2` gives `"Interface2"`.
>
> Only one of these interfaces can be what the object responds to with a QI for `IInspectable`, and that's the implementation that MBM calls.
That means we can't just add another interface easily. But what we can do:
> It might be possible to prefill the cache with the interfaces in question by
> marshaling objects that implement each of the interfaces in a way that
> registration-free MBM can work with.
>
> E.g. to keep it simple, marshal an
> instance of a separate implementation class per interface that "implements"
> each interface
So that's exactly what we're going to do, because it works. As an example,
we're going to add the following interface to our API:
```csharp
interface IExtendedAttributesProvider
{
Windows.Foundation.Collections.IMap<String, Object> GetProperties();
};
interface ICommandProvider2 requires ICommandProvider
{
Object[] GetApiExtensionStubs();
};
```
`IExtendedAttributesProvider` is just a simple interface, indicating that there's some
property bag of additional values that the host could read. We're starting with
this, because it's a helpful tool for us to add arbitrary properties to object
in an experimental fashion. We can continue to add more things we read from
this property set, without breaking the ABI.
As an example, `ICommand` proves uniquely challenging to extend, because it has
both the `IInvokableCommand` and `IPage` family trees of interfaces which
extend from it. Typically, it would be impossible for a class to be defined as
```cs
class MyCommandWithProperties : IInvokableCommand, IExtendedAttributesProvider { ... }
```
because Command Palette would only ever see the _first_ interface
(`IInvokableCommand`) via MBM, and would never be able to check if an extension
object was an `IExtendedAttributesProvider`. But a class defined like
```cs
class CommandWithOnlyProperties : IExtendedAttributesProvider { ... }
```
will populate the WinRT type cache in Command Palette with the type information
for `ICommandWithProperties`. In fact, if Command Palette has the
`IExtendedAttributesProvider` type info in it's cache, and then later receives a new
`MyCommandWithProperties` object, it'll actually be able to know that
`MyCommandWithProperties` is an `IExtendedAttributesProvider`. WinRT is just weird
like that some times.
`ICommandProvider2` is where the magic happens. This is a _linear_ addition to
`ICommandProvider`, which merely adds a method to return a set of objects.
Extensions can implement that method, by returning out stub implementations of
all the future additions to the API that we may add. In so doing, CmdPal will
be able to ask each extension for these stubs, pre-load the type cache for each
extension, and then never have to worry in the future.
As an example:
```cs
public partial class SamplePagesCommandsProvider : CommandProvider, ICommandProvider2 {
public SamplePagesCommandsProvider() {
DisplayName = "Sample Pages Commands";
Icon = new IconInfo("\uE82D");
}
public override ICommandItem[] TopLevelCommands() {
return [
new CommandItem(new SamplesListPage()) { Title = "Sample Pages", Subtitle = "View example commands" },
];
}
// Here is where we enable support for future additions to the API
public object[] GetApiExtensionStubs() {
return [new SupportCommandsWithProperties()];
}
private sealed partial class SupportCommandsWithProperties : IExtendedAttributesProvider {
public IDictionary<string, object>? GetProperties() => null;
}
}
```
Fortunately, we can put all of that (`GetApiExtensionStubs`,
`SupportCommandsWithProperties`) directly in `Toolkit.CommandProvider`, so
developers won't have to do anything. The toolkit will just do the right thing
for them.
## Class diagram
@@ -2210,6 +2319,8 @@ this prevents us from being able to use `[contract]` attributes to add to the
interfaces. We'll instead need to rely on the tried-and-true method of adding a
`IFoo2` when we want to add methods to `IFoo`.
[Addenda I](#addenda-i-api-additions-icommandprovider2) talks a little more on some of the challenges with adding more APIs.
[^1]: In this example, as in other places, I've referenced a
`Microsoft.DevPal.Extensions.InvokableCommand` class, as the base for that action.
Our SDK will include partial class implementations for interfaces like

View File

@@ -2,8 +2,11 @@
// 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.Collections.Generic;
using System.Diagnostics.CodeAnalysis;
using Microsoft.CommandPalette.Extensions;
using Microsoft.CommandPalette.Extensions.Toolkit;
using Windows.Foundation;
using Windows.System;
using Windows.Win32;
@@ -169,6 +172,61 @@ internal sealed partial class SampleListPage : ListPage
{
Title = "Get the name of the Foreground window",
},
new ListItem(new CommandWithProperties())
{
Title = "I have properties",
},
new ListItem(new OtherCommandWithProperties())
{
Title = "I also have properties",
},
];
}
internal sealed partial class CommandWithProperties : InvokableCommand, IExtendedAttributesProvider
{
private FontIconData _icon = new("\u0026", "Wingdings");
public override IconInfo Icon => new(_icon, _icon);
public override string Name => "Whatever";
// LOAD-BEARING: Use a Windows.Foundation.Collections.ValueSet as the
// backing store for Properties. A regular `Dictionary<string, object>`
// will not work across the ABI
public IDictionary<string, object> GetProperties() => new Windows.Foundation.Collections.ValueSet()
{
{ "Foo", "bar" },
{ "Secret", 42 },
{ "hmm?", null },
};
}
internal sealed partial class OtherCommandWithProperties : IExtendedAttributesProvider, IInvokableCommand
{
public string Name => "Whatever 2";
public IIconInfo Icon => new IconInfo("\uF146");
public string Id => string.Empty;
public event TypedEventHandler<object, IPropChangedEventArgs> PropChanged;
public ICommandResult Invoke(object sender)
{
PropChanged?.Invoke(this, new PropChangedEventArgs(nameof(Name)));
return CommandResult.ShowToast("whoop");
}
// LOAD-BEARING: Use a Windows.Foundation.Collections.ValueSet as the
// backing store for Properties. A regular `Dictionary<string, object>`
// will not work across the ABI
public IDictionary<string, object> GetProperties() => new Windows.Foundation.Collections.ValueSet()
{
{ "yo", "dog" },
{ "Secret", 12345 },
{ "hmm?", null },
};
}
}

View File

@@ -3,7 +3,7 @@
"SamplePagesExtension (Package)": {
"commandName": "MsixPackage",
"doNotLaunchApp": true,
"nativeDebugging": true
"nativeDebugging": false
},
"SamplePagesExtension (Unpackaged)": {
"commandName": "Project"

View File

@@ -6,7 +6,7 @@ using Windows.Foundation;
namespace Microsoft.CommandPalette.Extensions.Toolkit;
public abstract partial class CommandProvider : ICommandProvider
public abstract partial class CommandProvider : ICommandProvider, ICommandProvider2
{
public virtual string Id { get; protected set; } = string.Empty;
@@ -47,4 +47,26 @@ public abstract partial class CommandProvider : ICommandProvider
{
}
}
/// <summary>
/// This is used to manually populate the WinRT type cache in CmdPal with
/// any interfaces that might not follow a straight linear path of requires.
///
/// You don't need to call this as an extension author.
/// </summary>
/// <returns>an array of objects that implement all the leaf interfaces we support</returns>
public object[] GetApiExtensionStubs()
{
return [new SupportCommandsWithProperties()];
}
/// <summary>
/// A stub class which implements IExtendedAttributesProvider. Just marshalling this
/// across the ABI will be enough for CmdPal to store IExtendedAttributesProvider in
/// its type cache.
/// </summary>
private sealed partial class SupportCommandsWithProperties : IExtendedAttributesProvider
{
public IDictionary<string, object>? GetProperties() => null;
}
}

View File

@@ -0,0 +1,32 @@
// 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 Windows.Foundation.Collections;
namespace Microsoft.CommandPalette.Extensions.Toolkit;
/// <summary>
/// Represents an icon that is a font glyph.
/// This is used for icons that are defined by a specific font face,
/// such as Wingdings.
///
/// Note that Command Palette will default to using the Segoe Fluent Icons,
/// Segoe MDL2 Assets font for glyphs in the Segoe UI Symbol range, or Segoe
/// UI for any other glyphs. This class is only needed if you want a non-Segoe
/// font icon.
/// </summary>
public partial class FontIconData : IconData, IExtendedAttributesProvider
{
public string FontFamily { get; set; }
public FontIconData(string glyph, string fontFamily)
: base(glyph)
{
FontFamily = fontFamily;
}
public IDictionary<string, object>? GetProperties() => new ValueSet()
{
{ "FontFamily", FontFamily },
};
}

View File

@@ -2,6 +2,7 @@
// 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 Windows.Storage.Streams;
namespace Microsoft.CommandPalette.Extensions.Toolkit;

View File

@@ -27,6 +27,12 @@ public partial class IconInfo : IIconInfo
Dark = dark;
}
public IconInfo(IconData icon)
{
Light = icon;
Dark = icon;
}
internal IconInfo()
: this(string.Empty)
{

View File

@@ -364,5 +364,17 @@ namespace Microsoft.CommandPalette.Extensions
void InitializeWithHost(IExtensionHost host);
};
[contract(Microsoft.CommandPalette.Extensions.ExtensionsContract, 1)]
interface IExtendedAttributesProvider
{
Windows.Foundation.Collections.IMap<String, Object> GetProperties();
};
[contract(Microsoft.CommandPalette.Extensions.ExtensionsContract, 1)]
interface ICommandProvider2 requires ICommandProvider
{
Object[] GetApiExtensionStubs();
};
}