From e042e0df914448d73eb605a2f666001401f0f014 Mon Sep 17 00:00:00 2001 From: Michael Jolley Date: Tue, 21 Jul 2026 17:30:39 -0500 Subject: [PATCH 1/2] [CmdPal] Add host-driven navigation (GoToPageAsync on IExtensionHost2) Phase 3 of the built-in Command Palette auth feature. Lets an extension ask the host to navigate Command Palette to one of the extension's own pages, typically right after a successful sign-in. - SDK IDL: add GoToPageAsync(ICommand page, NavigationMode navigationMode) to IExtensionHost2 (contract v1, no version bump). - Host: implement AppExtensionHost.GoToPageAsync by reusing the existing nav pipeline via PerformCommandMessage with ShowWindowIfPage, preceded by GoHome/GoBack messages for those modes. Null page is a graceful no-op. - Toolkit: add ExtensionHost.GoToPageAsync and SupportsNavigation, mirroring the RequestAuthorizationAsync / SupportsAuthorization helper. - Tests: Toolkit fake-host tests (capability, NotSupported, ArgumentNull, arg forwarding) and host ViewModels tests asserting the right messages are sent. Co-authored-by: Copilot App <223556219+Copilot@users.noreply.github.com> Copilot-Session: 2ee31cb3-848f-43ba-ac48-f4e4485baa33 --- .../AppExtensionHost.cs | 38 +++++ .../Auth/AppExtensionHostNavigationTests.cs | 136 ++++++++++++++++++ .../Auth/ExtensionHostNavigationTests.cs | 111 ++++++++++++++ .../ExtensionHost.cs | 32 +++++ .../Microsoft.CommandPalette.Extensions.idl | 6 + 5 files changed, 323 insertions(+) create mode 100644 src/modules/cmdpal/Tests/Microsoft.CmdPal.UI.ViewModels.UnitTests/Auth/AppExtensionHostNavigationTests.cs create mode 100644 src/modules/cmdpal/Tests/Microsoft.CommandPalette.Extensions.Toolkit.UnitTests/Auth/ExtensionHostNavigationTests.cs diff --git a/src/modules/cmdpal/Microsoft.CmdPal.UI.ViewModels/AppExtensionHost.cs b/src/modules/cmdpal/Microsoft.CmdPal.UI.ViewModels/AppExtensionHost.cs index f94ba7d5eec1..1e36bf6d2f4e 100644 --- a/src/modules/cmdpal/Microsoft.CmdPal.UI.ViewModels/AppExtensionHost.cs +++ b/src/modules/cmdpal/Microsoft.CmdPal.UI.ViewModels/AppExtensionHost.cs @@ -4,8 +4,11 @@ using System.Collections.ObjectModel; using System.Diagnostics; +using CommunityToolkit.Mvvm.Messaging; using Microsoft.CmdPal.Common; using Microsoft.CmdPal.UI.ViewModels.Auth; +using Microsoft.CmdPal.UI.ViewModels.Messages; +using Microsoft.CmdPal.UI.ViewModels.Models; using Microsoft.CommandPalette.Extensions; using Microsoft.CommandPalette.Extensions.Toolkit; using Windows.Foundation; @@ -178,6 +181,41 @@ public IAsyncAction ShowStatus(IStatusMessage? message, StatusContext context) public IAsyncOperation RequestAuthorizationAsync(IAuthorizationRequest request) => AuthBrokerService.Instance.RequestAuthorizationAsync(request, this); + /// + /// . Navigate Command Palette to a page owned by + /// the extension. Reuses the existing navigation pipeline: a + /// with + /// set foregrounds the window and navigates when the command is a page. When + /// the mode is GoHome or GoBack, the stack is reset first via the matching + /// message. A null page is treated as a graceful no-op. + /// + public IAsyncAction GoToPageAsync(ICommand? page, NavigationMode navigationMode) + { + if (page is null) + { + return Task.CompletedTask.AsAsyncAction(); + } + + return Task.Run(() => + { + switch (navigationMode) + { + case NavigationMode.GoHome: + WeakReferenceMessenger.Default.Send(new(WithAnimation: false, FocusSearch: false)); + break; + case NavigationMode.GoBack: + WeakReferenceMessenger.Default.Send(new(WithAnimation: false, FocusSearch: false)); + break; + case NavigationMode.Push: + default: + break; + } + + WeakReferenceMessenger.Default.Send( + new(new ExtensionObject(page)) { ShowWindowIfPage = true }); + }).AsAsyncAction(); + } + public abstract string? GetExtensionDisplayName(); } diff --git a/src/modules/cmdpal/Tests/Microsoft.CmdPal.UI.ViewModels.UnitTests/Auth/AppExtensionHostNavigationTests.cs b/src/modules/cmdpal/Tests/Microsoft.CmdPal.UI.ViewModels.UnitTests/Auth/AppExtensionHostNavigationTests.cs new file mode 100644 index 000000000000..b1ab9e7cee80 --- /dev/null +++ b/src/modules/cmdpal/Tests/Microsoft.CmdPal.UI.ViewModels.UnitTests/Auth/AppExtensionHostNavigationTests.cs @@ -0,0 +1,136 @@ +// 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; +using System.Threading.Tasks; +using CommunityToolkit.Mvvm.Messaging; +using Microsoft.CmdPal.UI.ViewModels.Messages; +using Microsoft.CommandPalette.Extensions; +using Microsoft.CommandPalette.Extensions.Toolkit; +using Microsoft.VisualStudio.TestTools.UnitTesting; + +namespace Microsoft.CmdPal.UI.ViewModels.UnitTests.Auth; + +[TestClass] +public sealed partial class AppExtensionHostNavigationTests +{ + private sealed partial class TestAppExtensionHost : AppExtensionHost + { + public override string? GetExtensionDisplayName() => "Test Host"; + } + + // A minimal page so the recipient can confirm the exact command was wrapped. + private sealed partial class TestPage : Command + { + } + + [TestMethod] + public async Task GoToPageAsync_Push_SendsPerformCommandWithShowWindow() + { + var recipient = new object(); + PerformCommandMessage? received = null; + var homeOrBackSent = false; + + WeakReferenceMessenger.Default.Register(recipient, (_, m) => received = m); + WeakReferenceMessenger.Default.Register(recipient, (_, _) => homeOrBackSent = true); + WeakReferenceMessenger.Default.Register(recipient, (_, _) => homeOrBackSent = true); + + try + { + var host = new TestAppExtensionHost(); + var page = new TestPage(); + + await host.GoToPageAsync(page, NavigationMode.Push); + + Assert.IsNotNull(received, "PerformCommandMessage was not sent"); + Assert.IsTrue(received!.ShowWindowIfPage, "ShowWindowIfPage should be set"); + Assert.AreSame(page, received.Command.Unsafe, "the wrapped command should be the supplied page"); + Assert.IsFalse(homeOrBackSent, "Push must not reset the navigation stack"); + } + finally + { + WeakReferenceMessenger.Default.UnregisterAll(recipient); + } + } + + [TestMethod] + public async Task GoToPageAsync_GoHome_SendsGoHomeThenPerformCommand() + { + var recipient = new object(); + var goHomeSent = false; + var goBackSent = false; + var performSent = false; + + WeakReferenceMessenger.Default.Register(recipient, (_, _) => goHomeSent = true); + WeakReferenceMessenger.Default.Register(recipient, (_, _) => goBackSent = true); + WeakReferenceMessenger.Default.Register(recipient, (_, _) => performSent = true); + + try + { + var host = new TestAppExtensionHost(); + + await host.GoToPageAsync(new TestPage(), NavigationMode.GoHome); + + Assert.IsTrue(goHomeSent, "GoHomeMessage should be sent for GoHome mode"); + Assert.IsFalse(goBackSent, "GoBackMessage should not be sent for GoHome mode"); + Assert.IsTrue(performSent, "PerformCommandMessage should still be sent"); + } + finally + { + WeakReferenceMessenger.Default.UnregisterAll(recipient); + } + } + + [TestMethod] + public async Task GoToPageAsync_GoBack_SendsGoBackThenPerformCommand() + { + var recipient = new object(); + var goHomeSent = false; + var goBackSent = false; + var performSent = false; + + WeakReferenceMessenger.Default.Register(recipient, (_, _) => goHomeSent = true); + WeakReferenceMessenger.Default.Register(recipient, (_, _) => goBackSent = true); + WeakReferenceMessenger.Default.Register(recipient, (_, _) => performSent = true); + + try + { + var host = new TestAppExtensionHost(); + + await host.GoToPageAsync(new TestPage(), NavigationMode.GoBack); + + Assert.IsTrue(goBackSent, "GoBackMessage should be sent for GoBack mode"); + Assert.IsFalse(goHomeSent, "GoHomeMessage should not be sent for GoBack mode"); + Assert.IsTrue(performSent, "PerformCommandMessage should still be sent"); + } + finally + { + WeakReferenceMessenger.Default.UnregisterAll(recipient); + } + } + + [TestMethod] + public async Task GoToPageAsync_NullPage_IsGracefulNoOp() + { + var recipient = new object(); + var anySent = false; + + WeakReferenceMessenger.Default.Register(recipient, (_, _) => anySent = true); + WeakReferenceMessenger.Default.Register(recipient, (_, _) => anySent = true); + WeakReferenceMessenger.Default.Register(recipient, (_, _) => anySent = true); + + try + { + var host = new TestAppExtensionHost(); + + await host.GoToPageAsync(null, NavigationMode.Push); + + Assert.IsFalse(anySent, "a null page should not send any navigation message"); + } + finally + { + WeakReferenceMessenger.Default.UnregisterAll(recipient); + } + } +} diff --git a/src/modules/cmdpal/Tests/Microsoft.CommandPalette.Extensions.Toolkit.UnitTests/Auth/ExtensionHostNavigationTests.cs b/src/modules/cmdpal/Tests/Microsoft.CommandPalette.Extensions.Toolkit.UnitTests/Auth/ExtensionHostNavigationTests.cs new file mode 100644 index 000000000000..786cf508e635 --- /dev/null +++ b/src/modules/cmdpal/Tests/Microsoft.CommandPalette.Extensions.Toolkit.UnitTests/Auth/ExtensionHostNavigationTests.cs @@ -0,0 +1,111 @@ +// 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; +using System.Threading.Tasks; +using Microsoft.VisualStudio.TestTools.UnitTesting; +using Windows.Foundation; + +namespace Microsoft.CommandPalette.Extensions.Toolkit.UnitTests.Auth; + +[TestClass] +public class ExtensionHostNavigationTests +{ + // A host that implements only IExtensionHost (no IExtensionHost2), i.e. an + // older Command Palette that predates host-driven navigation. + private sealed class LegacyHost : IExtensionHost + { + public IAsyncAction ShowStatus(IStatusMessage message, StatusContext context) => null!; + + public IAsyncAction HideStatus(IStatusMessage message) => null!; + + public IAsyncAction LogMessage(ILogMessage message) => null!; + } + + // A host that supports navigation and records what it was asked to do. + private sealed class NavigatingHost : IExtensionHost2 + { + public ICommand? LastPage { get; private set; } + + public NavigationMode LastMode { get; private set; } + + public int GoToPageCallCount { get; private set; } + + public IAsyncAction ShowStatus(IStatusMessage message, StatusContext context) => null!; + + public IAsyncAction HideStatus(IStatusMessage message) => null!; + + public IAsyncAction LogMessage(ILogMessage message) => null!; + + public IAsyncOperation RequestAuthorizationAsync(IAuthorizationRequest request) => null!; + + public IAsyncAction GoToPageAsync(ICommand page, NavigationMode navigationMode) + { + LastPage = page; + LastMode = navigationMode; + GoToPageCallCount++; + return Task.CompletedTask.AsAsyncAction(); + } + } + + [TestMethod] + public void SupportsNavigation_FalseForLegacyHost() + { + ExtensionHost.Initialize(new LegacyHost()); + + Assert.IsFalse(ExtensionHost.SupportsNavigation); + } + + [TestMethod] + public void SupportsNavigation_TrueForHost2() + { + ExtensionHost.Initialize(new NavigatingHost()); + + Assert.IsTrue(ExtensionHost.SupportsNavigation); + } + + [TestMethod] + public async Task GoToPageAsync_LegacyHost_ThrowsNotSupported() + { + ExtensionHost.Initialize(new LegacyHost()); + + await Assert.ThrowsExceptionAsync( + () => ExtensionHost.GoToPageAsync(new Command())); + } + + [TestMethod] + public async Task GoToPageAsync_NullPage_ThrowsArgumentNull() + { + ExtensionHost.Initialize(new NavigatingHost()); + + await Assert.ThrowsExceptionAsync( + () => ExtensionHost.GoToPageAsync(null!)); + } + + [TestMethod] + public async Task GoToPageAsync_ForwardsPageAndMode() + { + var host = new NavigatingHost(); + ExtensionHost.Initialize(host); + + var page = new Command(); + + await ExtensionHost.GoToPageAsync(page, NavigationMode.GoHome); + + Assert.AreEqual(1, host.GoToPageCallCount); + Assert.AreSame(page, host.LastPage); + Assert.AreEqual(NavigationMode.GoHome, host.LastMode); + } + + [TestMethod] + public async Task GoToPageAsync_DefaultsToPush() + { + var host = new NavigatingHost(); + ExtensionHost.Initialize(host); + + await ExtensionHost.GoToPageAsync(new Command()); + + Assert.AreEqual(NavigationMode.Push, host.LastMode); + } +} diff --git a/src/modules/cmdpal/extensionsdk/Microsoft.CommandPalette.Extensions.Toolkit/ExtensionHost.cs b/src/modules/cmdpal/extensionsdk/Microsoft.CommandPalette.Extensions.Toolkit/ExtensionHost.cs index c9b58d9b7403..76cf3454a065 100644 --- a/src/modules/cmdpal/extensionsdk/Microsoft.CommandPalette.Extensions.Toolkit/ExtensionHost.cs +++ b/src/modules/cmdpal/extensionsdk/Microsoft.CommandPalette.Extensions.Toolkit/ExtensionHost.cs @@ -122,4 +122,36 @@ public static async Task RequestAuthorizationAsync( return await operation; } } + + /// + /// True when the connected host supports host-driven navigation + /// (i.e. it implements ). This is the same + /// capability that gates authorization. Older Command Palette versions + /// return false. + /// + public static bool SupportsNavigation => Host is IExtensionHost2; + + /// + /// Ask Command Palette to navigate to one of this extension's pages (for + /// example, right after a successful sign-in). The host summons its window + /// and navigates to the supplied page. + /// + /// The page to navigate to. Typically an IPage. + /// Controls navigation stack behavior. + /// is null. + /// + /// The connected host does not implement . + /// + public static async Task GoToPageAsync(ICommand page, NavigationMode navigationMode = NavigationMode.Push) + { + ArgumentNullException.ThrowIfNull(page); + + if (Host is not IExtensionHost2 host2) + { + throw new NotSupportedException( + "The Command Palette host does not support navigation. Update Command Palette to a newer version."); + } + + await host2.GoToPageAsync(page, navigationMode); + } } diff --git a/src/modules/cmdpal/extensionsdk/Microsoft.CommandPalette.Extensions/Microsoft.CommandPalette.Extensions.idl b/src/modules/cmdpal/extensionsdk/Microsoft.CommandPalette.Extensions/Microsoft.CommandPalette.Extensions.idl index 85e4c65f67e2..f0bded06b822 100644 --- a/src/modules/cmdpal/extensionsdk/Microsoft.CommandPalette.Extensions/Microsoft.CommandPalette.Extensions.idl +++ b/src/modules/cmdpal/extensionsdk/Microsoft.CommandPalette.Extensions/Microsoft.CommandPalette.Extensions.idl @@ -315,6 +315,12 @@ namespace Microsoft.CommandPalette.Extensions // re-foregrounds Command Palette, and returns the captured parameters. // The returned operation is cancelable. Windows.Foundation.IAsyncOperation RequestAuthorizationAsync(IAuthorizationRequest request); + + // Navigate Command Palette to a page provided by this extension. `page` + // is a live page object owned by the extension (typically an IPage). The + // host summons its window and navigates to that page. navigationMode + // controls stack behavior (Push / GoBack then Push / GoHome then Push). + Windows.Foundation.IAsyncAction GoToPageAsync(ICommand page, NavigationMode navigationMode); }; [contract(Microsoft.CommandPalette.Extensions.ExtensionsContract, 1)] From b8cf0ccb05428eda9923d3c047746b1c0454eb3a Mon Sep 17 00:00:00 2001 From: Michael Jolley Date: Tue, 21 Jul 2026 21:50:53 -0500 Subject: [PATCH 2/2] Fix host-driven GoToPageAsync to marshal navigation onto the UI thread Co-authored-by: Copilot App <223556219+Copilot@users.noreply.github.com> Copilot-Session: 2ee31cb3-848f-43ba-ac48-f4e4485baa33 --- .../AppExtensionHost.cs | 53 ++++++++++++++++++- .../Microsoft.CmdPal.UI/MainWindow.xaml.cs | 4 ++ 2 files changed, 55 insertions(+), 2 deletions(-) diff --git a/src/modules/cmdpal/Microsoft.CmdPal.UI.ViewModels/AppExtensionHost.cs b/src/modules/cmdpal/Microsoft.CmdPal.UI.ViewModels/AppExtensionHost.cs index 1e36bf6d2f4e..09529d5bed36 100644 --- a/src/modules/cmdpal/Microsoft.CmdPal.UI.ViewModels/AppExtensionHost.cs +++ b/src/modules/cmdpal/Microsoft.CmdPal.UI.ViewModels/AppExtensionHost.cs @@ -12,6 +12,7 @@ using Microsoft.CommandPalette.Extensions; using Microsoft.CommandPalette.Extensions.Toolkit; using Windows.Foundation; +using DispatcherQueue = Microsoft.UI.Dispatching.DispatcherQueue; namespace Microsoft.CmdPal.UI.ViewModels; @@ -31,6 +32,8 @@ public abstract partial class AppExtensionHost : IExtensionHost, IExtensionHost2 public static void SetHostHwnd(ulong hostHwnd) => _hostingHwnd = hostHwnd; + public static void SetUiDispatcherQueue(DispatcherQueue queue) => UiDispatcher.Queue = queue; + public void DebugLog(string message) { #if DEBUG @@ -196,7 +199,7 @@ public IAsyncAction GoToPageAsync(ICommand? page, NavigationMode navigationMode) return Task.CompletedTask.AsAsyncAction(); } - return Task.Run(() => + void SendNavigation() { switch (navigationMode) { @@ -213,10 +216,56 @@ public IAsyncAction GoToPageAsync(ICommand? page, NavigationMode navigationMode) WeakReferenceMessenger.Default.Send( new(new ExtensionObject(page)) { ShowWindowIfPage = true }); - }).AsAsyncAction(); + } + + // PerformCommand (the receiver of PerformCommandMessage) is UI-thread affine: + // it creates page view models and drives frame navigation. Extensions call + // this method from their own (non-UI) thread, so marshal the sends onto the + // UI thread. When there is no registered dispatcher (unit tests) or we are + // already on the UI thread, run inline so behavior and the returned action + // complete synchronously. + var dispatcher = UiDispatcher.Queue; + if (dispatcher is null || dispatcher.HasThreadAccess) + { + SendNavigation(); + return Task.CompletedTask.AsAsyncAction(); + } + + var tcs = new TaskCompletionSource(); + if (!dispatcher.TryEnqueue(() => + { + try + { + SendNavigation(); + tcs.TrySetResult(); + } + catch (Exception ex) + { + tcs.TrySetException(ex); + } + })) + { + // Enqueue failed (e.g. dispatcher shutting down); fall back to inline so + // the caller's await is never left hanging. + SendNavigation(); + tcs.TrySetResult(); + } + + return tcs.Task.AsAsyncAction(); } public abstract string? GetExtensionDisplayName(); + + // Holds the UI DispatcherQueue in a dedicated nested type so that reading it + // from GoToPageAsync does not force AppExtensionHost's static initializer to + // run. That initializer builds a GlobalLogPageContext, which captures the + // current SynchronizationContext, so forcing it on a non-UI thread (as in the + // headless unit tests) would throw. Keeping the queue here lets the inline + // navigation path run without touching that initializer. + private static class UiDispatcher + { + public static DispatcherQueue? Queue { get; set; } + } } public interface IAppHostService diff --git a/src/modules/cmdpal/Microsoft.CmdPal.UI/MainWindow.xaml.cs b/src/modules/cmdpal/Microsoft.CmdPal.UI/MainWindow.xaml.cs index a940c695cde7..38f72b399e09 100644 --- a/src/modules/cmdpal/Microsoft.CmdPal.UI/MainWindow.xaml.cs +++ b/src/modules/cmdpal/Microsoft.CmdPal.UI/MainWindow.xaml.cs @@ -140,6 +140,10 @@ public MainWindow() CommandPaletteHost.SetHostHwnd((ulong)_hwnd.Value); } + // Give host-driven navigation (GoToPageAsync) the UI dispatcher so it can + // marshal onto the UI thread when an extension calls it from a background thread. + CommandPaletteHost.SetUiDispatcherQueue(this.DispatcherQueue); + // Give the authorization broker a window-aware platform so it can open // the browser and re-foreground Command Palette when a redirect lands. ViewModels.Auth.AuthBrokerService.Instance.SetPlatform(new WindowAuthBrokerPlatform());