diff --git a/src/modules/cmdpal/Microsoft.CmdPal.Common/Services/IExtensionService.cs b/src/modules/cmdpal/Microsoft.CmdPal.Common/Services/IExtensionService.cs deleted file mode 100644 index dca49abe3a8a..000000000000 --- a/src/modules/cmdpal/Microsoft.CmdPal.Common/Services/IExtensionService.cs +++ /dev/null @@ -1,75 +0,0 @@ -// 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; - -namespace Microsoft.CmdPal.Common.Services; - -public interface IExtensionService -{ - /// - /// Gets the currently cached installed Command Palette extensions. - /// - /// True to include disabled extensions in the result. - /// A sequence of installed Command Palette extensions from the current in-memory cache. - Task> GetInstalledExtensionsAsync(bool includeDisabledExtensions = false); - - /// - /// Forces a fresh scan of installed Command Palette extensions and updates the in-memory cache. - /// - /// True to include disabled extensions in the result. - /// A sequence of installed Command Palette extensions after the cache has been rebuilt. - Task> RefreshInstalledExtensionsAsync(bool includeDisabledExtensions = false); - - // Task> GetInstalledHomeWidgetPackageFamilyNamesAsync(bool includeDisabledExtensions = false); - /// - /// Gets the installed Command Palette extensions for a specific provider type. - /// - /// The provider type to match. - /// True to include disabled extensions in the result. - /// A sequence of installed Command Palette extensions for the requested provider type. - Task> GetInstalledExtensionsAsync(Microsoft.CommandPalette.Extensions.ProviderType providerType, bool includeDisabledExtensions = false); - - /// - /// Gets a cached installed extension by its unique id. - /// - /// The unique id of the extension to look up. - /// The cached extension if found; otherwise, null. - IExtensionWrapper? GetInstalledExtension(string extensionUniqueId); - - /// - /// Signals running extensions to stop. - /// - Task SignalStopExtensionsAsync(); - - /// - /// Raised when one or more extensions are added to the installed set. - /// - event TypedEventHandler>? OnExtensionAdded; - - /// - /// Raised when one or more extensions are removed from the installed set. - /// - event TypedEventHandler>? OnExtensionRemoved; - - /// - /// Enables an installed extension by unique id. - /// - /// The unique id of the extension to enable. - void EnableExtension(string extensionUniqueId); - - /// - /// Disables an installed extension by unique id. - /// - /// The unique id of the extension to disable. - void DisableExtension(string extensionUniqueId); - - ///// - ///// Gets a boolean indicating whether the extension was disabled due to the corresponding Windows optional feature - ///// being absent from the machine or in an unknown state. - ///// - ///// The out of proc extension object - ///// True only if the extension was disabled. False otherwise. - // public Task DisableExtensionIfWindowsFeatureNotAvailable(IExtensionWrapper extension); -} diff --git a/src/modules/cmdpal/Microsoft.CmdPal.UI.ViewModels/Commands/BuiltInsCommandProvider.cs b/src/modules/cmdpal/Microsoft.CmdPal.UI.ViewModels/Commands/BuiltInsCommandProvider.cs index 7db1a52ffe29..889a031d59fa 100644 --- a/src/modules/cmdpal/Microsoft.CmdPal.UI.ViewModels/Commands/BuiltInsCommandProvider.cs +++ b/src/modules/cmdpal/Microsoft.CmdPal.UI.ViewModels/Commands/BuiltInsCommandProvider.cs @@ -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 Microsoft.CmdPal.UI.ViewModels.Commands; using Microsoft.CommandPalette.Extensions; using Microsoft.CommandPalette.Extensions.Toolkit; @@ -18,8 +19,7 @@ public sealed partial class BuiltInsCommandProvider : CommandProvider private readonly FallbackReloadItem _fallbackReloadItem = new(); private readonly FallbackLogItem _fallbackLogItem = new(); private readonly NewExtensionPage _newExtension = new(); - - private readonly IRootPageService _rootPageService; + private readonly GoHomeDockCommand _goHomeDockCommand = new(); public override ICommandItem[] TopLevelCommands() => [ @@ -41,22 +41,16 @@ public override IFallbackCommandItem[] FallbackCommands() => _fallbackLogItem, ]; - public BuiltInsCommandProvider(IRootPageService rootPageService) + public BuiltInsCommandProvider() { Id = "com.microsoft.cmdpal.builtin.core"; DisplayName = Properties.Resources.builtin_display_name; Icon = IconHelpers.FromRelativePath("Assets\\Square44x44Logo.altform-unplated_targetsize-256.png"); - - _rootPageService = rootPageService; } public override ICommandItem[]? GetDockBands() { - var rootPage = _rootPageService.GetRootPage(); - List bandItems = new(); - bandItems.Add(new WrappedDockItem(rootPage, Properties.Resources.builtin_command_palette_title)); - - return bandItems.ToArray(); + return [new WrappedDockItem(_goHomeDockCommand, Properties.Resources.builtin_command_palette_title)]; } public override void InitializeWithHost(IExtensionHost host) => BuiltinsExtensionHost.Instance.Initialize(host); diff --git a/src/modules/cmdpal/Microsoft.CmdPal.UI.ViewModels/Commands/GoHomeDockCommand.cs b/src/modules/cmdpal/Microsoft.CmdPal.UI.ViewModels/Commands/GoHomeDockCommand.cs new file mode 100644 index 000000000000..5f264ca16725 --- /dev/null +++ b/src/modules/cmdpal/Microsoft.CmdPal.UI.ViewModels/Commands/GoHomeDockCommand.cs @@ -0,0 +1,23 @@ +// 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 Microsoft.CommandPalette.Extensions; +using Microsoft.CommandPalette.Extensions.Toolkit; + +namespace Microsoft.CmdPal.UI.ViewModels.Commands; + +/// +/// A lightweight command used as a dock band item that navigates the user +/// back to the Command Palette home page when invoked. +/// +internal sealed partial class GoHomeDockCommand : InvokableCommand +{ + public GoHomeDockCommand() + { + Name = Properties.Resources.builtin_command_palette_title; + Icon = IconHelpers.FromRelativePath("Assets\\Square44x44Logo.altform-unplated_targetsize-256.png"); + } + + public override ICommandResult Invoke() => CommandResult.GoHome(); +} diff --git a/src/modules/cmdpal/Microsoft.CmdPal.UI.ViewModels/Gallery/ExtensionGalleryViewModel.cs b/src/modules/cmdpal/Microsoft.CmdPal.UI.ViewModels/Gallery/ExtensionGalleryViewModel.cs index a8428ae1421e..3611fe6aa2bb 100644 --- a/src/modules/cmdpal/Microsoft.CmdPal.UI.ViewModels/Gallery/ExtensionGalleryViewModel.cs +++ b/src/modules/cmdpal/Microsoft.CmdPal.UI.ViewModels/Gallery/ExtensionGalleryViewModel.cs @@ -13,6 +13,7 @@ using Microsoft.CmdPal.Common.WinGet.Models; using Microsoft.CmdPal.Common.WinGet.Services; using Microsoft.CmdPal.UI.ViewModels.Properties; +using Microsoft.CmdPal.UI.ViewModels.Services; using Microsoft.CommandPalette.Extensions.Toolkit; using Microsoft.Extensions.Logging; @@ -50,7 +51,7 @@ private static readonly CompositeFormat LabelGalleryExtensionsFound "Failed to check WinGet package status"); private readonly IExtensionGalleryService _galleryService; - private readonly IExtensionService _extensionService; + private readonly IEnumerable _extensionServices; private readonly ILogger _logger; private readonly ExtensionGalleryItemViewModelFactory _galleryExtensionViewModelFactory; private readonly IWinGetPackageManagerService? _winGetPackageManagerService; @@ -162,7 +163,7 @@ public string ItemCounterText public ExtensionGalleryViewModel( IExtensionGalleryService galleryService, - IExtensionService extensionService, + IEnumerable extensionServices, ILogger logger, ExtensionGalleryItemViewModelFactory galleryExtensionViewModelFactory, IWinGetPackageManagerService? winGetPackageManagerService = null, @@ -171,7 +172,7 @@ public ExtensionGalleryViewModel( TaskScheduler? uiScheduler = null) { _galleryService = galleryService; - _extensionService = extensionService; + _extensionServices = extensionServices; _logger = logger; _galleryExtensionViewModelFactory = galleryExtensionViewModelFactory; _winGetPackageManagerService = winGetPackageManagerService; @@ -347,17 +348,23 @@ private async Task CheckInstalledAsync( List snapshot; try { - var installedExtensions = refreshInstalledExtensions - ? await RunInBackgroundAsync( - () => _extensionService.RefreshInstalledExtensionsAsync(includeDisabledExtensions: true), - cancellationToken) - : await RunInBackgroundAsync( - () => _extensionService.GetInstalledExtensionsAsync(includeDisabledExtensions: true), - cancellationToken); + var allInstalledExtensions = new List(); + foreach (var service in _extensionServices) + { + var extensions = refreshInstalledExtensions + ? await RunInBackgroundAsync( + () => service.RefreshInstalledExtensionsAsync(includeDisabledExtensions: true), + cancellationToken) + : await RunInBackgroundAsync( + () => service.GetInstalledExtensionsAsync(includeDisabledExtensions: true), + cancellationToken); + allInstalledExtensions.AddRange(extensions); + } + cancellationToken.ThrowIfCancellationRequested(); var installedPfns = new HashSet( - installedExtensions + allInstalledExtensions .Select(e => e.PackageFamilyName) .Where(pfn => !string.IsNullOrEmpty(pfn)), StringComparer.OrdinalIgnoreCase); diff --git a/src/modules/cmdpal/Microsoft.CmdPal.UI.ViewModels/Messages/ProviderEnabledStateChangedMessage.cs b/src/modules/cmdpal/Microsoft.CmdPal.UI.ViewModels/Messages/ProviderEnabledStateChangedMessage.cs new file mode 100644 index 000000000000..f3da124d5595 --- /dev/null +++ b/src/modules/cmdpal/Microsoft.CmdPal.UI.ViewModels/Messages/ProviderEnabledStateChangedMessage.cs @@ -0,0 +1,7 @@ +// 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. + +namespace Microsoft.CmdPal.UI.ViewModels.Messages; + +public record ProviderEnabledStateChangedMessage(string ProviderId, bool IsEnabled); diff --git a/src/modules/cmdpal/Microsoft.CmdPal.UI.ViewModels/ProviderSettingsViewModel.cs b/src/modules/cmdpal/Microsoft.CmdPal.UI.ViewModels/ProviderSettingsViewModel.cs index 35fc866c3701..e6338b5646eb 100644 --- a/src/modules/cmdpal/Microsoft.CmdPal.UI.ViewModels/ProviderSettingsViewModel.cs +++ b/src/modules/cmdpal/Microsoft.CmdPal.UI.ViewModels/ProviderSettingsViewModel.cs @@ -120,7 +120,7 @@ public bool IsEnabled ProviderSettings = s.ProviderSettings.SetItem(_provider.ProviderId, newSettings), }); _providerSettings = newSettings; - WeakReferenceMessenger.Default.Send(new()); + WeakReferenceMessenger.Default.Send(new(_provider.ProviderId, value)); OnPropertyChanged(nameof(IsEnabled)); OnPropertyChanged(nameof(ExtensionSubtext)); OnPropertyChanged(nameof(Icon)); diff --git a/src/modules/cmdpal/Microsoft.CmdPal.UI.ViewModels/Services/BuiltInExtensionService.cs b/src/modules/cmdpal/Microsoft.CmdPal.UI.ViewModels/Services/BuiltInExtensionService.cs new file mode 100644 index 000000000000..599e42ab769b --- /dev/null +++ b/src/modules/cmdpal/Microsoft.CmdPal.UI.ViewModels/Services/BuiltInExtensionService.cs @@ -0,0 +1,80 @@ +// 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 Microsoft.CmdPal.Common.Services; +using Microsoft.CommandPalette.Extensions; +using Windows.Foundation; + +namespace Microsoft.CmdPal.UI.ViewModels.Services; + +/// +/// Extension service that manages in-process built-in command providers +/// registered in the DI container as . +/// +public sealed class BuiltInExtensionService : IExtensionService +{ + private readonly IEnumerable _commandProviders; + private readonly TaskScheduler _taskScheduler; + private readonly List _wrappers = []; + +#pragma warning disable CS0067 // Events are required by the interface but not raised by this implementation + public event TypedEventHandler>? OnProviderAdded; + + public event TypedEventHandler>? OnProviderRemoved; +#pragma warning restore CS0067 + + public BuiltInExtensionService(IEnumerable commandProviders, TaskScheduler taskScheduler) + { + _commandProviders = commandProviders; + _taskScheduler = taskScheduler; + } + + public Task> LoadProvidersAsync(CancellationToken ct) + { + if (ct.IsCancellationRequested) + { + return Task.FromResult>([]); + } + + var wrappers = new List(); + foreach (var provider in _commandProviders) + { + wrappers.Add(new CommandProviderWrapper(provider, _taskScheduler)); + } + + return Task.FromResult>(wrappers); + } + + public Task SignalStopAsync() + { + // Built-in providers are in-proc and don't need explicit stop signaling. + return Task.CompletedTask; + } + + public Task> GetInstalledExtensionsAsync(bool includeDisabledExtensions = false) + { + return Task.FromResult>(_wrappers); + } + + public Task> RefreshInstalledExtensionsAsync(bool includeDisabledExtensions = false) + { + // Built-in set is fixed at startup; refresh is a no-op. + return GetInstalledExtensionsAsync(includeDisabledExtensions); + } + + public IExtensionWrapper? GetInstalledExtension(string extensionUniqueId) + { + return _wrappers.FirstOrDefault(w => w.ExtensionUniqueId == extensionUniqueId); + } + + public void EnableExtension(string extensionUniqueId) + { + // Nothing to do here. We're built-in extensions. + } + + public void DisableExtension(string extensionUniqueId) + { + // Nothing to do here. We're built-in extensions. + } +} diff --git a/src/modules/cmdpal/Microsoft.CmdPal.UI.ViewModels/Services/ExtensionStartResult.cs b/src/modules/cmdpal/Microsoft.CmdPal.UI.ViewModels/Services/ExtensionStartResult.cs new file mode 100644 index 000000000000..f4b2944325a8 --- /dev/null +++ b/src/modules/cmdpal/Microsoft.CmdPal.UI.ViewModels/Services/ExtensionStartResult.cs @@ -0,0 +1,48 @@ +// 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 Microsoft.CmdPal.Common.Services; + +namespace Microsoft.CmdPal.UI.ViewModels.Services; + +/// +/// Represents the outcome of attempting to start a single WinRT extension. +/// +internal sealed class ExtensionStartResult +{ + public IExtensionWrapper Extension { get; } + + public CommandProviderWrapper? Wrapper { get; private init; } + + public Task? PendingStartTask { get; private init; } + + public Stopwatch? Stopwatch { get; private init; } + + [System.Diagnostics.CodeAnalysis.MemberNotNullWhen(true, nameof(Wrapper))] + public bool IsStarted => Wrapper is not null; + + [System.Diagnostics.CodeAnalysis.MemberNotNullWhen(true, nameof(PendingStartTask), nameof(Stopwatch))] + public bool IsTimedOut => PendingStartTask is not null; + + private ExtensionStartResult(IExtensionWrapper extension) + { + Extension = extension; + } + + public static ExtensionStartResult Started(IExtensionWrapper extension, CommandProviderWrapper wrapper) + { + return new ExtensionStartResult(extension) { Wrapper = wrapper }; + } + + public static ExtensionStartResult TimedOut(IExtensionWrapper extension, Task pendingStartTask, Stopwatch sw) + { + return new ExtensionStartResult(extension) { PendingStartTask = pendingStartTask, Stopwatch = sw }; + } + + public static ExtensionStartResult Failed(IExtensionWrapper extension) + { + return new ExtensionStartResult(extension); + } +} diff --git a/src/modules/cmdpal/Microsoft.CmdPal.UI.ViewModels/Services/IExtensionService.cs b/src/modules/cmdpal/Microsoft.CmdPal.UI.ViewModels/Services/IExtensionService.cs new file mode 100644 index 000000000000..173024671ea0 --- /dev/null +++ b/src/modules/cmdpal/Microsoft.CmdPal.UI.ViewModels/Services/IExtensionService.cs @@ -0,0 +1,67 @@ +// 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 Microsoft.CmdPal.Common.Services; +using Windows.Foundation; + +namespace Microsoft.CmdPal.UI.ViewModels.Services; + +public interface IExtensionService +{ + /// + /// Loads command providers managed by this service. Returns providers that + /// are immediately ready. Slow or late providers arrive via . + /// + /// Cancellation token owned by the caller to cancel in-flight loading. + /// Command provider wrappers that are started and ready for command loading. + Task> LoadProvidersAsync(CancellationToken ct); + + /// + /// Signals running providers managed by this service to stop/dispose. + /// + Task SignalStopAsync(); + + /// + /// Gets the currently cached installed extensions managed by this service. + /// + /// True to include disabled extensions in the result. + /// A sequence of installed extensions from the current in-memory cache. + Task> GetInstalledExtensionsAsync(bool includeDisabledExtensions = false); + + /// + /// Forces a fresh scan of installed extensions and updates the in-memory cache. + /// + /// True to include disabled extensions in the result. + /// A sequence of installed extensions after the cache has been rebuilt. + Task> RefreshInstalledExtensionsAsync(bool includeDisabledExtensions = false); + + /// + /// Gets a cached installed extension by its unique id. + /// + /// The unique id of the extension to look up. + /// The cached extension if found; otherwise, null. + IExtensionWrapper? GetInstalledExtension(string extensionUniqueId); + + /// + /// Enables an installed extension by unique id. + /// + /// The unique id of the extension to enable. + void EnableExtension(string extensionUniqueId); + + /// + /// Disables an installed extension by unique id. + /// + /// The unique id of the extension to disable. + void DisableExtension(string extensionUniqueId); + + /// + /// Raised when one or more providers become available (late start, new package install, etc.). + /// + event TypedEventHandler>? OnProviderAdded; + + /// + /// Raised when one or more providers are removed (package uninstall, etc.). + /// + event TypedEventHandler>? OnProviderRemoved; +} diff --git a/src/modules/cmdpal/Microsoft.CmdPal.UI.ViewModels/Models/ExtensionService.cs b/src/modules/cmdpal/Microsoft.CmdPal.UI.ViewModels/Services/WinRTExtensionService.cs similarity index 69% rename from src/modules/cmdpal/Microsoft.CmdPal.UI.ViewModels/Models/ExtensionService.cs rename to src/modules/cmdpal/Microsoft.CmdPal.UI.ViewModels/Services/WinRTExtensionService.cs index 1e791fa3ca0e..13ef75090d7b 100644 --- a/src/modules/cmdpal/Microsoft.CmdPal.UI.ViewModels/Models/ExtensionService.cs +++ b/src/modules/cmdpal/Microsoft.CmdPal.UI.ViewModels/Services/WinRTExtensionService.cs @@ -2,28 +2,34 @@ // 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.Immutable; +using System.Diagnostics; using ManagedCommon; using Microsoft.CmdPal.Common.Services; +using Microsoft.CmdPal.UI.ViewModels.Models; using Microsoft.CommandPalette.Extensions; using Windows.ApplicationModel; using Windows.ApplicationModel.AppExtensions; using Windows.Foundation; using Windows.Foundation.Collections; -namespace Microsoft.CmdPal.UI.ViewModels.Models; +namespace Microsoft.CmdPal.UI.ViewModels.Services; -public partial class ExtensionService : IExtensionService, IDisposable +/// +/// Extension service that manages out-of-process WinRT AppExtension-based command providers. +/// Handles package catalog monitoring, extension startup with timeouts, and background retries. +/// +public partial class WinRTExtensionService : IExtensionService, IDisposable { - public event TypedEventHandler>? OnExtensionAdded; - - public event TypedEventHandler>? OnExtensionRemoved; + private static readonly TimeSpan ExtensionStartTimeout = TimeSpan.FromSeconds(10); + private static readonly TimeSpan BackgroundStartTimeout = TimeSpan.FromSeconds(60); private static readonly PackageCatalog _catalog = PackageCatalog.OpenForCurrentUser(); private static readonly Lock _lock = new(); private readonly SemaphoreSlim _getInstalledExtensionsLock = new(1, 1); - private readonly SemaphoreSlim _getInstalledWidgetsLock = new(1, 1); + private readonly TaskScheduler _taskScheduler; + private readonly ICommandProviderCache _commandProviderCache; - // private readonly ILocalSettingsService _localSettingsService; private bool _disposedValue; private const string CreateInstanceProperty = "CreateInstance"; @@ -32,17 +38,119 @@ public partial class ExtensionService : IExtensionService, IDisposable private static readonly List _installedExtensions = []; private static readonly List _enabledExtensions = []; - public ExtensionService() + public event TypedEventHandler>? OnProviderAdded; + + public event TypedEventHandler>? OnProviderRemoved; + + public WinRTExtensionService(TaskScheduler taskScheduler, ICommandProviderCache commandProviderCache) { + _taskScheduler = taskScheduler; + _commandProviderCache = commandProviderCache; + _catalog.PackageInstalling += Catalog_PackageInstalling; _catalog.PackageUninstalling += Catalog_PackageUninstalling; _catalog.PackageUpdating += Catalog_PackageUpdating; + } + + public async Task> LoadProvidersAsync(CancellationToken ct) + { + var extensions = (await GetInstalledExtensionsAsync().ConfigureAwait(false)).ToImmutableList(); + + var timer = Stopwatch.StartNew(); - //// These two were an investigation into getting updates when a package - //// gets redeployed from VS. Neither get raised (nor do the above) - //// _catalog.PackageStatusChanged += Catalog_PackageStatusChanged; - //// _catalog.PackageStaging += Catalog_PackageStaging; - // _localSettingsService = settingsService; + // Start all extensions in parallel + var startResults = await Task.WhenAll(extensions.Select(ext => TryStartExtensionAsync(ext, ct))).ConfigureAwait(false); + + var startedWrappers = new List(); + foreach (var r in startResults) + { + if (r.IsStarted) + { + startedWrappers.Add(r.Wrapper); + } + else if (r.IsTimedOut) + { + _ = StartExtensionWhenReadyAsync(r.Extension, r.PendingStartTask, r.Stopwatch, ct); + } + } + + timer.Stop(); + Logger.LogInfo($"WinRTExtensionService: Started {startedWrappers.Count} extension(s) in {timer.ElapsedMilliseconds} ms"); + + return startedWrappers; + } + + public async Task SignalStopAsync() + { + var installedExtensions = await GetInstalledExtensionsAsync().ConfigureAwait(false); + foreach (var installedExtension in installedExtensions) + { + Logger.LogDebug($"Signaling dispose to {installedExtension.ExtensionUniqueId}"); + try + { + if (installedExtension.IsRunning()) + { + installedExtension.SignalDispose(); + } + } + catch (Exception ex) + { + Logger.LogError($"Failed to send dispose signal to extension {installedExtension.ExtensionUniqueId}", ex); + } + } + } + + private async Task TryStartExtensionAsync(IExtensionWrapper extension, CancellationToken ct) + { + Logger.LogDebug($"Starting {extension.PackageFullName}"); + var sw = Stopwatch.StartNew(); + var startTask = extension.StartExtensionAsync(); + try + { + await startTask.WaitAsync(ExtensionStartTimeout, ct).ConfigureAwait(false); + Logger.LogInfo($"Started extension {extension.PackageFullName} in {sw.ElapsedMilliseconds} ms"); + return ExtensionStartResult.Started(extension, new CommandProviderWrapper(extension, _taskScheduler, _commandProviderCache)); + } + catch (TimeoutException) + { + Logger.LogWarning($"Starting extension {extension.PackageFullName} timed out after {sw.ElapsedMilliseconds} ms, continuing in background"); + return ExtensionStartResult.TimedOut(extension, startTask, sw); + } + catch (OperationCanceledException) + { + Logger.LogDebug($"Starting extension {extension.PackageFullName} was cancelled after {sw.ElapsedMilliseconds} ms"); + return ExtensionStartResult.Failed(extension); + } + catch (Exception ex) + { + Logger.LogError($"Failed to start extension {extension.PackageFullName} after {sw.ElapsedMilliseconds} ms: {ex}"); + return ExtensionStartResult.Failed(extension); + } + } + + private async Task StartExtensionWhenReadyAsync( + IExtensionWrapper extension, + Task startTask, + Stopwatch sw, + CancellationToken ct) + { + try + { + await startTask.WaitAsync(BackgroundStartTimeout, ct).ConfigureAwait(false); + + var wrapper = new CommandProviderWrapper(extension, _taskScheduler, _commandProviderCache); + Logger.LogInfo($"Late-started extension {extension.PackageFullName} in {sw.ElapsedMilliseconds} ms"); + + OnProviderAdded?.Invoke(this, [wrapper]); + } + catch (OperationCanceledException) + { + // Reload happened -- discard stale results + } + catch (Exception ex) + { + Logger.LogError($"Background start of extension {extension.PackageFullName} failed after {sw.ElapsedMilliseconds} ms: {ex}"); + } } private void Catalog_PackageInstalling(PackageCatalog sender, PackageInstallingEventArgs args) @@ -73,10 +181,7 @@ private void Catalog_PackageUpdating(PackageCatalog sender, PackageUpdatingEvent { lock (_lock) { - // Get any extension providers that we previously had from this app UninstallPackageUnderLock(args.TargetPackage); - - // then add the new ones. InstallPackageUnderLock(args.TargetPackage); } } @@ -103,7 +208,25 @@ private void InstallPackageUnderLock(Package package) UpdateExtensionsListsFromWrappers(wrappers); - OnExtensionAdded?.Invoke(this, wrappers); + // Start extensions and notify via OnProviderAdded + var startedProviders = new List(); + foreach (var wrapper in wrappers) + { + try + { + await wrapper.StartExtensionAsync().ConfigureAwait(false); + startedProviders.Add(new CommandProviderWrapper(wrapper, _taskScheduler, _commandProviderCache)); + } + catch (Exception ex) + { + Logger.LogError($"Failed to start newly installed extension {wrapper.ExtensionUniqueId}: {ex}"); + } + } + + if (startedProviders.Count > 0) + { + OnProviderAdded?.Invoke(this, startedProviders); + } } finally { @@ -121,7 +244,6 @@ private void UninstallPackageUnderLock(Package package) if (extension.PackageFullName == package.Id.FullName) { CommandPaletteHost.Instance.DebugLog($"Uninstalled extension app {extension.PackageDisplayName}"); - removedExtensions.Add(extension); } } @@ -134,7 +256,24 @@ private void UninstallPackageUnderLock(Package package) _installedExtensions.RemoveAll(i => removedExtensions.Contains(i)); _enabledExtensions.RemoveAll(i => removedExtensions.Contains(i)); - OnExtensionRemoved?.Invoke(this, removedExtensions); + // Build placeholder wrappers for removal notification. + // The TopLevelCommandManager matches by Extension reference, so we need to pass + // something that carries the IExtensionWrapper identity. + var removedProviders = new List(); + foreach (var ext in removedExtensions) + { + try + { + removedProviders.Add(new CommandProviderWrapper(ext, _taskScheduler, _commandProviderCache)); + } + catch + { + // Extension may not be in a runnable state if it was uninstalled; + // we still need to signal removal + } + } + + OnProviderRemoved?.Invoke(this, removedProviders); } finally { @@ -143,52 +282,6 @@ private void UninstallPackageUnderLock(Package package) }); } - private static async Task IsValidCmdPalExtension(Package package) - { - var extensions = await AppExtensionCatalog.Open("com.microsoft.commandpalette").FindAllAsync(); - foreach (var extension in extensions) - { - if (package.Id?.FullName == extension.Package?.Id?.FullName) - { - var (cmdPalProvider, classId) = await GetCmdPalExtensionPropertiesAsync(extension); - - return new(cmdPalProvider is not null && classId.Count != 0, extension); - } - } - - return new(false, null); - } - - private static async Task<(IPropertySet? CmdPalProvider, List ClassIds)> GetCmdPalExtensionPropertiesAsync(AppExtension extension) - { - var classIds = new List(); - var properties = await extension.GetExtensionPropertiesAsync(); - - if (properties is null) - { - return (null, classIds); - } - - var cmdPalProvider = GetSubPropertySet(properties, "CmdPalProvider"); - if (cmdPalProvider is null) - { - return (null, classIds); - } - - var activation = GetSubPropertySet(cmdPalProvider, "Activation"); - if (activation is null) - { - return (cmdPalProvider, classIds); - } - - // Handle case where extension creates multiple instances. - classIds.AddRange(GetCreateInstanceList(activation)); - - return (cmdPalProvider, classIds); - } - - private static async Task> GetInstalledAppExtensionsAsync() => await AppExtensionCatalog.Open("com.microsoft.commandpalette").FindAllAsync(); - public async Task> GetInstalledExtensionsAsync(bool includeDisabledExtensions = false) { await _getInstalledExtensionsLock.WaitAsync(); @@ -215,25 +308,22 @@ public async Task> RefreshInstalledExtensionsAsyn } } - private static void UpdateExtensionsListsFromWrappers(List wrappers) + public IExtensionWrapper? GetInstalledExtension(string extensionUniqueId) { - foreach (var extensionWrapper in wrappers) - { - // var localSettingsService = Application.Current.GetService(); - var extensionUniqueId = extensionWrapper.ExtensionUniqueId; - var isExtensionDisabled = false; // await localSettingsService.ReadSettingAsync(extensionUniqueId + "-ExtensionDisabled"); + var extension = _installedExtensions.Where(extension => extension.ExtensionUniqueId.Equals(extensionUniqueId, StringComparison.Ordinal)); + return extension.FirstOrDefault(); + } - _installedExtensions.Add(extensionWrapper); - if (!isExtensionDisabled) - { - _enabledExtensions.Add(extensionWrapper); - } + public void EnableExtension(string extensionUniqueId) + { + var extension = _installedExtensions.Where(extension => extension.ExtensionUniqueId.Equals(extensionUniqueId, StringComparison.Ordinal)); + _enabledExtensions.Add(extension.First()); + } - // TelemetryFactory.Get().Log( - // "Extension_ReportInstalled", - // LogLevel.Critical, - // new ReportInstalledExtensionEvent(extensionUniqueId, isEnabled: !isExtensionDisabled)); - } + public void DisableExtension(string extensionUniqueId) + { + var extension = _enabledExtensions.Where(extension => extension.ExtensionUniqueId.Equals(extensionUniqueId, StringComparison.Ordinal)); + _enabledExtensions.Remove(extension.First()); } private static async Task> GetInstalledExtensionsAsyncUnderLock(bool includeDisabledExtensions, bool refresh) @@ -295,6 +385,8 @@ private static async Task RebuildInstalledExtensionsCacheAsync() } } + private static async Task> GetInstalledAppExtensionsAsync() => await AppExtensionCatalog.Open("com.microsoft.commandpalette").FindAllAsync(); + private static async Task> CreateWrappersForExtension(AppExtension extension) { var (cmdPalProvider, classIds) = await GetCmdPalExtensionPropertiesAsync(extension); @@ -337,7 +429,6 @@ private static ExtensionWrapper CreateExtensionWrapper(AppExtension extension, I } else { - // log warning that extension declared unsupported extension interface CommandPaletteHost.Instance.DebugLog($"Extension {extension.DisplayName} declared an unsupported interface: {supportedInterface.Key}"); } } @@ -346,77 +437,68 @@ private static ExtensionWrapper CreateExtensionWrapper(AppExtension extension, I return extensionWrapper; } - public IExtensionWrapper? GetInstalledExtension(string extensionUniqueId) - { - var extension = _installedExtensions.Where(extension => extension.ExtensionUniqueId.Equals(extensionUniqueId, StringComparison.Ordinal)); - return extension.FirstOrDefault(); - } - - public async Task SignalStopExtensionsAsync() + private static void UpdateExtensionsListsFromWrappers(List wrappers) { - var installedExtensions = await GetInstalledExtensionsAsync(); - foreach (var installedExtension in installedExtensions) + foreach (var extensionWrapper in wrappers) { - Logger.LogDebug($"Signaling dispose to {installedExtension.ExtensionUniqueId}"); - try - { - if (installedExtension.IsRunning()) - { - installedExtension.SignalDispose(); - } - } - catch (Exception ex) + var extensionUniqueId = extensionWrapper.ExtensionUniqueId; + var isExtensionDisabled = false; + + _installedExtensions.Add(extensionWrapper); + if (!isExtensionDisabled) { - Logger.LogError($"Failed to send dispose signal to extension {installedExtension.ExtensionUniqueId}", ex); + _enabledExtensions.Add(extensionWrapper); } } } - public async Task> GetInstalledExtensionsAsync(ProviderType providerType, bool includeDisabledExtensions = false) + private static async Task IsValidCmdPalExtension(Package package) { - var installedExtensions = await GetInstalledExtensionsAsync(includeDisabledExtensions); - - List filteredExtensions = []; - foreach (var installedExtension in installedExtensions) + var extensions = await AppExtensionCatalog.Open("com.microsoft.commandpalette").FindAllAsync(); + foreach (var extension in extensions) { - if (installedExtension.HasProviderType(providerType)) + if (package.Id?.FullName == extension.Package?.Id?.FullName) { - filteredExtensions.Add(installedExtension); + var (cmdPalProvider, classId) = await GetCmdPalExtensionPropertiesAsync(extension); + + return new(cmdPalProvider is not null && classId.Count != 0, extension); } } - return filteredExtensions; + return new(false, null); } - public void Dispose() + private static async Task<(IPropertySet? CmdPalProvider, List ClassIds)> GetCmdPalExtensionPropertiesAsync(AppExtension extension) { - Dispose(disposing: true); - GC.SuppressFinalize(this); - } + var classIds = new List(); + var properties = await extension.GetExtensionPropertiesAsync(); - protected virtual void Dispose(bool disposing) - { - if (!_disposedValue) + if (properties is null) { - if (disposing) - { - _getInstalledExtensionsLock.Dispose(); - _getInstalledWidgetsLock.Dispose(); - } + return (null, classIds); + } - _disposedValue = true; + var cmdPalProvider = GetSubPropertySet(properties, "CmdPalProvider"); + if (cmdPalProvider is null) + { + return (null, classIds); } + + var activation = GetSubPropertySet(cmdPalProvider, "Activation"); + if (activation is null) + { + return (cmdPalProvider, classIds); + } + + classIds.AddRange(GetCreateInstanceList(activation)); + + return (cmdPalProvider, classIds); } private static IPropertySet? GetSubPropertySet(IPropertySet propSet, string name) => propSet.TryGetValue(name, out var value) ? value as IPropertySet : null; private static object[]? GetSubPropertySetArray(IPropertySet propSet, string name) => propSet.TryGetValue(name, out var value) ? value as object[] : null; - /// - /// There are cases where the extension creates multiple COM instances. - /// - /// Activation property set object - /// List of ClassId strings associated with the activation property private static List GetCreateInstanceList(IPropertySet activationPropSet) { var propSetList = new List(); @@ -424,8 +506,6 @@ private static List GetCreateInstanceList(IPropertySet activationPropSet if (singlePropertySet is not null) { var classId = GetProperty(singlePropertySet, ClassIdProperty); - - // If the instance has a classId as a single string, then it's only supporting a single instance. if (classId is not null) { propSetList.Add(classId); @@ -457,35 +537,24 @@ private static List GetCreateInstanceList(IPropertySet activationPropSet private static string? GetProperty(IPropertySet propSet, string name) => propSet[name] as string; - public void EnableExtension(string extensionUniqueId) + public void Dispose() { - var extension = _installedExtensions.Where(extension => extension.ExtensionUniqueId.Equals(extensionUniqueId, StringComparison.Ordinal)); - _enabledExtensions.Add(extension.First()); + Dispose(disposing: true); + GC.SuppressFinalize(this); } - public void DisableExtension(string extensionUniqueId) + private void Dispose(bool disposing) { - var extension = _enabledExtensions.Where(extension => extension.ExtensionUniqueId.Equals(extensionUniqueId, StringComparison.Ordinal)); - _enabledExtensions.Remove(extension.First()); - } + if (!_disposedValue) + { + if (disposing) + { + _getInstalledExtensionsLock.Dispose(); + } - /* - ///// - //public async Task DisableExtensionIfWindowsFeatureNotAvailable(IExtensionWrapper extension) - //{ - // // Only attempt to disable feature if its available. - // if (IsWindowsOptionalFeatureAvailableForExtension(extension.ExtensionClassId)) - // { - // return false; - // } - // _log.Warning($"Disabling extension: '{extension.ExtensionDisplayName}' because its feature is absent or unknown"); - // // Remove extension from list of enabled extensions to prevent Dev Home from re-querying for this extension - // // for the rest of its process lifetime. - // DisableExtension(extension.ExtensionUniqueId); - // // Update the local settings so the next time the user launches Dev Home the extension will be disabled. - // await _localSettingsService.SaveSettingAsync(extension.ExtensionUniqueId + "-ExtensionDisabled", true); - // return true; - //} */ + _disposedValue = true; + } + } } internal record struct IsExtensionResult(bool IsExtension, AppExtension? Extension) diff --git a/src/modules/cmdpal/Microsoft.CmdPal.UI.ViewModels/TopLevelCommandManager.cs b/src/modules/cmdpal/Microsoft.CmdPal.UI.ViewModels/TopLevelCommandManager.cs index 3f4dbc6d328e..5fdc8c48f0a7 100644 --- a/src/modules/cmdpal/Microsoft.CmdPal.UI.ViewModels/TopLevelCommandManager.cs +++ b/src/modules/cmdpal/Microsoft.CmdPal.UI.ViewModels/TopLevelCommandManager.cs @@ -2,7 +2,6 @@ // 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.Immutable; using System.Collections.ObjectModel; using System.Diagnostics; using System.Diagnostics.CodeAnalysis; @@ -11,7 +10,6 @@ using CommunityToolkit.Mvvm.Messaging; using ManagedCommon; using Microsoft.CmdPal.Common.Helpers; -using Microsoft.CmdPal.Common.Services; using Microsoft.CmdPal.UI.ViewModels.Messages; using Microsoft.CmdPal.UI.ViewModels.Services; using Microsoft.CommandPalette.Extensions; @@ -22,22 +20,20 @@ namespace Microsoft.CmdPal.UI.ViewModels; public sealed partial class TopLevelCommandManager : ObservableObject, IRecipient, + IRecipient, IRecipient, IRecipient, IRecipient, IDisposable { - private static readonly TimeSpan ExtensionStartTimeout = TimeSpan.FromSeconds(10); private static readonly TimeSpan CommandLoadTimeout = TimeSpan.FromSeconds(10); - private static readonly TimeSpan BackgroundStartTimeout = TimeSpan.FromSeconds(60); private static readonly TimeSpan BackgroundCommandLoadTimeout = TimeSpan.FromSeconds(60); private readonly IServiceProvider _serviceProvider; - private readonly ICommandProviderCache _commandProviderCache; + private readonly IEnumerable _extensionServices; private readonly TaskScheduler _taskScheduler; - private readonly List _builtInCommands = []; - private readonly List _extensionCommandProviders = []; + private readonly List _commandProviders = []; private readonly Lock _commandProvidersLock = new(); // watch out: if you add code that locks CommandProviders, be sure to always @@ -50,18 +46,25 @@ public sealed partial class TopLevelCommandManager : ObservableObject, private HashSet<(string ProviderId, string CommandId)> _pinnedCommandSet = []; - public TopLevelCommandManager(IServiceProvider serviceProvider, ICommandProviderCache commandProviderCache) + public TopLevelCommandManager(IServiceProvider serviceProvider, IEnumerable extensionServices) { _serviceProvider = serviceProvider; - _commandProviderCache = commandProviderCache; + _extensionServices = extensionServices; _currentExtensionLoadCancellationToken = _extensionLoadCts.Token; _taskScheduler = _serviceProvider.GetService()!; WeakReferenceMessenger.Default.Register(this); + WeakReferenceMessenger.Default.Register(this); WeakReferenceMessenger.Default.Register(this); WeakReferenceMessenger.Default.Register(this); WeakReferenceMessenger.Default.Register(this); _reloadCommandsGate = new(ReloadAllCommandsAsyncCore); RebuildPinnedCache(); + + foreach (var service in _extensionServices) + { + service.OnProviderAdded += ExtensionService_OnProviderAdded; + service.OnProviderRemoved += ExtensionService_OnProviderRemoved; + } } public ObservableCollection PinnedCommands { get; } = []; @@ -84,7 +87,7 @@ public IEnumerable CommandProviders { lock (_commandProvidersLock) { - return _builtInCommands.Concat(_extensionCommandProviders).ToList(); + return _commandProviders.ToList(); } } } @@ -101,58 +104,6 @@ internal void RebuildPinnedCache() ListHelpers.InPlaceUpdateList(PinnedCommands, settings.PinnedCommands); } - public async Task LoadBuiltinsAsync() - { - var s = new Stopwatch(); - s.Start(); - - lock (_commandProvidersLock) - { - _builtInCommands.Clear(); - } - - // Load built-In commands first. These are all in-proc, and - // owned by our ServiceProvider. - var builtInCommands = _serviceProvider.GetServices(); - foreach (var provider in builtInCommands) - { - CommandProviderWrapper wrapper = new(provider, _taskScheduler); - lock (_commandProvidersLock) - { - _builtInCommands.Add(wrapper); - } - - var objects = await LoadTopLevelCommandsFromProvider(wrapper); - lock (TopLevelCommands) - { - if (objects.Commands is IEnumerable commands) - { - foreach (var c in commands) - { - TopLevelCommands.Add(c); - } - } - } - - lock (_dockBandsLock) - { - if (objects.DockBands is IEnumerable bands) - { - foreach (var c in bands) - { - DockBands.Add(c); - } - } - } - } - - s.Stop(); - - Logger.LogDebug($"Loading built-ins took {s.ElapsedMilliseconds}ms"); - - return true; - } - // May be called from a background thread private async Task LoadTopLevelCommandsFromProvider(CommandProviderWrapper commandProvider) { @@ -288,125 +239,197 @@ public async Task ReloadAllCommandsAsync() { // gate ensures that the reload is serialized and if multiple calls // request a reload, only the first and the last one will be executed. - // this should be superseded with a cancellable version. await _reloadCommandsGate.ExecuteAsync(CancellationToken.None); } - private async Task ReloadAllCommandsAsyncCore(CancellationToken cancellationToken) + /// + /// Loads only built-in (in-process) command providers. This is fast and + /// suitable for the initial pre-load phase so the UI appears immediately. + /// + public async Task LoadBuiltInProvidersAsync() { - IsLoading = true; - - // Invalidate any background continuations from the previous load cycle - await _extensionLoadCts.CancelAsync().ConfigureAwait(false); - _extensionLoadCts.Dispose(); - _extensionLoadCts = new(); - _currentExtensionLoadCancellationToken = _extensionLoadCts.Token; - - var extensionService = _serviceProvider.GetService()!; - await extensionService.SignalStopExtensionsAsync().ConfigureAwait(false); - - lock (TopLevelCommands) + var ct = _currentExtensionLoadCancellationToken; + foreach (var service in _extensionServices.OfType()) { - TopLevelCommands.Clear(); + var wrappers = await service.LoadProvidersAsync(ct).ConfigureAwait(false); + await RegisterAndLoadCommandsAsync(wrappers, ct).ConfigureAwait(false); } + } - lock (_dockBandsLock) + /// + /// Loads external (out-of-process WinRT) command providers. Call this after + /// the root page is displayed so the UI is not blocked by extension startup. + /// Commands appear progressively via . + /// + [RelayCommand] + public async Task LoadExternalProvidersAsync() + { + IsLoading = true; + try { - DockBands.Clear(); + var ct = _currentExtensionLoadCancellationToken; + foreach (var service in _extensionServices.Where(s => s is not BuiltInExtensionService)) + { + var wrappers = await service.LoadProvidersAsync(ct).ConfigureAwait(false); + await RegisterAndLoadCommandsAsync(wrappers, ct).ConfigureAwait(false); + } + } + finally + { + IsLoading = false; } - - await LoadBuiltinsAsync().ConfigureAwait(false); - _ = Task.Run(LoadExtensionsAsync, cancellationToken); } - // Load commands from our extensions. Called on a background thread. - // Currently, this - // * queries the package catalog, - // * starts all the extensions, - // * then fetches the top-level commands from them. - // TODO In the future, we'll probably abstract some of this away, to have - // separate extension tracking vs stub loading. - [RelayCommand] - public async Task LoadExtensionsAsync() + private async Task ReloadAllCommandsAsyncCore(CancellationToken cancellationToken) { - var extensionService = _serviceProvider.GetService()!; - - extensionService.OnExtensionAdded -= ExtensionService_OnExtensionAdded; - extensionService.OnExtensionRemoved -= ExtensionService_OnExtensionRemoved; - - var ct = _currentExtensionLoadCancellationToken; + IsLoading = true; - var extensions = (await extensionService.GetInstalledExtensionsAsync().ConfigureAwait(false)).ToImmutableList(); - lock (_commandProvidersLock) + try { - _extensionCommandProviders.Clear(); - } + // Invalidate any background continuations from the previous load cycle + await _extensionLoadCts.CancelAsync().ConfigureAwait(false); + _extensionLoadCts.Dispose(); + _extensionLoadCts = new(); + _currentExtensionLoadCancellationToken = _extensionLoadCts.Token; - await StartExtensionsAndGetCommands(extensions, ct).ConfigureAwait(false); - - extensionService.OnExtensionAdded += ExtensionService_OnExtensionAdded; - extensionService.OnExtensionRemoved += ExtensionService_OnExtensionRemoved; + // Signal all services to stop their running providers + foreach (var service in _extensionServices) + { + await service.SignalStopAsync().ConfigureAwait(false); + } - IsLoading = false; + lock (TopLevelCommands) + { + TopLevelCommands.Clear(); + } - // Send on the current thread; receivers should marshal to UI if needed - WeakReferenceMessenger.Default.Send(); + lock (_dockBandsLock) + { + DockBands.Clear(); + } - return true; - } + lock (_commandProvidersLock) + { + _commandProviders.Clear(); + } - private void ExtensionService_OnExtensionAdded(IExtensionService sender, IEnumerable extensions) - { - var ct = _currentExtensionLoadCancellationToken; + var ct = _currentExtensionLoadCancellationToken; - // When we get an extension install event, hop off to a BG thread - _ = Task.Run( - async () => + // Load providers from each service sequentially (order matters: built-ins first) + foreach (var service in _extensionServices) { - // for each newly installed extension, start it and get commands - // from it. One single package might have more than one - // IExtensionWrapper in it. - await StartExtensionsAndGetCommands(extensions, ct).ConfigureAwait(false); - }, - ct); + var wrappers = await service.LoadProvidersAsync(ct).ConfigureAwait(false); + await RegisterAndLoadCommandsAsync(wrappers, ct).ConfigureAwait(false); + } + } + finally + { + IsLoading = false; + WeakReferenceMessenger.Default.Send(); + } } - private async Task StartExtensionsAndGetCommands(IEnumerable extensions, CancellationToken ct) + private async Task UpdateProviderEnabledStateAsyncCore(string providerId, bool isEnabled) { - var timer = Stopwatch.StartNew(); - - // Start all extensions in parallel - var startResults = await Task.WhenAll(extensions.Select(TryStartExtensionAsync)).ConfigureAwait(false); + IsLoading = true; - var startedWrappers = new List(); - foreach (var r in startResults) + try { - if (r.IsStarted) + // If disabled, we'll remove that providers commands from top level commands, dock bands, and pinned commands. + if (!isEnabled) { - startedWrappers.Add(r.Wrapper); + lock (TopLevelCommands) + { + var commandsToRemove = TopLevelCommands.Where(c => c.CommandProviderId == providerId).ToList(); + foreach (var command in commandsToRemove) + { + TopLevelCommands.Remove(command); + } + } + + lock (_dockBandsLock) + { + var dockBandsToRemove = DockBands.Where(b => b.CommandProviderId == providerId).ToList(); + foreach (var band in dockBandsToRemove) + { + DockBands.Remove(band); + } + } + + lock (PinnedCommands) + { + var pinnedToRemove = PinnedCommands.Where(p => p.ProviderId == providerId).ToList(); + foreach (var command in pinnedToRemove) + { + PinnedCommands.Remove(command); + } + } } - else if (r.IsTimedOut) + else { - _ = StartExtensionWhenReadyAsync(r.Extension, r.PendingStartTask, r.Stopwatch, ct); - } - } + CommandProviderWrapper? provider; + lock (_commandProvidersLock) + { + provider = _commandProviders.FirstOrDefault(p => p.ProviderId == providerId); + } - // Register started extensions and load their commands - var loadSummary = await RegisterAndLoadCommandsAsync(startedWrappers, ct).ConfigureAwait(false); + if (provider != null) + { + await provider.LoadTopLevelCommands(_serviceProvider); - timer.Stop(); - Logger.LogInfo($"Loaded {loadSummary.CommandCount} command(s) and {loadSummary.DockBandCount} band(s) from {startedWrappers.Count} extension(s) in {timer.ElapsedMilliseconds} ms"); + lock (TopLevelCommands) + { + foreach (var command in provider.TopLevelItems) + { + if (!TopLevelCommands.Any(a => a.Id == command.Id)) + { + TopLevelCommands.Add(command); + } + } + + foreach (var item in provider.FallbackItems) + { + if (!TopLevelCommands.Any(a => a.Id == item.Id) && item.IsEnabled) + { + TopLevelCommands.Add(item); + } + } + } + + lock (_dockBandsLock) + { + foreach (var band in provider.DockBandItems) + { + if (!DockBands.Any(a => a.Id == band.Id)) + { + DockBands.Add(band); + } + } + } + } + else + { + Logger.LogWarning($"Could not find provider with id '{providerId}' to update enabled state."); + return; + } + } + } + finally + { + IsLoading = false; + } } - private async Task RegisterAndLoadCommandsAsync(ICollection wrappers, CancellationToken ct) + private async Task RegisterAndLoadCommandsAsync(IEnumerable wrappers, CancellationToken ct) { + var wrapperList = wrappers.ToList(); lock (_commandProvidersLock) { - _extensionCommandProviders.AddRange(wrappers); + _commandProviders.AddRange(wrapperList); } // Load the commands from the providers in parallel - var loadResults = await Task.WhenAll(wrappers.Select(w => TryLoadCommandsAsync(w, ct))).ConfigureAwait(false); + var loadResults = await Task.WhenAll(wrapperList.Select(w => TryLoadCommandsAsync(w, ct))).ConfigureAwait(false); var totalCommands = 0; var totalDockBands = 0; @@ -463,7 +486,6 @@ private async Task RegisterAndLoadCommandsAsync(ICollect // Fire background continuations for timed-out loads outside the lock foreach (var r in timedOut) { - // It's weird to repeat the condition here, but it allows the compiler to track nullability of other properties if (r.IsTimedOut) { _ = AppendCommandsWhenReadyAsync(r.Wrapper, r.PendingLoadTask, r.Stopwatch, ct); @@ -473,60 +495,6 @@ private async Task RegisterAndLoadCommandsAsync(ICollect return new RegisterAndLoadSummary(totalCommands, totalDockBands); } - private async Task TryStartExtensionAsync(IExtensionWrapper extension) - { - Logger.LogDebug($"Starting {extension.PackageFullName}"); - var sw = Stopwatch.StartNew(); - var ct = _currentExtensionLoadCancellationToken; - var startTask = extension.StartExtensionAsync(); - try - { - await startTask.WaitAsync(ExtensionStartTimeout, ct).ConfigureAwait(false); - Logger.LogInfo($"Started extension {extension.PackageFullName} in {sw.ElapsedMilliseconds} ms"); - return ExtensionStartResult.Started(extension, new CommandProviderWrapper(extension, _taskScheduler, _commandProviderCache)); - } - catch (TimeoutException) - { - Logger.LogWarning($"Starting extension {extension.PackageFullName} timed out after {sw.ElapsedMilliseconds} ms, continuing in background"); - return ExtensionStartResult.TimedOut(extension, startTask, sw); - } - catch (OperationCanceledException) - { - Logger.LogDebug($"Starting extension {extension.PackageFullName} was cancelled after {sw.ElapsedMilliseconds} ms"); - return ExtensionStartResult.Failed(extension); - } - catch (Exception ex) - { - Logger.LogError($"Failed to start extension {extension.PackageFullName} after {sw.ElapsedMilliseconds} ms: {ex}"); - return ExtensionStartResult.Failed(extension); - } - } - - private async Task StartExtensionWhenReadyAsync( - IExtensionWrapper extension, - Task startTask, - Stopwatch sw, - CancellationToken ct) - { - try - { - await startTask.WaitAsync(BackgroundStartTimeout, ct).ConfigureAwait(false); - - var wrapper = new CommandProviderWrapper(extension, _taskScheduler, _commandProviderCache); - Logger.LogInfo($"Late-started extension {extension.PackageFullName} in {sw.ElapsedMilliseconds} ms, loading commands and bands"); - - await RegisterAndLoadCommandsAsync([wrapper], ct).ConfigureAwait(false); - } - catch (OperationCanceledException) - { - // Reload happened -- discard stale results - } - catch (Exception ex) - { - Logger.LogError($"Background start/load of extension {extension.PackageFullName} failed after {sw.ElapsedMilliseconds} ms: {ex}"); - } - } - private async Task TryLoadCommandsAsync(CommandProviderWrapper wrapper, CancellationToken ct) { var sw = Stopwatch.StartNew(); @@ -536,22 +504,22 @@ private async Task TryLoadCommandsAsync(CommandProviderWrappe var result = await loadTask.WaitAsync(CommandLoadTimeout, ct).ConfigureAwait(false); var commandCount = result.Commands?.Count ?? 0; var dockBandCount = result.DockBands?.Count ?? 0; - Logger.LogInfo($"Loaded {commandCount} command(s) and {dockBandCount} band(s) from {wrapper.ExtensionHost?.Extension?.PackageFullName} in {sw.ElapsedMilliseconds} ms"); + Logger.LogInfo($"Loaded {commandCount} command(s) and {dockBandCount} band(s) from {wrapper.ExtensionHost?.Extension?.PackageFullName ?? wrapper.DisplayName} in {sw.ElapsedMilliseconds} ms"); return CommandLoadResult.Loaded(wrapper, result); } catch (TimeoutException) { - Logger.LogWarning($"Loading commands and bands from {wrapper.ExtensionHost?.Extension?.PackageFullName} timed out after {sw.ElapsedMilliseconds} ms, continuing in background"); + Logger.LogWarning($"Loading commands and bands from {wrapper.ExtensionHost?.Extension?.PackageFullName ?? wrapper.DisplayName} timed out after {sw.ElapsedMilliseconds} ms, continuing in background"); return CommandLoadResult.TimedOut(wrapper, loadTask, sw); } catch (OperationCanceledException) { - Logger.LogDebug($"Loading commands and bands from {wrapper.ExtensionHost?.Extension?.PackageFullName} was cancelled after {sw.ElapsedMilliseconds} ms"); + Logger.LogDebug($"Loading commands and bands from {wrapper.ExtensionHost?.Extension?.PackageFullName ?? wrapper.DisplayName} was cancelled after {sw.ElapsedMilliseconds} ms"); return CommandLoadResult.Failed(wrapper); } catch (Exception ex) { - Logger.LogError($"Failed to load commands and bands for extension {wrapper.ExtensionHost?.Extension?.PackageFullName} after {sw.ElapsedMilliseconds} ms: {ex}"); + Logger.LogError($"Failed to load commands and bands for {wrapper.ExtensionHost?.Extension?.PackageFullName ?? wrapper.DisplayName} after {sw.ElapsedMilliseconds} ms: {ex}"); return CommandLoadResult.Failed(wrapper); } } @@ -590,7 +558,7 @@ private async Task AppendCommandsWhenReadyAsync( } } - Logger.LogInfo($"Late-loaded {commands?.Count ?? 0} command(s) and {dockBands?.Count ?? 0} band(s) from {wrapper.ExtensionHost?.Extension?.PackageFullName} in {sw.ElapsedMilliseconds} ms"); + Logger.LogInfo($"Late-loaded {commands?.Count ?? 0} command(s) and {dockBands?.Count ?? 0} band(s) from {wrapper.ExtensionHost?.Extension?.PackageFullName ?? wrapper.DisplayName} in {sw.ElapsedMilliseconds} ms"); } catch (OperationCanceledException) { @@ -598,52 +566,63 @@ private async Task AppendCommandsWhenReadyAsync( } catch (Exception ex) { - Logger.LogError($"Background loading of commands and bands from {wrapper.ExtensionHost?.Extension?.PackageFullName} failed after {sw.ElapsedMilliseconds} ms: {ex}"); + Logger.LogError($"Background loading of commands and bands from {wrapper.ExtensionHost?.Extension?.PackageFullName ?? wrapper.DisplayName} failed after {sw.ElapsedMilliseconds} ms: {ex}"); } } - private void ExtensionService_OnExtensionRemoved(IExtensionService sender, IEnumerable extensions) + private void ExtensionService_OnProviderAdded(IExtensionService sender, IEnumerable wrappers) { - // When we get an extension uninstall event, hop off to a BG thread + var ct = _currentExtensionLoadCancellationToken; + _ = Task.Run( async () => { - // Then find all the top-level commands that belonged to that extension + await RegisterAndLoadCommandsAsync(wrappers, ct).ConfigureAwait(false); + }, + ct); + } + + private void ExtensionService_OnProviderRemoved(IExtensionService sender, IEnumerable removedWrappers) + { + // When we get a provider removal event, hop off to a BG thread + _ = Task.Run( + async () => + { + var removedProviderIds = new HashSet(removedWrappers.Select(w => w.ProviderId)); + List commandsToRemove = []; List bandsToRemove = []; - foreach (var extension in extensions) + + lock (TopLevelCommands) { - lock (TopLevelCommands) + foreach (var command in TopLevelCommands) { - foreach (var command in TopLevelCommands) + if (removedProviderIds.Contains(command.CommandProviderId)) { - var host = command.ExtensionHost; - if (host?.Extension == extension) - { - commandsToRemove.Add(command); - } + commandsToRemove.Add(command); } } + } - lock (_dockBandsLock) + lock (_dockBandsLock) + { + foreach (var band in DockBands) { - foreach (var band in DockBands) + if (removedProviderIds.Contains(band.CommandProviderId)) { - var host = band.ExtensionHost; - if (host?.Extension == extension) - { - bandsToRemove.Add(band); - } + bandsToRemove.Add(band); } } } - // Then back on the UI thread (remember, TopLevelCommands is - // Observable, so you can't touch it on the BG thread)... + lock (_commandProvidersLock) + { + _commandProviders.RemoveAll(w => removedProviderIds.Contains(w.ProviderId)); + } + await Task.Factory.StartNew( () => { - // ... remove all the deleted commands. lock (TopLevelCommands) { if (commandsToRemove.Count != 0) @@ -715,6 +694,9 @@ public List GetDockBandsSnapshot() public void Receive(ReloadCommandsMessage message) => _ = ReloadAllCommandsAsync(); + public void Receive(ProviderEnabledStateChangedMessage message) => + _ = UpdateProviderEnabledStateAsyncCore(message.ProviderId, message.IsEnabled); + public void Receive(PinCommandItemMessage message) { var wrapper = LookupProvider(message.ProviderId); @@ -752,8 +734,7 @@ public void Receive(PinToDockMessage message) { lock (_commandProvidersLock) { - return _builtInCommands.FirstOrDefault(w => w.ProviderId == providerId) - ?? _extensionCommandProviders.FirstOrDefault(w => w.ProviderId == providerId); + return _commandProviders.FirstOrDefault(w => w.ProviderId == providerId); } } @@ -761,8 +742,7 @@ internal bool IsProviderActive(string id) { lock (_commandProvidersLock) { - return _builtInCommands.Any(wrapper => wrapper.Id == id && wrapper.IsActive) - || _extensionCommandProviders.Any(wrapper => wrapper.Id == id && wrapper.IsActive); + return _commandProviders.Any(wrapper => wrapper.Id == id && wrapper.IsActive); } } @@ -815,49 +795,18 @@ internal void PinDockBand(TopLevelViewModel bandVm) public void Dispose() { + foreach (var service in _extensionServices) + { + service.OnProviderAdded -= ExtensionService_OnProviderAdded; + service.OnProviderRemoved -= ExtensionService_OnProviderRemoved; + } + _extensionLoadCts.Cancel(); _extensionLoadCts.Dispose(); _reloadCommandsGate.Dispose(); GC.SuppressFinalize(this); } - private sealed class ExtensionStartResult - { - public IExtensionWrapper Extension { get; } - - public CommandProviderWrapper? Wrapper { get; private init; } - - public Task? PendingStartTask { get; private init; } - - public Stopwatch? Stopwatch { get; private init; } - - [MemberNotNullWhen(true, nameof(Wrapper))] - public bool IsStarted => Wrapper is not null; - - [MemberNotNullWhen(true, nameof(PendingStartTask), nameof(Stopwatch))] - public bool IsTimedOut => PendingStartTask is not null; - - private ExtensionStartResult(IExtensionWrapper extension) - { - Extension = extension; - } - - public static ExtensionStartResult Started(IExtensionWrapper extension, CommandProviderWrapper wrapper) - { - return new ExtensionStartResult(extension) { Wrapper = wrapper }; - } - - public static ExtensionStartResult TimedOut(IExtensionWrapper extension, Task pendingStartTask, Stopwatch sw) - { - return new ExtensionStartResult(extension) { PendingStartTask = pendingStartTask, Stopwatch = sw }; - } - - public static ExtensionStartResult Failed(IExtensionWrapper extension) - { - return new ExtensionStartResult(extension); - } - } - private sealed class CommandLoadResult { public TopLevelObjectSets? TopLevelObjectSets { get; private init; } diff --git a/src/modules/cmdpal/Microsoft.CmdPal.UI/App.xaml.cs b/src/modules/cmdpal/Microsoft.CmdPal.UI/App.xaml.cs index 9808478e5f1c..6616de62b9ac 100644 --- a/src/modules/cmdpal/Microsoft.CmdPal.UI/App.xaml.cs +++ b/src/modules/cmdpal/Microsoft.CmdPal.UI/App.xaml.cs @@ -264,7 +264,10 @@ private static void AddCoreServices( // Core services services.AddSingleton(appInfoService); - services.AddSingleton(); + // Load IExtensionServices here + services.AddSingleton(); + services.AddSingleton(); + services.AddSingleton(); services.AddSingleton(); diff --git a/src/modules/cmdpal/Microsoft.CmdPal.UI/MainWindow.xaml.cs b/src/modules/cmdpal/Microsoft.CmdPal.UI/MainWindow.xaml.cs index d82983fad273..9d57b96eff27 100644 --- a/src/modules/cmdpal/Microsoft.CmdPal.UI/MainWindow.xaml.cs +++ b/src/modules/cmdpal/Microsoft.CmdPal.UI/MainWindow.xaml.cs @@ -922,8 +922,11 @@ internal void MainWindow_Closed(object sender, WindowEventArgs args) } } - var extensionService = serviceProvider.GetService()!; - extensionService.SignalStopExtensionsAsync(); + var extensionServices = serviceProvider.GetServices(); + foreach (var extensionService in extensionServices) + { + extensionService.SignalStopAsync(); + } App.Current.Services.GetService()!.Destroy(); diff --git a/src/modules/cmdpal/Microsoft.CmdPal.UI/PowerToysRootPageService.cs b/src/modules/cmdpal/Microsoft.CmdPal.UI/PowerToysRootPageService.cs index f24d9273f684..012437b6ad46 100644 --- a/src/modules/cmdpal/Microsoft.CmdPal.UI/PowerToysRootPageService.cs +++ b/src/modules/cmdpal/Microsoft.CmdPal.UI/PowerToysRootPageService.cs @@ -36,7 +36,7 @@ public PowerToysRootPageService(TopLevelCommandManager topLevelCommandManager, A public async Task PreLoadAsync() { - await _tlcManager.LoadBuiltinsAsync(); + await _tlcManager.LoadBuiltInProvidersAsync(); } public Microsoft.CommandPalette.Extensions.IPage GetRootPage() @@ -46,11 +46,11 @@ public Microsoft.CommandPalette.Extensions.IPage GetRootPage() public async Task PostLoadRootPageAsync() { - // After loading built-ins, and starting navigation, kick off a thread to load extensions. - _tlcManager.LoadExtensionsCommand.Execute(null); + // After loading built-ins, and starting navigation, kick off a thread to load external extensions. + _tlcManager.LoadExternalProvidersCommand.Execute(null); - await _tlcManager.LoadExtensionsCommand.ExecutionTask!; - if (_tlcManager.LoadExtensionsCommand.ExecutionTask.Status != TaskStatus.RanToCompletion) + await _tlcManager.LoadExternalProvidersCommand.ExecutionTask!; + if (_tlcManager.LoadExternalProvidersCommand.ExecutionTask.Status != TaskStatus.RanToCompletion) { // TODO: Handle failure case } diff --git a/src/modules/cmdpal/Tests/Microsoft.CmdPal.UI.ViewModels.UnitTests/ExtensionGalleryViewModelTests.cs b/src/modules/cmdpal/Tests/Microsoft.CmdPal.UI.ViewModels.UnitTests/ExtensionGalleryViewModelTests.cs index 8a82ea040e1a..03829108429c 100644 --- a/src/modules/cmdpal/Tests/Microsoft.CmdPal.UI.ViewModels.UnitTests/ExtensionGalleryViewModelTests.cs +++ b/src/modules/cmdpal/Tests/Microsoft.CmdPal.UI.ViewModels.UnitTests/ExtensionGalleryViewModelTests.cs @@ -13,6 +13,7 @@ using Microsoft.CmdPal.Common.WinGet.Models; using Microsoft.CmdPal.Common.WinGet.Services; using Microsoft.CmdPal.UI.ViewModels.Gallery; +using Microsoft.CmdPal.UI.ViewModels.Services; using Microsoft.Extensions.Logging.Abstractions; using Microsoft.VisualStudio.TestTools.UnitTesting; using Moq; @@ -61,7 +62,7 @@ public async Task LoadAsync_DoesNotBlockOnSlowSynchronousInstalledStatusKickoff( using var viewModel = new ExtensionGalleryViewModel( galleryService.Object, - extensionService.Object, + new[] { extensionService.Object }, NullLogger.Instance, CreateGalleryExtensionViewModelFactory()); @@ -118,7 +119,7 @@ public async Task RefreshCommand_RefreshesInstalledAndWinGetStateInBackground() using var viewModel = new ExtensionGalleryViewModel( galleryService.Object, - extensionService.Object, + new[] { extensionService.Object }, NullLogger.Instance, CreateGalleryExtensionViewModelFactory(winGetService.Object, winGetStatusService.Object), winGetService.Object, @@ -202,7 +203,7 @@ public async Task RefreshCommand_DoesNotBlockOnSlowSynchronousStatusRefreshKicko using var viewModel = new ExtensionGalleryViewModel( galleryService.Object, - extensionService.Object, + new[] { extensionService.Object }, NullLogger.Instance, CreateGalleryExtensionViewModelFactory(winGetService.Object, winGetStatusService.Object), winGetService.Object, @@ -250,7 +251,7 @@ public async Task LoadAsync_DoesNotShowFallbackCacheWarning_ForNormalCacheHits() using var viewModel = new ExtensionGalleryViewModel( galleryService.Object, - extensionService.Object, + new[] { extensionService.Object }, NullLogger.Instance, CreateGalleryExtensionViewModelFactory()); @@ -292,7 +293,7 @@ public async Task LoadAsync_ShowsFallbackCacheWarning_WhenServiceFallsBackToCach using var viewModel = new ExtensionGalleryViewModel( galleryService.Object, - extensionService.Object, + new[] { extensionService.Object }, NullLogger.Instance, CreateGalleryExtensionViewModelFactory()); @@ -324,7 +325,7 @@ public async Task LoadAsync_ShowsErrorSurface_WhenGalleryIsRateLimited() using var viewModel = new ExtensionGalleryViewModel( galleryService.Object, - extensionService.Object, + new[] { extensionService.Object }, NullLogger.Instance, CreateGalleryExtensionViewModelFactory()); @@ -359,7 +360,7 @@ public async Task LoadAsync_ShowsGenericErrorSurface_WhenGalleryLoadFailsWithout using var viewModel = new ExtensionGalleryViewModel( galleryService.Object, - extensionService.Object, + new[] { extensionService.Object }, NullLogger.Instance, CreateGalleryExtensionViewModelFactory()); @@ -389,7 +390,7 @@ public async Task SortByNameCommand_SortsEntriesByTitle() using var viewModel = new ExtensionGalleryViewModel( galleryService.Object, - extensionService.Object, + new[] { extensionService.Object }, NullLogger.Instance, CreateGalleryExtensionViewModelFactory()); @@ -421,7 +422,7 @@ public async Task SortByAuthorCommand_SortsEntriesByAuthor() using var viewModel = new ExtensionGalleryViewModel( galleryService.Object, - extensionService.Object, + new[] { extensionService.Object }, NullLogger.Instance, CreateGalleryExtensionViewModelFactory()); @@ -485,7 +486,7 @@ public async Task SortByInstallationStatusCommand_SortsUpdatesBeforeInstalledBef using var viewModel = new ExtensionGalleryViewModel( galleryService.Object, - extensionService.Object, + new[] { extensionService.Object }, NullLogger.Instance, CreateGalleryExtensionViewModelFactory(winGetPackageStatusService: winGetStatusService.Object), winGetPackageManagerService: null,