Compare commits

...

8 Commits

Author SHA1 Message Date
Mike Griese
e83d6cb8f6 more ci fixes? 2026-05-13 06:38:59 -05:00
Mike Griese
47c66cea90 fix CI 2026-05-13 05:46:57 -05:00
Mike Griese
758f23ed7e PRE-MERGE List Parameters (not filed yet) 2026-05-12 16:53:52 -05:00
Mike Griese
dc3ce6a081 PRE-MERGE CmdPal: Add support for pages with parameters (redux) PR #47826 2026-05-12 16:51:13 -05:00
Mike Griese
1b63c3e554 PRE-MERGE CmdPal: Update the shell provider to be run PR #47642 2026-05-12 16:49:31 -05:00
Mike Griese
b76671adab PRE-MERGE PR #46915 CmdPal Dock: Multi-monitor support 2026-05-12 16:47:24 -05:00
Mike Griese
a2768b066f PRE-MERGE PR #46636 CmdPal: Extension Gallery 2026-05-12 16:46:03 -05:00
Mike Griese
23aa09ee2f cmdpal: bump to 0.11 2026-05-12 16:36:30 -05:00
224 changed files with 24447 additions and 2095 deletions

View File

@@ -310,6 +310,7 @@ onefuzz
# NameInCode
leilzh
mengyuanchen
contoso
# DllName
testhost
@@ -346,6 +347,13 @@ WINDOWPOS
WINEVENTPROC
WORKERW
FULLSCREENAPP
ACLO
CACLI
DOENVSUBST
FILESYSONLY
URLIS
WAITTIMEOUT
DEFAULTTONEAREST
# COM/WinRT interface prefixes and type fragments
BAlt
@@ -380,6 +388,12 @@ YYY
# Unicode
precomposed
# names of characters
zwsp
# mermaid
autonumber
# GitHub issue/PR commands
azp
feedbackhub

View File

@@ -209,6 +209,7 @@ Bilibili
BVID
capturevideosample
cmdow
contoso
Contoso
Controlz
cortana

View File

@@ -105,13 +105,13 @@
^src/common/ManagedCommon/ColorFormatHelper\.cs$
^src/common/notifications/BackgroundActivatorDLL/cpp\.hint$
^src/common/sysinternals/Eula/
^src/modules/cmdpal/Tests/Microsoft\.CommandPalette\.Extensions\.Toolkit\.UnitTests/FuzzyMatcherComparisonTests.cs$
^src/modules/cmdpal/Tests/Microsoft\.CommandPalette\.Extensions\.Toolkit\.UnitTests/FuzzyMatcherDiacriticsTests.cs$
^doc/devdocs/modules/cmdpal/initial-sdk-spec/list-elements-mock-002\.pdn$
^src/modules/cmdpal/ext/SamplePagesExtension/Pages/SampleMarkdownImagesPage\.cs$
^src/modules/cmdpal/Microsoft\.CmdPal\.UI/Settings/InternalPage\.SampleData\.cs$
^src/modules/cmdpal/Tests/Microsoft\.CmdPal\.Common\.UnitTests/.*\.TestData\.cs$
^src/modules/cmdpal/Tests/Microsoft\.CmdPal\.Common\.UnitTests/Text/.*\.cs$
^src/modules/cmdpal/Tests/Microsoft\.CommandPalette\.Extensions\.Toolkit\.UnitTests/FuzzyMatcherComparisonTests.cs$
^src/modules/cmdpal/Tests/Microsoft\.CommandPalette\.Extensions\.Toolkit\.UnitTests/FuzzyMatcherDiacriticsTests.cs$
^src/modules/colorPicker/ColorPickerUI/Shaders/GridShader\.cso$
^src/modules/launcher/Plugins/Microsoft\.PowerToys\.Run\.Plugin\.TimeDate/Properties/
^src/modules/MouseUtils/MouseJumpUI/MainForm\.resx$

View File

@@ -1533,6 +1533,7 @@ resmimetype
RESOURCEID
RESTORETOMAXIMIZED
RETURNONLYFSDIRS
Revalidates
RGBQUAD
rgbs
rgelt

View File

@@ -1,4 +1,4 @@
<Project>
<Project>
<PropertyGroup>
<ManagePackageVersionsCentrally>true</ManagePackageVersionsCentrally>
<CentralPackageTransitivePinningEnabled>true</CentralPackageTransitivePinningEnabled>
@@ -69,7 +69,7 @@
<PackageVersion Include="Microsoft.Win32.SystemEvents" Version="10.0.7" />
<PackageVersion Include="Microsoft.WindowsPackageManager.ComInterop" Version="1.10.340" />
<PackageVersion Include="Microsoft.Windows.Compatibility" Version="10.0.7" />
<PackageVersion Include="Microsoft.Windows.CsWin32" Version="0.3.183" />
<PackageVersion Include="Microsoft.Windows.CsWin32" Version="0.3.269" />
<!-- CsWinRT version needs to be set to have a WinRT.Runtime.dll at the same version contained inside the NET SDK we're currently building on CI. -->
<!--
TODO: in Common.Dotnet.CsWinRT.props, on upgrade, verify RemoveCsWinRTPackageAnalyzer is no longer needed.

View File

@@ -206,6 +206,10 @@
<Platform Solution="*|ARM64" Project="ARM64" />
<Platform Solution="*|x64" Project="x64" />
</Project>
<Project Path="src/modules/cmdpal/ext/Microsoft.CmdPal.Ext.Actions/Microsoft.CmdPal.Ext.Actions.csproj">
<Platform Solution="*|ARM64" Project="ARM64" />
<Platform Solution="*|x64" Project="x64" />
</Project>
<Project Path="src/modules/cmdpal/ext/Microsoft.CmdPal.Ext.Bookmark/Microsoft.CmdPal.Ext.Bookmarks.csproj">
<Platform Solution="*|ARM64" Project="ARM64" />
<Platform Solution="*|x64" Project="x64" />

View File

@@ -60,7 +60,7 @@ A markdown page is a page inside of command palette that displays markdown conte
```csharp
interface IMarkdownPage requires IPage {
String[] Bodies(); // TODO! should this be an IBody, so we can make it observable?
String[] Bodies();
IDetails Details();
IContextItem[] Commands { get; };
}

View File

@@ -0,0 +1,167 @@
# Command Palette Extension Gallery
This document describes how Command Palette (CmdPal) discovers extensions for
the in-app **Extension gallery** page.
## At a glance
- The gallery loads a single JSON feed called `extensions.json` from a remote
HTTPS URL, parses it, and renders the entries.
- The default feed lives in the external repo
**`microsoft/CmdPal-Extensions`** at
`https://raw.githubusercontent.com/microsoft/CmdPal-Extensions/refs/heads/main/extensions.json`.
- Feed content + icon images are cached on disk so the page works offline and
survives short network hiccups.
- There is no WinGet discovery, no per-extension `manifest.json` fetch, and no
other network call for rendering the list.
## Implementation pointers
| Concern | File |
| --- | --- |
| Fetching, parsing, caching, pruning | `Microsoft.CmdPal.Common/ExtensionGallery/Services/ExtensionGalleryService.cs` |
| Resolving which URL to fetch | `Microsoft.CmdPal.Common/ExtensionGallery/Services/GalleryFeedUrlProvider.cs` + `Microsoft.CmdPal.UI/Helpers/GalleryServiceRegistration.cs` |
| HTTP + on-disk cache | `Microsoft.CmdPal.Common/ExtensionGallery/Services/ExtensionGalleryHttpClient.cs` (wraps `Microsoft.CmdPal.Common/Services/HttpCaching/HttpCachingClient`) |
| Feed + entry models | `Microsoft.CmdPal.Common/ExtensionGallery/Models/` |
## Feed URL resolution
`ExtensionGalleryService.GetFeedUrl()` returns, in order:
1. The user-configured URL from CmdPal settings (`SettingsModel.GalleryFeedUrl`,
exposed via the hidden `InternalPage` settings page). Any non-empty value
wins. Mostly used for local testing against a custom feed.
2. Otherwise, the built-in default
`https://raw.githubusercontent.com/microsoft/CmdPal-Extensions/refs/heads/main/extensions.json`.
Local `file://` URIs are allowed too — `FetchFeedDocumentAsync` reads the file
directly and bypasses the HTTP cache.
## Feed format
The feed is a single wrapped JSON document with inline entries:
```json
{
"$schema": "https://raw.githubusercontent.com/microsoft/CmdPal-Extensions/main/.github/schemas/gallery.schema.json",
"extensions": [
{
"id": "sample-extension",
"title": "Sample Extension",
"description": "A sample extension demonstrating the gallery feed format.",
"author": { "name": "Microsoft", "url": "https://github.com/microsoft" },
"homepage": "https://github.com/microsoft/CmdPal-Extensions",
"iconUrl": "https://.../icon.png",
"screenshotUrls": ["https://.../screenshot-1.png"],
"tags": ["sample"],
"installSources": [
{ "type": "winget", "id": "Contoso.SampleExtension" },
{ "type": "msstore", "id": "9P…" },
{ "type": "url", "uri": "https://github.com/contoso/sample/releases/latest" }
],
"detection": { "packageFamilyName": "Contoso.SampleExtension_1234567890abc" }
}
]
}
```
Only the `extensions` array is read at runtime. The authoritative JSON
schema for an entry lives in the upstream feed repo
([`microsoft/CmdPal-Extensions`](https://github.com/microsoft/CmdPal-Extensions));
don't duplicate it here — it drifts.
### Required + optional entry fields
| Field | Required | Notes |
| --- | --- | --- |
| `id` | yes | Lowercase stable identifier; entries with empty id are dropped. |
| `title` | yes | Display name. |
| `description` | yes | Shown in list and detail views. |
| `author.name` | yes | `author.url` optional. |
| `installSources` | yes | At least one entry; see [Install sources](#install-sources). |
| `homepage`, `iconUrl`, `screenshotUrls`, `tags`, `detection.packageFamilyName` | no | All optional. |
Relative `iconUrl` / `screenshotUrls` are resolved against the feed URL's
directory (useful only for local / `file://` feeds during development).
## Install sources
Each entry's `installSources` is consumed by
`ExtensionGalleryItemViewModel` to decide which install affordances to show.
| `type` | Required field | Behaviour |
| --- | --- | --- |
| `winget` | `id` | Enables the "Install via WinGet" button (uses the shared WinGet service), and joins in-flight install progress + installed/update status. |
| `msstore` | `id` | Opens `ms-windows-store://pdp/?ProductId={id}`. |
| `url` | `uri` | Shown as a "GitHub" or "Website" link depending on host. |
An entry can declare any combination. Sources the runtime does not recognise
are surfaced as an "unknown source" indicator.
## Fetching and caching
`ExtensionGalleryService` uses `ExtensionGalleryHttpClient`, which wraps
`HttpCachingClient` over a file-system cache. Both the feed JSON and any
cacheable icon URLs are cached.
| Setting | Value | Defined in |
| --- | --- | --- |
| Cache root | `{AppCache}\GalleryCache\` | `ExtensionGalleryHttpClient.CacheDirectoryName` |
| Feed TTL | 4 hours | `ExtensionGalleryHttpClient.DefaultTimeToLive` |
| Icon TTL | 24 hours | `ExtensionGalleryService.IconCacheTtl` |
| HTTP timeout | 30 s | `ExtensionGalleryHttpClient` |
| `User-Agent` | `PowerToys-CmdPal/1.0` | `ExtensionGalleryHttpClient` |
`{AppCache}` resolves to `ApplicationData.Current.LocalCacheFolder` when
CmdPal runs packaged, and to
`%LOCALAPPDATA%\Microsoft\PowerToys\Microsoft.CmdPal\Cache\` when unpackaged
(see `ApplicationInfoService.DetermineCacheDirectory`).
### Fetch flow
`GetExtensionsAsync` (normal load) and `RefreshAsync` (user-initiated
refresh, `forceRefresh: true`) both go through `FetchWrappedFeedAsync`:
1. Resolve the feed URL (see above).
2. If the URL is local, read it from disk. Otherwise, hand it to
`HttpCachingClient.GetResourceAsync` which:
- Serves a fresh cached copy if one exists and TTL has not elapsed.
- Otherwise issues a conditional GET (ETag / `If-None-Match`). On `304
Not Modified` it refreshes the cache metadata and returns the cached
body.
- On network failure it returns the last-known cached body with
`UsedFallbackCache = true`, so the UI can show a "stale data" banner.
3. Parse the JSON with the source-generated `GallerySerializationContext`
(strongly-typed `GalleryRemoteIndex` — no reflection, AOT-friendly).
4. Drop entries with missing `id`, normalize relative `iconUrl` and
`screenshotUrls`, and resolve remote icon URIs through the same HTTP
cache so the UI binds to local `file://` URIs.
5. On a successful forced refresh, `PruneCachedResources` deletes cache
entries that are no longer referenced by the current feed (old feed URL
and icon URLs that dropped out of the feed).
### Fetch result flags
`GetExtensionsAsync` returns a `GalleryFetchResult` that the view model uses
for UI hints:
| Flag | Meaning |
| --- | --- |
| `FromCache` | The feed came from cache without hitting the network (TTL still valid). |
| `UsedFallbackCache` | A network request was attempted and failed, and the cached copy was served as fallback. The UI shows a stale-data info bar. |
| `RateLimited` | The origin returned `429 Too Many Requests` and no fallback was available. The UI shows a rate-limit error. |
## Authoring
- Entries for the production gallery are added to the feed repo
`microsoft/CmdPal-Extensions`.
- For editor validation of an entry, reference the schema published in the
upstream repo via the entry's `$schema` field.
- Keep `id` stable once an extension is published — users may have it
installed and the gallery keys install status by id.
- Prefer providing a `winget` source when the extension ships through App
Installer; the gallery uses it both for status ("Installed" / "Update
available") and for the in-app install button.
- `detection.packageFamilyName` lets the gallery recognise an
already-installed packaged extension before WinGet metadata resolves.

View File

@@ -76,6 +76,13 @@ functionality.
- [Status messages](#status-messages)
- [Rendering of ICommandItems in Lists and Menus](#rendering-of-icommanditems-in-lists-and-menus)
- [Addenda I: API additions (ICommandProvider2)](#addenda-i-api-additions-icommandprovider2)
- [Addenda II: Commands with Parameters](#addenda-ii-commands-with-parameters)
- [String parameters](#string-parameters)
- [Command parameters - Invokable Commands](#command-parameters---invokable-commands)
- [Command parameters - List Commands](#command-parameters---list-commands)
- [Examples](#examples)
- [Addenda III: Rich Search (DRAFT)](#addenda-iii-rich-search-draft)
- [Nov 2025 status](#nov-2025-status)
- [Addenda IV: Dock bands](#addenda-iv-dock-bands)
- [Pinning nested commands to the dock (and top level)](#pinning-nested-commands-to-the-dock-and-top-level)
- [Class diagram](#class-diagram)
@@ -2048,6 +2055,183 @@ Fortunately, we can put all of that (`GetApiExtensionStubs`,
developers won't have to do anything. The toolkit will just do the right thing
for them.
## Addenda II: Commands with Parameters
Extensions will often want to provide commands that accept parameters from the
user.
To support this, we're adding a new page type. The `IParametersPage` is a page
that allows an extension to define a set of parameters that the user can fill.
These parameters can be of different types, such as:
* Labels: static text that provides context or instructions.
* String parameters: text input fields where the user can type a string.
* Command parameters: interactive fields that allow the user to select from a
list of predefined commands, or just press a button to select an input.
Interleaving labels with parameters allows extensions to create rich, guided
input forms for their commands. These are a more lightweight solution than the
current adaptive card content.
```csharp
[uuid("a2590cc9-510c-4af7-b562-a6b56fe37f55")]
interface IParameterRun requires INotifyPropChanged
{
};
interface ILabelRun requires IParameterRun
{
String Text{ get; };
};
interface IParameterValueRun requires IParameterRun
{
String PlaceholderText{ get; };
Boolean NeedsValue{ get; }; // TODO! name is weird
};
interface IStringParameterRun requires IParameterValueRun
{
String Text{ get; set; };
// TODO! do we need a way to validate string inputs?
};
interface ICommandParameterRun requires IParameterValueRun
{
String DisplayText{ get; };
ICommand GetSelectValueCommand(UInt64 hostHwnd);
IIconInfo Icon{ get; }; // ? maybe
};
interface IParametersPage requires IPage
{
IParameterRun[] Parameters{ get; };
IListItem Command{ get; };
};
```
When we open a `IParametersPage`, we will render the `Parameters` in the search
box. We'll move focus to the first `IParameterRun` that is not a `ILabelRun`.
What those interactions looks like depends on the type of `IParameterRun`.
There are three basic types of inputs: strings, invokable commands, and lists.
Strings are a special case that doesn't require a command to set the value.
Lists and invokable commands are picked based on the type of the
`SelectValueCommand`. Each of these are detailed below.
When all the parameters have `NeedsValue` set to `false`, we will display a
single item to the user - the `Command` item.
### String parameters
These are rendered as a text box within the search box. The user can type into
it. Focus is moved to the next parameter when the user presses Enter or tab.
### Command parameters - Invokable Commands
These are used when the `SelectValueCommand` is an `IInvokableCommand`.
These are rendered as a button within the search box. The button text is
`DisplayText` if it is set. If it is not, we will display the
`PlaceholderText`. If the user clicks the button, we invoke the
`SelectValueCommand` (and ignore the `CommandResult`).
This is good for file pickers, date pickers, color pickers, etc. Anything that
requires a custom UI to pick a value.
When the extension has picked a value, it should set the `NeedsValue` to false.
The extension can also set the `DisplayText` and `Icon` to reflect the chosen value.
When the user presses enter with the button focused, we will also invoke the
`SelectValueCommand`.
When the user presses tab, we will move focus to the next parameter.
If the `NeedsValue` property is changed to `false` while it's focused, we will
move focus to the next parameter.
### Command parameters - List Commands
These are used when the `SelectValueCommand` is an `IListPage` - both static and
dynamic lists work similarly.
These are rendered as a text box within the search box. When the user focuses
the text box, we will display the items from the `IListPage` in the body of
CmdPal. The user can then type to filter the list. This filtering will work the
same way as any other list page in CmdPal - CmdPal will filter static lists, or
pass the query to a dynamic list.
The items in this list should all be `IListItem` objects with
`IInvokableCommands`. Putting a `IPage` into one of these items will cause the
user to navigate away from the parameters page, which would probably be
unexpected.
When the user picks an item from the list, the extension should handle that
command by bubbling an event up to the `CommandRun`, and setting the `Value`,
`DisplayText`, and `Icon` properties, and setting `NeedsValue` to false.
When the user presses enter with the text box focused, we will invoke the
command of the selected item in the list.
When the user presses tab, we will move focus to the next parameter.
If the `NeedsValue` property is changed to `false` while it's focused, we will
move focus to the next parameter.
### Examples
Lets say you had a command like "Create a note \${title} in \${folder}".
`title` is a string input, and `folder` is a static list of folders.
The extension author can then define a `IParametersPage` with four runs in it:
* A `ILabelRun` for "Create a note"
* A `IStringParameterRun` for the `title`
* A `ILabelRun` for "in"
* A `ICommandParameterRun` for the `folder`. The `Command` will be a
`IListPage`, where the items are possible folders
In this example, the user can pick the "create note" command, then type the
title, hit enter/tab, and then pick a folder from the list, then hit enter to
run the command.
Samples for the parameters page are implemented over in
[the sample extension](../../ext/SamplePagesExtension/Pages/ParameterSamples.cs)
## Addenda III: Rich Search (DRAFT)
> [!NOTE]
> _Mike_: Rich search and parameters were prototyped together, but ultimately we used two different solutions.
>
> Currently, we have a dummy implementation of draft C (ZWSP tokens), but without full API changes. Detailed [below](#nov-2025-status).
Extensions will often want to provide rich search experiences for their users.
This addenda is broken into multiple draft specs currently. These represent
different approaches to the same goals.
* **A**: [Rich Search Box](./drafts/RichSearchBox-draft-A.md)
* **B**: [Prefix Search](./drafts/PrefixSearch-draft-B.md)
* **C**: [ZWSP tokens](./drafts/PlainRichSearch-draft-C.md)
### Nov 2025 status
As of Nov 2025, we're implementing a simple version of draft C in the host.
In this version, if the extension implements `IDynamicListPage`, and also
implements `IExtendedAttributesProvider`, then they can set the `TokenSearch`
property. This will enlighten CmdPal to treat ZWSP-separated tokens in the
search text specially.
For an example, see
[this sample implementation](../../ext/SamplePagesExtension/Pages/SampleSuggestionsPage.cs).
In my head, I am still leaning towards a more full-featured version of draft C,
but with full CommandItem's in the `ISearchUpdateArgs` instead of just strings.
We'd almost need a new page type to support that, where the extension can add
`ICommandItem`s to the search box directly.
## Addenda IV: Dock bands
The "dock" is another way to surface commands to the user. This is a
@@ -2158,7 +2342,6 @@ because that method is was designed for two main purposes:
In neither of those scenarios was the full "display" of the item needed. In
pinning scenarios, however, we need everything that the user would see in the UI
for that item, which is all in the `ICommandItem`.
## Class diagram
This is a diagram attempting to show the relationships between the various types we've defined for the SDK. Some elements are omitted for clarity. (Notably, `IconData` and `IPropChanged`, which are used in many places.)

View File

@@ -8,6 +8,9 @@
<!-- Suppress DynamicallyAccessedMemberTypes.PublicParameterlessConstructor in fallback code path of Windows SDK projection -->
<!-- Suppress CA1416 for Windows-specific APIs that are used in PowerToys which only runs on Windows 10.0.19041.0+ -->
<WarningsNotAsErrors>IL2081;CsWinRT1028;CA1416;$(WarningsNotAsErrors)</WarningsNotAsErrors>
<!-- IL2104 is the per-assembly roll-up warning emitted by ILLink ("Assembly X produced trim warnings"). The
Windows SDK projection assemblies (Microsoft.Windows.SDK.NET, WinRT.Runtime) ship with known trim warnings
that we can't fix, so we allow IL2104 through as a warning rather than failing the build. -->
<WarningsNotAsErrors>IL2081;IL2104;CsWinRT1028;CA1416;$(WarningsNotAsErrors)</WarningsNotAsErrors>
</PropertyGroup>
</Project>

View File

@@ -38,6 +38,7 @@
"src\\modules\\cmdpal\\Tests\\Microsoft.CmdPal.UI.ViewModels.UnitTests\\Microsoft.CmdPal.UI.ViewModels.UnitTests.csproj",
"src\\modules\\cmdpal\\Tests\\Microsoft.CmdPal.UITests\\Microsoft.CmdPal.UITests.csproj",
"src\\modules\\cmdpal\\Tests\\Microsoft.CommandPalette.Extensions.Toolkit.UnitTests\\Microsoft.CommandPalette.Extensions.Toolkit.UnitTests.csproj",
"src\\modules\\cmdpal\\ext\\Microsoft.CmdPal.Ext.Actions\\Microsoft.CmdPal.Ext.Actions.csproj",
"src\\modules\\cmdpal\\ext\\Microsoft.CmdPal.Ext.Apps\\Microsoft.CmdPal.Ext.Apps.csproj",
"src\\modules\\cmdpal\\ext\\Microsoft.CmdPal.Ext.Bookmark\\Microsoft.CmdPal.Ext.Bookmarks.csproj",
"src\\modules\\cmdpal\\ext\\Microsoft.CmdPal.Ext.Calc\\Microsoft.CmdPal.Ext.Calc.csproj",

View File

@@ -0,0 +1,12 @@
// 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.Common.ExtensionGallery.Models;
public sealed class GalleryAuthor
{
public string Name { get; set; } = string.Empty;
public string? Url { get; set; }
}

View File

@@ -0,0 +1,10 @@
// 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.Common.ExtensionGallery.Models;
public sealed class GalleryDetection
{
public string? PackageFamilyName { get; set; }
}

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.
namespace Microsoft.CmdPal.Common.ExtensionGallery.Models;
public sealed class GalleryExtensionEntry
{
public string Id { get; set; } = string.Empty;
public string Title { get; set; } = string.Empty;
public string Description { get; set; } = string.Empty;
public string? ShortDescription { get; set; }
public GalleryAuthor Author { get; set; } = new();
public string? Homepage { get; set; }
public string? Readme { get; set; }
public string? IconUrl { get; set; }
public List<string> ScreenshotUrls { get; set; } = [];
public List<GalleryInstallSource> InstallSources { get; set; } = [];
public GalleryDetection? Detection { get; set; }
public List<string> Tags { get; set; } = [];
}

View File

@@ -0,0 +1,14 @@
// 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.Common.ExtensionGallery.Models;
public sealed class GalleryInstallSource
{
public string Type { get; set; } = string.Empty;
public string? Id { get; set; }
public string? Uri { get; set; }
}

View File

@@ -0,0 +1,13 @@
// 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.Common.ExtensionGallery.Models;
/// <summary>
/// Represents the wrapped gallery index format where extension data is inline.
/// </summary>
public sealed class GalleryRemoteIndex
{
public List<GalleryExtensionEntry> Extensions { get; set; } = [];
}

View File

@@ -0,0 +1,14 @@
// 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.Text.Json.Serialization;
namespace Microsoft.CmdPal.Common.ExtensionGallery.Models;
[JsonSerializable(typeof(GalleryExtensionEntry))]
[JsonSerializable(typeof(GalleryRemoteIndex))]
[JsonSourceGenerationOptions(PropertyNameCaseInsensitive = true)]
public sealed partial class GallerySerializationContext : JsonSerializerContext
{
}

View File

@@ -0,0 +1,57 @@
// 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.CmdPal.Common.Services.HttpCaching;
using Microsoft.Extensions.Logging;
namespace Microsoft.CmdPal.Common.ExtensionGallery.Services;
/// <summary>
/// Identifies the HTTP client instance used by the extension gallery.
/// </summary>
public sealed partial class ExtensionGalleryHttpClient : IDisposable
{
internal const string CacheDirectoryName = "GalleryCache";
private const int TimeoutSeconds = 15;
private const string UserAgent = "PowerToys-CmdPal/1.0";
private readonly HttpCachingClient _cache;
internal static readonly TimeSpan DefaultTimeToLive = TimeSpan.FromHours(4);
public ExtensionGalleryHttpClient(IApplicationInfoService applicationInfoService, ILogger<ExtensionGalleryHttpClient> logger)
: this(applicationInfoService, innerHandler: null, logger)
{
}
internal ExtensionGalleryHttpClient(IApplicationInfoService applicationInfoService, HttpMessageHandler? innerHandler, ILogger<ExtensionGalleryHttpClient> logger)
: this(
Path.Combine(applicationInfoService.CacheDirectory, CacheDirectoryName),
innerHandler,
logger)
{
ArgumentNullException.ThrowIfNull(applicationInfoService);
}
internal ExtensionGalleryHttpClient(string cacheDirectory, HttpMessageHandler? innerHandler, ILogger<ExtensionGalleryHttpClient> logger)
{
ArgumentException.ThrowIfNullOrWhiteSpace(cacheDirectory);
ArgumentNullException.ThrowIfNull(logger);
_cache = new HttpCachingClient(
cacheDirectory,
DefaultTimeToLive,
TimeSpan.FromSeconds(TimeoutSeconds),
UserAgent,
innerHandler,
logger);
}
internal HttpCachingClient Cache => _cache;
public void Dispose()
{
_cache.Dispose();
}
}

View File

@@ -0,0 +1,404 @@
// 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.CodeAnalysis;
using System.Net;
using System.Text.Json;
using Microsoft.CmdPal.Common.ExtensionGallery.Models;
using Microsoft.Extensions.Logging;
using MEL = Microsoft.Extensions.Logging;
namespace Microsoft.CmdPal.Common.ExtensionGallery.Services;
public sealed partial class ExtensionGalleryService : IExtensionGalleryService
{
private const string DefaultFeedUrl = "https://aka.ms/CmdPal-ExtensionsJson";
private const string LocalFeedFileName = "extensions.json";
private static readonly TimeSpan IconCacheTtl = TimeSpan.FromDays(1);
private static readonly TimeSpan CacheTtl = ExtensionGalleryHttpClient.DefaultTimeToLive;
private static readonly Action<MEL.ILogger, Exception?> LogGalleryFetchFailedMessage = LoggerMessage.Define(
LogLevel.Error,
new EventId(0, nameof(LogGalleryFetchFailed)),
"Gallery fetch failed");
private static readonly Action<MEL.ILogger, string, Exception?> LogFailedToResolveExtensionGalleryIconMessage = LoggerMessage.Define<string>(
LogLevel.Error,
new EventId(1, nameof(LogFailedToResolveExtensionGalleryIcon)),
"Failed to resolve extension gallery icon '{IconUri}'.");
private readonly ILogger<ExtensionGalleryService> _logger;
private readonly GalleryFeedUrlProvider _galleryFeedUrlProvider;
private readonly ExtensionGalleryHttpClient _galleryHttpClient;
private static readonly HashSet<string> SupportedFeedSchemes =
[
Uri.UriSchemeHttp,
Uri.UriSchemeHttps,
Uri.UriSchemeFile,
];
public ExtensionGalleryService(
ExtensionGalleryHttpClient galleryHttpClient,
ILogger<ExtensionGalleryService> logger,
GalleryFeedUrlProvider galleryFeedUrlProvider)
{
ArgumentNullException.ThrowIfNull(galleryHttpClient);
ArgumentNullException.ThrowIfNull(logger);
ArgumentNullException.ThrowIfNull(galleryFeedUrlProvider);
_logger = logger;
_galleryHttpClient = galleryHttpClient;
_galleryFeedUrlProvider = galleryFeedUrlProvider;
}
public bool IsCustomFeed => !string.IsNullOrWhiteSpace(_galleryFeedUrlProvider());
public string GetBaseUrl()
{
return GetFeedUrl();
}
public string GetFeedUrl()
{
var configuredUrl = _galleryFeedUrlProvider();
return string.IsNullOrWhiteSpace(configuredUrl) ? DefaultFeedUrl : configuredUrl.Trim();
}
public Task<GalleryFetchResult> FetchExtensionsAsync(CancellationToken cancellationToken = default)
{
return FetchWrappedFeedAsync(forceRefresh: false, cancellationToken);
}
public Task<GalleryFetchResult> RefreshAsync(CancellationToken cancellationToken = default)
{
return FetchWrappedFeedAsync(forceRefresh: true, cancellationToken);
}
private async Task<GalleryFetchResult> FetchWrappedFeedAsync(bool forceRefresh, CancellationToken cancellationToken)
{
try
{
if (!TryGetFeedUri(out var feedUri))
{
throw new InvalidOperationException($"Invalid gallery feed URL '{GetFeedUrl()}'.");
}
var fetchResult = await FetchFeedDocumentAsync(feedUri, forceRefresh, cancellationToken);
var extensions = TryParseWrappedGallery(fetchResult.Json);
if (extensions is null || extensions.Count == 0)
{
throw new InvalidOperationException("The extension gallery feed is empty or invalid.");
}
TryGetBaseDirectoryUri(feedUri, out var baseDirectoryUri);
NormalizeRemoteEntries(extensions, baseDirectoryUri);
var cacheableIconUris = CollectCacheableIconUris(extensions);
if (forceRefresh && !fetchResult.UsedFallbackCache)
{
PruneCachedResources(feedUri, cacheableIconUris);
}
await LocalizeIconUrisAsync(extensions, cancellationToken);
return new GalleryFetchResult
{
Extensions = extensions,
FromCache = fetchResult.FromCache,
UsedFallbackCache = fetchResult.UsedFallbackCache,
};
}
catch (Exception ex) when (ex is HttpRequestException or IOException or TaskCanceledException or OperationCanceledException or InvalidOperationException or UriFormatException)
{
LogGalleryFetchFailed(_logger, ex);
var isRateLimited = ex is HttpRequestException { StatusCode: HttpStatusCode.TooManyRequests };
return new GalleryFetchResult
{
IsRateLimited = isRateLimited,
HasError = true,
ErrorMessage = isRateLimited ? null : ex.Message,
};
}
}
private async Task<FeedFetchResult> FetchFeedDocumentAsync(Uri feedUri, bool forceRefresh, CancellationToken cancellationToken)
{
if (feedUri.IsFile)
{
var localJson = await File.ReadAllTextAsync(feedUri.LocalPath, cancellationToken);
return new FeedFetchResult(localJson, FromCache: false, UsedFallbackCache: false);
}
if (!feedUri.Scheme.Equals(Uri.UriSchemeHttp, StringComparison.OrdinalIgnoreCase)
&& !feedUri.Scheme.Equals(Uri.UriSchemeHttps, StringComparison.OrdinalIgnoreCase))
{
throw new InvalidOperationException($"Unsupported gallery URI scheme '{feedUri.Scheme}'.");
}
var fetchResult = await _galleryHttpClient.Cache.GetResourceAsync(
feedUri,
fileNameHint: ResolveFeedFileName(feedUri),
forceRefresh: forceRefresh,
timeToLiveOverride: CacheTtl,
cancellationToken: cancellationToken);
var responseJson = await File.ReadAllTextAsync(fetchResult.Resource.ContentPath, cancellationToken);
return new FeedFetchResult(responseJson, fetchResult.Resource.FromCache, fetchResult.UsedFallbackCache);
}
private static List<GalleryExtensionEntry>? TryParseWrappedGallery(string json)
{
try
{
var index = JsonSerializer.Deserialize(json, GallerySerializationContext.Default.GalleryRemoteIndex);
return index?.Extensions;
}
catch (JsonException)
{
return null;
}
}
private static void NormalizeRemoteEntries(List<GalleryExtensionEntry> entries, Uri? baseDirectoryUri)
{
for (var i = entries.Count - 1; i >= 0; i--)
{
var entry = entries[i];
if (string.IsNullOrWhiteSpace(entry.Id))
{
entries.RemoveAt(i);
continue;
}
entry.Id = entry.Id.Trim();
NormalizeEntry(entry, baseDirectoryUri);
}
}
private static void NormalizeEntry(GalleryExtensionEntry entry, Uri? baseDirectoryUri)
{
entry.IconUrl = NormalizeOptionalUri(entry.IconUrl, baseDirectoryUri);
entry.ScreenshotUrls = NormalizeOptionalUris(entry.ScreenshotUrls, baseDirectoryUri);
}
private static string? NormalizeOptionalUri(string? value, Uri? baseDirectoryUri)
{
var normalizedValue = ToNullIfWhiteSpace(value);
if (normalizedValue is null)
{
return null;
}
if (Uri.TryCreate(normalizedValue, UriKind.Absolute, out var absoluteUri))
{
return absoluteUri.AbsoluteUri;
}
if (baseDirectoryUri is null || !Uri.TryCreate(baseDirectoryUri, normalizedValue, out var candidate))
{
return normalizedValue;
}
if (!candidate.AbsoluteUri.StartsWith(baseDirectoryUri.AbsoluteUri, StringComparison.OrdinalIgnoreCase))
{
return normalizedValue;
}
return candidate.AbsoluteUri;
}
private static List<string> NormalizeOptionalUris(List<string>? values, Uri? baseDirectoryUri)
{
if (values is null || values.Count == 0)
{
return [];
}
List<string> normalizedValues = [];
for (var i = 0; i < values.Count; i++)
{
var normalizedValue = NormalizeOptionalUri(values[i], baseDirectoryUri);
if (normalizedValue is not null)
{
normalizedValues.Add(normalizedValue);
}
}
return normalizedValues;
}
private static string? ToNullIfWhiteSpace(string? value)
{
if (string.IsNullOrWhiteSpace(value))
{
return null;
}
return value.Trim();
}
private async Task LocalizeIconUrisAsync(IEnumerable<GalleryExtensionEntry> extensions, CancellationToken cancellationToken)
{
List<Task> localizationTasks = [];
foreach (var extension in extensions)
{
localizationTasks.Add(LocalizeIconUriAsync(extension, cancellationToken));
}
await Task.WhenAll(localizationTasks);
}
private async Task LocalizeIconUriAsync(GalleryExtensionEntry extension, CancellationToken cancellationToken)
{
var iconUrl = ToNullIfWhiteSpace(extension.IconUrl);
if (iconUrl is null || !Uri.TryCreate(iconUrl, UriKind.Absolute, out var iconUri))
{
extension.IconUrl = null;
return;
}
var localizedIconUri = await ResolveLocalizedIconUriAsync(iconUri, cancellationToken);
extension.IconUrl = localizedIconUri?.AbsoluteUri;
}
private async Task<Uri?> ResolveLocalizedIconUriAsync(Uri iconUri, CancellationToken cancellationToken)
{
ArgumentNullException.ThrowIfNull(iconUri);
if (iconUri.IsFile || iconUri.Scheme.Equals("ms-appx", StringComparison.OrdinalIgnoreCase))
{
return iconUri;
}
if (!IsCacheableUri(iconUri))
{
return null;
}
try
{
var fetchResult = await _galleryHttpClient.Cache.GetResourceAsync(
iconUri,
fileNameHint: Path.GetFileName(Uri.UnescapeDataString(iconUri.AbsolutePath)),
forceRefresh: false,
timeToLiveOverride: IconCacheTtl,
cancellationToken: cancellationToken);
return fetchResult.Resource.ContentUri;
}
catch (OperationCanceledException)
{
throw;
}
catch (Exception ex) when (ex is HttpRequestException or IOException or InvalidOperationException)
{
LogFailedToResolveExtensionGalleryIcon(_logger, iconUri.AbsoluteUri, ex);
return null;
}
}
private static List<Uri> CollectCacheableIconUris(IEnumerable<GalleryExtensionEntry> extensions)
{
List<Uri> retainedResourceUris = [];
foreach (var extension in extensions)
{
if (!Uri.TryCreate(extension.IconUrl, UriKind.Absolute, out var iconUri)
|| !IsCacheableUri(iconUri))
{
continue;
}
retainedResourceUris.Add(iconUri);
}
return retainedResourceUris;
}
private void PruneCachedResources(Uri feedUri, IEnumerable<Uri> cacheableIconUris)
{
List<Uri> retainedResourceUris = [];
if (IsCacheableUri(feedUri))
{
retainedResourceUris.Add(feedUri);
}
foreach (var iconUri in cacheableIconUris)
{
retainedResourceUris.Add(iconUri);
}
_galleryHttpClient.Cache.Prune(retainedResourceUris);
}
private static bool IsCacheableUri(Uri resourceUri)
{
return resourceUri.Scheme.Equals(Uri.UriSchemeHttp, StringComparison.OrdinalIgnoreCase)
|| resourceUri.Scheme.Equals(Uri.UriSchemeHttps, StringComparison.OrdinalIgnoreCase);
}
private static string ResolveFeedFileName(Uri feedUri)
{
var fileNameHint = Path.GetFileName(Uri.UnescapeDataString(feedUri.AbsolutePath));
return string.IsNullOrWhiteSpace(fileNameHint) ? LocalFeedFileName : fileNameHint;
}
private bool TryGetFeedUri([NotNullWhen(true)] out Uri? feedUri)
{
feedUri = null;
var feedUrl = GetFeedUrl();
if (string.IsNullOrWhiteSpace(feedUrl))
{
return false;
}
if (!Uri.TryCreate(feedUrl, UriKind.Absolute, out var candidate))
{
return false;
}
if (!SupportedFeedSchemes.Contains(candidate.Scheme))
{
return false;
}
if (candidate.IsFile && Directory.Exists(candidate.LocalPath))
{
candidate = new Uri(Path.Combine(candidate.LocalPath, LocalFeedFileName));
}
feedUri = candidate;
return true;
}
private static bool TryGetBaseDirectoryUri(Uri feedUri, [NotNullWhen(true)] out Uri? baseDirectoryUri)
{
baseDirectoryUri = null;
try
{
var candidate = new Uri(feedUri, ".");
if (!SupportedFeedSchemes.Contains(candidate.Scheme))
{
return false;
}
baseDirectoryUri = candidate;
return true;
}
catch (UriFormatException)
{
return false;
}
}
private static void LogGalleryFetchFailed(MEL.ILogger logger, Exception exception)
{
LogGalleryFetchFailedMessage(logger, exception);
}
private static void LogFailedToResolveExtensionGalleryIcon(MEL.ILogger logger, string iconUri, Exception exception)
{
LogFailedToResolveExtensionGalleryIconMessage(logger, iconUri, exception);
}
private sealed record FeedFetchResult(string Json, bool FromCache, bool UsedFallbackCache);
}

View File

@@ -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.Common.ExtensionGallery.Services;
public delegate string? GalleryFeedUrlProvider();

View File

@@ -0,0 +1,42 @@
// 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.ExtensionGallery.Models;
namespace Microsoft.CmdPal.Common.ExtensionGallery.Services;
public sealed record GalleryFetchResult
{
/// <summary>
/// Gets or sets the gallery entries returned by the fetch operation.
/// </summary>
public List<GalleryExtensionEntry> Extensions { get; init; } = [];
/// <summary>
/// Gets or sets a value indicating whether the result was loaded from cache.
/// </summary>
public bool FromCache { get; init; }
/// <summary>
/// Gets or sets a value indicating whether the service had to fall back to cached data
/// because a remote refresh could not be completed successfully.
/// </summary>
public bool UsedFallbackCache { get; init; }
/// <summary>
/// Gets or sets a value indicating whether the fetch failed because the gallery responded
/// with HTTP 429 Too Many Requests and no cached fallback data was available.
/// </summary>
public bool IsRateLimited { get; init; }
/// <summary>
/// Gets or sets a value indicating whether the fetch operation completed with an error.
/// </summary>
public bool HasError { get; init; }
/// <summary>
/// Gets or sets the error message associated with the fetch operation, when available.
/// </summary>
public string? ErrorMessage { get; init; }
}

View File

@@ -0,0 +1,38 @@
// 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.Common.ExtensionGallery.Services;
public interface IExtensionGalleryService
{
/// <summary>
/// Fetches the gallery feed.
/// Falls back to cached data on failure.
/// Returned entries are normalized for local display, including icon URIs.
/// </summary>
/// <param name="cancellationToken">A token that cancels the fetch operation.</param>
/// <returns>The fetched gallery data, optionally populated from cache.</returns>
Task<GalleryFetchResult> FetchExtensionsAsync(CancellationToken cancellationToken = default);
/// <summary>
/// Attempts to fetch fresh data from the feed.
/// Falls back to cached data if the refresh fails.
/// Returned entries are normalized for local display, including icon URIs.
/// </summary>
/// <param name="cancellationToken">A token that cancels the refresh operation.</param>
/// <returns>The refreshed gallery data, optionally populated from cache.</returns>
Task<GalleryFetchResult> RefreshAsync(CancellationToken cancellationToken = default);
/// <summary>
/// Gets the configured gallery feed URL.
/// For compatibility this method keeps its historical name, but it returns the full feed endpoint.
/// </summary>
/// <returns>The configured gallery feed endpoint.</returns>
string GetBaseUrl();
/// <summary>
/// Returns true if a custom (non-default) feed URL is configured.
/// </summary>
bool IsCustomFeed { get; }
}

View File

@@ -2,9 +2,9 @@
// 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.Core.Common.Messages;
namespace Microsoft.CmdPal.Common.Messages;
/// <summary>
/// Message to request hiding the window.
/// </summary>
public partial record HideWindowMessage();
public partial class GetHwndMessage
{
public nint Hwnd { get; set; } = 0;
}

View File

@@ -22,6 +22,7 @@
<PropertyGroup>
<CsWinRTAotOptimizerEnabled>true</CsWinRTAotOptimizerEnabled>
<CsWinRTIncludes>Microsoft.Management.Deployment</CsWinRTIncludes>
</PropertyGroup>
<ItemGroup>
@@ -29,8 +30,18 @@
<ProjectReference Include="..\..\..\common\ManagedCommon\ManagedCommon.csproj" />
</ItemGroup>
<ItemGroup>
<AdditionalFiles Include="NativeMethods.txt" />
<AdditionalFiles Include="NativeMethods.json" />
</ItemGroup>
<ItemGroup>
<PackageReference Include="Microsoft.Extensions.Hosting" />
<PackageReference Include="Microsoft.WindowsPackageManager.ComInterop">
<NoWarn>NU1701</NoWarn>
<GeneratePathProperty>true</GeneratePathProperty>
<IncludeAssets>none</IncludeAssets>
</PackageReference>
<PackageReference Include="Microsoft.Windows.CsWin32">
<PrivateAssets>all</PrivateAssets>
<IncludeAssets>runtime; build; native; contentfiles; analyzers; buildtransitive</IncludeAssets>
@@ -40,6 +51,11 @@
<!-- This line forces the WebView2 version used by Windows App SDK to be the one we expect from Directory.Packages.props . -->
</ItemGroup>
<ItemGroup>
<CsWinRTInputs Include="$(PkgMicrosoft_WindowsPackageManager_ComInterop)\lib\uap10.0\Microsoft.Management.Deployment.winmd" />
<Content Include="$(PkgMicrosoft_WindowsPackageManager_ComInterop)\lib\uap10.0\Microsoft.Management.Deployment.winmd" Link="Microsoft.Management.Deployment.winmd" CopyToOutputDirectory="PreserveNewest" />
</ItemGroup>
<ItemGroup>
<EmbeddedResource Update="Properties\Resources.resx">
<Generator>ResXFileCodeGenerator</Generator>

View File

@@ -12,8 +12,9 @@ MonitorFromWindow
SHOW_WINDOW_CMD
ShellExecuteEx
SEE_MASK_INVOKEIDLIST
SEE_MASK_INVOKEIDLIST
GetFileAttributes
FILE_FLAGS_AND_ATTRIBUTES
INVALID_FILE_ATTRIBUTES
INVALID_FILE_ATTRIBUTES
CoCreateInstance

View File

@@ -8,6 +8,7 @@ using System.Security.Principal;
using Microsoft.CmdPal.Common.Helpers;
using Microsoft.CommandPalette.Extensions.Toolkit;
using Windows.ApplicationModel;
using Windows.Storage;
namespace Microsoft.CmdPal.Common.Services;
@@ -16,6 +17,9 @@ namespace Microsoft.CmdPal.Common.Services;
/// </summary>
public sealed class ApplicationInfoService : IApplicationInfoService
{
private const string UnpackagedCacheDirectoryName = "Cache";
private readonly Lazy<string> _cacheDirectory;
private readonly Lazy<string> _configDirectory = new(() => Utilities.BaseSettingsPath("Microsoft.CmdPal"));
private readonly Lazy<bool> _isElevated;
private readonly Lazy<string> _logDirectory;
@@ -28,6 +32,7 @@ public sealed class ApplicationInfoService : IApplicationInfoService
/// </summary>
public ApplicationInfoService()
{
_cacheDirectory = new Lazy<string>(DetermineCacheDirectory);
_packagingFlavor = new Lazy<AppPackagingFlavor>(DeterminePackagingFlavor);
_isElevated = new Lazy<bool>(DetermineElevationStatus);
_logDirectory = new Lazy<string>(() => _getLogDirectory?.Invoke() ?? "Not available");
@@ -62,6 +67,8 @@ public sealed class ApplicationInfoService : IApplicationInfoService
public string ConfigDirectory => _configDirectory.Value;
public string CacheDirectory => _cacheDirectory.Value;
public bool IsElevated => _isElevated.Value;
public string GetApplicationInfoSummary()
@@ -84,9 +91,33 @@ public sealed class ApplicationInfoService : IApplicationInfoService
Paths:
Log directory: {LogDirectory}
Config directory: {ConfigDirectory}
Cache directory: {CacheDirectory}
""";
}
private string DetermineCacheDirectory()
{
if (PackagingFlavor != AppPackagingFlavor.Packaged)
{
return Path.Combine(Utilities.BaseSettingsPath("Microsoft.CmdPal"), UnpackagedCacheDirectoryName);
}
try
{
var cacheDirectory = ApplicationData.Current.LocalCacheFolder.Path;
if (!string.IsNullOrWhiteSpace(cacheDirectory))
{
return cacheDirectory;
}
}
catch (Exception ex)
{
CoreLogger.LogError("Failed to resolve packaged cache directory", ex);
}
return Path.Combine(Utilities.BaseSettingsPath("Microsoft.CmdPal"), UnpackagedCacheDirectoryName);
}
private static AppPackagingFlavor DeterminePackagingFlavor()
{
// Try to determine if running as packaged

View File

@@ -0,0 +1,12 @@
// 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.Common.Services.HttpCaching.Abstraction;
internal sealed class CachedHttpFetchResult(CachedHttpResource resource, bool usedFallbackCache)
{
public CachedHttpResource Resource { get; } = resource;
public bool UsedFallbackCache { get; } = usedFallbackCache;
}

View File

@@ -0,0 +1,18 @@
// 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.Common.Services.HttpCaching.Abstraction;
internal sealed class CachedHttpResource(string contentPath, string? contentType, bool fromCache, bool wasRevalidated)
{
public string ContentPath { get; } = Path.GetFullPath(contentPath);
public Uri ContentUri => new(ContentPath);
public string? ContentType { get; } = contentType;
public bool FromCache { get; } = fromCache;
public bool WasRevalidated { get; } = wasRevalidated;
}

View File

@@ -0,0 +1,26 @@
// 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.Common.Services.HttpCaching.Abstraction;
internal sealed class CachedHttpResourceEntry(
Uri resourceUri,
string entryDirectory,
string metadataPath,
string payloadPath,
string payloadFileName,
HttpResourceCacheMetadata? metadata)
{
public Uri ResourceUri { get; } = resourceUri;
public string EntryDirectory { get; } = Path.GetFullPath(entryDirectory);
public string MetadataPath { get; } = Path.GetFullPath(metadataPath);
public string PayloadPath { get; } = Path.GetFullPath(payloadPath);
public string PayloadFileName { get; } = payloadFileName;
public HttpResourceCacheMetadata? Metadata { get; } = metadata;
}

View File

@@ -0,0 +1,22 @@
// 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.Common.Services.HttpCaching.Abstraction;
internal sealed class HttpResourceCacheMetadata
{
public string? ContentType { get; set; }
public string? ETag { get; set; }
public DateTimeOffset? ExpiresUtc { get; set; }
public string FileName { get; set; } = "payload.bin";
public DateTimeOffset? LastModifiedUtc { get; set; }
public DateTimeOffset LastValidatedUtc { get; set; }
public string SourceUri { get; set; } = string.Empty;
}

View File

@@ -0,0 +1,20 @@
// 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.Common.Services.HttpCaching.Abstraction;
internal interface IHttpResourceCacheStore
{
CachedHttpResourceEntry GetEntry(Uri resourceUri, string? fileNameHint = null);
CachedHttpResource? TryGetFresh(CachedHttpResourceEntry entry, TimeSpan? timeToLiveOverride);
CachedHttpResource? TryGetCached(CachedHttpResourceEntry entry, bool fromCache, bool wasRevalidated);
CachedHttpResource? UpdateAfterNotModified(CachedHttpResourceEntry entry, HttpResponseMessage response);
Task<CachedHttpResource> SaveResponseAsync(CachedHttpResourceEntry entry, HttpResponseMessage response, CancellationToken cancellationToken);
void Prune(IEnumerable<Uri> retainedResourceUris);
}

View File

@@ -0,0 +1,375 @@
// 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.Net;
using System.Security.Cryptography;
using System.Text;
using System.Text.Json;
using Microsoft.CmdPal.Common.Services.HttpCaching.Abstraction;
using Microsoft.Extensions.Logging;
using MEL = Microsoft.Extensions.Logging;
namespace Microsoft.CmdPal.Common.Services.HttpCaching;
internal sealed class FileSystemHttpResourceCacheStore : IHttpResourceCacheStore
{
private const string MetadataFileName = "metadata.json";
private const string DefaultPayloadFileName = "payload.bin";
private static readonly Action<MEL.ILogger, string, Exception?> LogFailedToEnumerateHttpResourceCacheMessage = LoggerMessage.Define<string>(
LogLevel.Error,
new EventId(1, nameof(LogFailedToEnumerateHttpResourceCache)),
"Failed to enumerate HTTP resource cache '{CacheDirectory}' for pruning.");
private static readonly Action<MEL.ILogger, string, Exception?> LogFailedToLoadCachedMetadataMessage = LoggerMessage.Define<string>(
LogLevel.Error,
new EventId(2, nameof(LogFailedToLoadCachedMetadata)),
"Failed to load cached metadata from '{MetadataPath}'.");
private static readonly Action<MEL.ILogger, string, Exception?> LogFailedToSaveCachedMetadataMessage = LoggerMessage.Define<string>(
LogLevel.Error,
new EventId(3, nameof(LogFailedToSaveCachedMetadata)),
"Failed to save cached metadata to '{MetadataPath}'.");
private static readonly Action<MEL.ILogger, string, Exception?> LogFailedToDeleteCachedHttpResourceDirectoryMessage = LoggerMessage.Define<string>(
LogLevel.Error,
new EventId(4, nameof(LogFailedToDeleteCachedHttpResourceDirectory)),
"Failed to delete cached HTTP resource directory '{EntryDirectory}'.");
private readonly string _cacheDirectory;
private readonly TimeSpan _defaultTimeToLive;
private readonly MEL.ILogger _logger;
public FileSystemHttpResourceCacheStore(string cacheDirectory, TimeSpan defaultTimeToLive, MEL.ILogger logger)
{
ArgumentException.ThrowIfNullOrWhiteSpace(cacheDirectory);
ArgumentNullException.ThrowIfNull(logger);
_cacheDirectory = cacheDirectory;
_defaultTimeToLive = defaultTimeToLive;
_logger = logger;
Directory.CreateDirectory(_cacheDirectory);
}
public CachedHttpResourceEntry GetEntry(Uri resourceUri, string? fileNameHint = null)
{
ArgumentNullException.ThrowIfNull(resourceUri);
var entryDirectory = GetEntryDirectory(resourceUri);
Directory.CreateDirectory(entryDirectory);
var metadataPath = Path.Combine(entryDirectory, MetadataFileName);
var metadata = TryLoadMetadata(metadataPath);
var payloadFileName = ResolvePayloadFileName(resourceUri, fileNameHint, metadata);
var payloadPath = Path.Combine(entryDirectory, payloadFileName);
return new CachedHttpResourceEntry(
resourceUri,
entryDirectory,
metadataPath,
payloadPath,
payloadFileName,
metadata);
}
public CachedHttpResource? TryGetFresh(CachedHttpResourceEntry entry, TimeSpan? timeToLiveOverride)
{
ArgumentNullException.ThrowIfNull(entry);
if (!File.Exists(entry.PayloadPath) || !IsFresh(entry.Metadata, timeToLiveOverride))
{
return null;
}
return CreateCachedResource(entry.PayloadPath, entry.Metadata, fromCache: true, wasRevalidated: false);
}
public CachedHttpResource? TryGetCached(CachedHttpResourceEntry entry, bool fromCache, bool wasRevalidated)
{
ArgumentNullException.ThrowIfNull(entry);
if (!File.Exists(entry.PayloadPath))
{
return null;
}
return CreateCachedResource(entry.PayloadPath, entry.Metadata, fromCache, wasRevalidated);
}
public CachedHttpResource? UpdateAfterNotModified(CachedHttpResourceEntry entry, HttpResponseMessage response)
{
ArgumentNullException.ThrowIfNull(entry);
ArgumentNullException.ThrowIfNull(response);
if (!File.Exists(entry.PayloadPath))
{
return null;
}
var refreshedMetadata = UpdateMetadata(entry.Metadata, entry.ResourceUri, response, entry.PayloadFileName, DateTimeOffset.UtcNow);
TrySaveMetadata(entry.MetadataPath, refreshedMetadata);
return CreateCachedResource(entry.PayloadPath, refreshedMetadata, fromCache: true, wasRevalidated: true);
}
public async Task<CachedHttpResource> SaveResponseAsync(CachedHttpResourceEntry entry, HttpResponseMessage response, CancellationToken cancellationToken)
{
ArgumentNullException.ThrowIfNull(entry);
ArgumentNullException.ThrowIfNull(response);
ArgumentNullException.ThrowIfNull(response.Content);
var tempPath = Path.Combine(entry.EntryDirectory, $"{entry.PayloadFileName}.tmp");
try
{
await using var sourceStream = await response.Content.ReadAsStreamAsync(cancellationToken);
await using (var destinationStream = new FileStream(tempPath, FileMode.Create, FileAccess.Write, FileShare.None, 81920, FileOptions.Asynchronous))
{
await sourceStream.CopyToAsync(destinationStream, cancellationToken);
}
File.Move(tempPath, entry.PayloadPath, overwrite: true);
}
finally
{
if (File.Exists(tempPath))
{
File.Delete(tempPath);
}
}
var updatedMetadata = UpdateMetadata(entry.Metadata, entry.ResourceUri, response, entry.PayloadFileName, DateTimeOffset.UtcNow);
TrySaveMetadata(entry.MetadataPath, updatedMetadata);
return CreateCachedResource(entry.PayloadPath, updatedMetadata, fromCache: false, wasRevalidated: entry.Metadata is not null);
}
public void Prune(IEnumerable<Uri> retainedResourceUris)
{
ArgumentNullException.ThrowIfNull(retainedResourceUris);
HashSet<string> retainedEntryDirectories = new(StringComparer.OrdinalIgnoreCase);
foreach (var retainedResourceUri in retainedResourceUris)
{
if (!IsSupportedHttpUri(retainedResourceUri))
{
continue;
}
retainedEntryDirectories.Add(Path.GetFullPath(GetEntryDirectory(retainedResourceUri)));
}
try
{
foreach (var entryDirectory in Directory.EnumerateDirectories(_cacheDirectory))
{
var fullEntryDirectory = Path.GetFullPath(entryDirectory);
if (retainedEntryDirectories.Contains(fullEntryDirectory))
{
continue;
}
TryDeleteEntryDirectory(fullEntryDirectory);
}
}
catch (Exception ex) when (ex is IOException or UnauthorizedAccessException or DirectoryNotFoundException)
{
LogFailedToEnumerateHttpResourceCache(_logger, _cacheDirectory, ex);
}
}
private bool IsFresh(HttpResourceCacheMetadata? metadata, TimeSpan? timeToLiveOverride)
{
if (metadata is null)
{
return false;
}
var now = DateTimeOffset.UtcNow;
if (metadata.ExpiresUtc is { } expiresUtc)
{
return expiresUtc > now;
}
var effectiveTimeToLive = timeToLiveOverride ?? _defaultTimeToLive;
return metadata.LastValidatedUtc + effectiveTimeToLive > now;
}
private static CachedHttpResource CreateCachedResource(
string payloadPath,
HttpResourceCacheMetadata? metadata,
bool fromCache,
bool wasRevalidated)
{
return new CachedHttpResource(
payloadPath,
metadata?.ContentType,
fromCache,
wasRevalidated);
}
private static HttpResourceCacheMetadata UpdateMetadata(
HttpResourceCacheMetadata? metadata,
Uri resourceUri,
HttpResponseMessage response,
string payloadFileName,
DateTimeOffset now)
{
return new HttpResourceCacheMetadata
{
ContentType = response.Content?.Headers.ContentType?.MediaType ?? metadata?.ContentType,
ETag = response.Headers.ETag?.ToString() ?? metadata?.ETag,
ExpiresUtc = GetExpirationUtc(response, now),
FileName = payloadFileName,
LastModifiedUtc = response.Content?.Headers.LastModified ?? metadata?.LastModifiedUtc,
LastValidatedUtc = now,
SourceUri = resourceUri.AbsoluteUri,
};
}
private static DateTimeOffset? GetExpirationUtc(HttpResponseMessage response, DateTimeOffset now)
{
if (response.Headers.CacheControl?.MaxAge is { } maxAge)
{
return now + maxAge;
}
return response.Content?.Headers.Expires;
}
private string GetEntryDirectory(Uri resourceUri)
{
var normalizedResourceName = BuildEntryName(resourceUri);
if (normalizedResourceName.Length > 48)
{
normalizedResourceName = normalizedResourceName[..48];
}
var hash = Convert.ToHexString(SHA256.HashData(Encoding.UTF8.GetBytes(resourceUri.AbsoluteUri)));
return Path.Combine(_cacheDirectory, $"{normalizedResourceName}_{hash}");
}
private static string BuildEntryName(Uri resourceUri)
{
var host = SanitizeFileName(resourceUri.Host);
var fileName = SanitizeFileName(Path.GetFileName(Uri.UnescapeDataString(resourceUri.AbsolutePath)));
if (string.IsNullOrWhiteSpace(fileName))
{
fileName = DefaultPayloadFileName;
}
if (string.IsNullOrWhiteSpace(host))
{
return fileName;
}
return $"{host}_{fileName}";
}
private static string ResolvePayloadFileName(Uri resourceUri, string? fileNameHint, HttpResourceCacheMetadata? metadata)
{
var candidate = metadata?.FileName;
if (!string.IsNullOrWhiteSpace(candidate))
{
return candidate;
}
candidate = fileNameHint;
if (string.IsNullOrWhiteSpace(candidate))
{
candidate = Path.GetFileName(Uri.UnescapeDataString(resourceUri.AbsolutePath));
}
candidate = SanitizeFileName(candidate);
return string.IsNullOrWhiteSpace(candidate) ? DefaultPayloadFileName : candidate;
}
private static string SanitizeFileName(string? value)
{
if (string.IsNullOrWhiteSpace(value))
{
return string.Empty;
}
StringBuilder builder = new(value.Length);
for (var i = 0; i < value.Length; i++)
{
var current = value[i];
builder.Append(Path.GetInvalidFileNameChars().Contains(current) ? '_' : current);
}
return builder
.ToString()
.Trim()
.Trim('.', ' ');
}
private static bool IsSupportedHttpUri(Uri resourceUri)
{
return resourceUri.Scheme.Equals(Uri.UriSchemeHttp, StringComparison.OrdinalIgnoreCase)
|| resourceUri.Scheme.Equals(Uri.UriSchemeHttps, StringComparison.OrdinalIgnoreCase);
}
private HttpResourceCacheMetadata? TryLoadMetadata(string metadataPath)
{
try
{
if (!File.Exists(metadataPath))
{
return null;
}
var json = File.ReadAllText(metadataPath);
return JsonSerializer.Deserialize(json, HttpResourceCacheJsonContext.Default.HttpResourceCacheMetadata) as HttpResourceCacheMetadata;
}
catch (Exception ex) when (ex is IOException or JsonException)
{
LogFailedToLoadCachedMetadata(_logger, metadataPath, ex);
return null;
}
}
private void TrySaveMetadata(string metadataPath, HttpResourceCacheMetadata metadata)
{
try
{
var json = JsonSerializer.Serialize(metadata, HttpResourceCacheJsonContext.Default.HttpResourceCacheMetadata);
File.WriteAllText(metadataPath, json);
}
catch (Exception ex) when (ex is IOException or UnauthorizedAccessException)
{
LogFailedToSaveCachedMetadata(_logger, metadataPath, ex);
}
}
private void TryDeleteEntryDirectory(string entryDirectory)
{
try
{
Directory.Delete(entryDirectory, recursive: true);
}
catch (Exception ex) when (ex is IOException or UnauthorizedAccessException or DirectoryNotFoundException)
{
LogFailedToDeleteCachedHttpResourceDirectory(_logger, entryDirectory, ex);
}
}
private static void LogFailedToEnumerateHttpResourceCache(MEL.ILogger logger, string cacheDirectory, Exception exception)
{
LogFailedToEnumerateHttpResourceCacheMessage(logger, cacheDirectory, exception);
}
private static void LogFailedToLoadCachedMetadata(MEL.ILogger logger, string metadataPath, Exception exception)
{
LogFailedToLoadCachedMetadataMessage(logger, metadataPath, exception);
}
private static void LogFailedToSaveCachedMetadata(MEL.ILogger logger, string metadataPath, Exception exception)
{
LogFailedToSaveCachedMetadataMessage(logger, metadataPath, exception);
}
private static void LogFailedToDeleteCachedHttpResourceDirectory(MEL.ILogger logger, string entryDirectory, Exception exception)
{
LogFailedToDeleteCachedHttpResourceDirectoryMessage(logger, entryDirectory, exception);
}
}

View File

@@ -0,0 +1,104 @@
// 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.HttpCaching.Abstraction;
using MEL = Microsoft.Extensions.Logging;
namespace Microsoft.CmdPal.Common.Services.HttpCaching;
internal sealed partial class HttpCachingClient : IDisposable
{
private readonly HttpClient _httpClient;
private readonly IHttpResourceCacheStore _cacheStore;
private readonly HttpResourceCacheHandler _cacheHandler;
public HttpCachingClient(
string cacheDirectory,
TimeSpan defaultTimeToLive,
TimeSpan timeout,
string? userAgent,
HttpMessageHandler? innerHandler,
MEL.ILogger logger)
: this(
new FileSystemHttpResourceCacheStore(cacheDirectory, defaultTimeToLive, logger),
timeout,
userAgent,
innerHandler,
logger)
{
}
public HttpCachingClient(
IHttpResourceCacheStore cacheStore,
TimeSpan timeout,
string? userAgent,
HttpMessageHandler? innerHandler,
MEL.ILogger logger)
{
ArgumentNullException.ThrowIfNull(cacheStore);
ArgumentNullException.ThrowIfNull(logger);
_cacheStore = cacheStore;
_cacheHandler = new HttpResourceCacheHandler(cacheStore, innerHandler ?? new HttpClientHandler(), logger);
_httpClient = new HttpClient(_cacheHandler) { Timeout = timeout };
if (!string.IsNullOrWhiteSpace(userAgent))
{
_httpClient.DefaultRequestHeaders.UserAgent.ParseAdd(userAgent);
}
}
public async Task<CachedHttpFetchResult> GetResourceAsync(
Uri resourceUri,
string? fileNameHint = null,
bool forceRefresh = false,
TimeSpan? timeToLiveOverride = null,
CancellationToken cancellationToken = default)
{
ArgumentNullException.ThrowIfNull(resourceUri);
if (!IsSupportedHttpUri(resourceUri))
{
throw new InvalidOperationException($"Unsupported HTTP resource URI scheme '{resourceUri.Scheme}'.");
}
using var request = new HttpRequestMessage(HttpMethod.Get, resourceUri);
HttpResourceCacheHandler.ConfigureRequest(
request,
fileNameHint: fileNameHint,
forceRefresh: forceRefresh,
timeToLiveOverride: timeToLiveOverride);
using var response = await _httpClient.SendAsync(request, HttpCompletionOption.ResponseHeadersRead, cancellationToken);
response.EnsureSuccessStatusCode();
var cacheInfo = HttpResourceCacheHandler.GetResponseInfo(response);
if (cacheInfo.Resource is null)
{
throw new InvalidOperationException($"The HTTP cache did not produce a cached resource for '{resourceUri}'.");
}
return new CachedHttpFetchResult(cacheInfo.Resource, cacheInfo.UsedFallbackCache);
}
public void Prune(IEnumerable<Uri> retainedResourceUris)
{
ArgumentNullException.ThrowIfNull(retainedResourceUris);
List<Uri> retainedUris = [.. retainedResourceUris];
_cacheHandler.AddInflightResourceUris(retainedUris);
_cacheStore.Prune(retainedUris);
}
public void Dispose()
{
_httpClient.Dispose();
}
private static bool IsSupportedHttpUri(Uri resourceUri)
{
return resourceUri.Scheme.Equals(Uri.UriSchemeHttp, StringComparison.OrdinalIgnoreCase)
|| resourceUri.Scheme.Equals(Uri.UriSchemeHttps, StringComparison.OrdinalIgnoreCase);
}
}

View File

@@ -0,0 +1,284 @@
// 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.CodeAnalysis;
using System.Net;
using System.Net.Http.Headers;
using Microsoft.CmdPal.Common.Services.HttpCaching.Abstraction;
using Microsoft.Extensions.Logging;
using MEL = Microsoft.Extensions.Logging;
namespace Microsoft.CmdPal.Common.Services.HttpCaching;
internal sealed partial class HttpResourceCacheHandler : DelegatingHandler
{
private static readonly HttpRequestOptionsKey<HttpResourceCacheRequestOptions> RequestOptionsKey = new("CmdPal.HttpResourceCache.RequestOptions");
private static readonly HttpRequestOptionsKey<CachedHttpResponseInfo> ResponseInfoKey = new("CmdPal.HttpResourceCache.ResponseInfo");
private static readonly Action<MEL.ILogger, string, Exception?> LogFailedToCacheHttpResourceMessage = LoggerMessage.Define<string>(
LogLevel.Error,
new EventId(0, nameof(LogFailedToCacheHttpResource)),
"Failed to cache HTTP resource '{ResourceUri}'.");
private readonly IHttpResourceCacheStore _cacheStore;
private readonly MEL.ILogger _logger;
private readonly Lock _lock = new();
private readonly Dictionary<string, Task<CachedHttpResponseInfo?>> _inflightFetches = new(StringComparer.Ordinal);
public HttpResourceCacheHandler(IHttpResourceCacheStore cacheStore, HttpMessageHandler innerHandler, MEL.ILogger logger)
{
ArgumentNullException.ThrowIfNull(cacheStore);
ArgumentNullException.ThrowIfNull(innerHandler);
ArgumentNullException.ThrowIfNull(logger);
_cacheStore = cacheStore;
_logger = logger;
InnerHandler = innerHandler;
}
public static void ConfigureRequest(
HttpRequestMessage request,
string? fileNameHint = null,
bool forceRefresh = false,
TimeSpan? timeToLiveOverride = null)
{
ArgumentNullException.ThrowIfNull(request);
request.Options.Set(
RequestOptionsKey,
new HttpResourceCacheRequestOptions(fileNameHint, forceRefresh, timeToLiveOverride));
}
public static CachedHttpResponseInfo GetResponseInfo(HttpResponseMessage response)
{
ArgumentNullException.ThrowIfNull(response);
return response.RequestMessage?.Options.TryGetValue(ResponseInfoKey, out var responseInfo) == true
? responseInfo
: CachedHttpResponseInfo.None;
}
public static bool TryGetResponseInfo(HttpResponseMessage response, [NotNullWhen(true)] out CachedHttpResponseInfo? responseInfo)
{
ArgumentNullException.ThrowIfNull(response);
if (response.RequestMessage?.Options.TryGetValue(ResponseInfoKey, out responseInfo) == true)
{
return true;
}
responseInfo = null;
return false;
}
internal void AddInflightResourceUris(ICollection<Uri> retainedResourceUris)
{
ArgumentNullException.ThrowIfNull(retainedResourceUris);
lock (_lock)
{
foreach (var inflightKey in _inflightFetches.Keys)
{
if (!Uri.TryCreate(inflightKey, UriKind.Absolute, out var inflightUri))
{
continue;
}
retainedResourceUris.Add(inflightUri);
}
}
}
protected async override Task<HttpResponseMessage> SendAsync(HttpRequestMessage request, CancellationToken cancellationToken)
{
ArgumentNullException.ThrowIfNull(request);
if (!CanCache(request))
{
return await base.SendAsync(request, cancellationToken);
}
var options = request.Options.TryGetValue(RequestOptionsKey, out var requestOptions)
? requestOptions
: HttpResourceCacheRequestOptions.Default;
var fetchResult = await GetOrFetchAsync(request, options, cancellationToken);
if (fetchResult?.Resource is null)
{
throw new HttpRequestException($"Could not reach HTTP resource '{request.RequestUri}'.");
}
return CreateResponse(request, fetchResult);
}
private Task<CachedHttpResponseInfo?> GetOrFetchAsync(
HttpRequestMessage request,
HttpResourceCacheRequestOptions options,
CancellationToken cancellationToken)
{
var inflightKey = request.RequestUri!.AbsoluteUri;
lock (_lock)
{
if (_inflightFetches.TryGetValue(inflightKey, out var existingTask))
{
return existingTask;
}
var fetchTask = GetOrFetchCoreAsync(request, options, cancellationToken);
_inflightFetches[inflightKey] = fetchTask;
_ = fetchTask.ContinueWith(
_ =>
{
lock (_lock)
{
_inflightFetches.Remove(inflightKey);
}
},
CancellationToken.None,
TaskContinuationOptions.None,
TaskScheduler.Default);
return fetchTask;
}
}
private async Task<CachedHttpResponseInfo?> GetOrFetchCoreAsync(
HttpRequestMessage request,
HttpResourceCacheRequestOptions options,
CancellationToken cancellationToken)
{
var entry = _cacheStore.GetEntry(request.RequestUri!, options.FileNameHint);
if (!options.ForceRefresh && _cacheStore.TryGetFresh(entry, options.TimeToLiveOverride) is { } freshResource)
{
return new CachedHttpResponseInfo(freshResource, usedFallbackCache: false);
}
try
{
using var networkRequest = CloneRequest(request, entry.Metadata);
using var response = await base.SendAsync(networkRequest, cancellationToken);
if (response.StatusCode == HttpStatusCode.NotModified)
{
var revalidatedResource = _cacheStore.UpdateAfterNotModified(entry, response);
return revalidatedResource is null
? null
: new CachedHttpResponseInfo(revalidatedResource, usedFallbackCache: false);
}
response.EnsureSuccessStatusCode();
var cachedResource = await _cacheStore.SaveResponseAsync(entry, response, cancellationToken);
return new CachedHttpResponseInfo(cachedResource, usedFallbackCache: false);
}
catch (OperationCanceledException)
{
throw;
}
catch (HttpRequestException ex)
{
LogFailedToCacheHttpResource(_logger, request.RequestUri!.AbsoluteUri, ex);
var cachedResource = _cacheStore.TryGetCached(entry, fromCache: true, wasRevalidated: false);
if (cachedResource is not null)
{
return new CachedHttpResponseInfo(cachedResource, usedFallbackCache: true);
}
throw;
}
catch (IOException ex)
{
LogFailedToCacheHttpResource(_logger, request.RequestUri!.AbsoluteUri, ex);
var cachedResource = _cacheStore.TryGetCached(entry, fromCache: true, wasRevalidated: false);
if (cachedResource is not null)
{
return new CachedHttpResponseInfo(cachedResource, usedFallbackCache: true);
}
throw new HttpRequestException($"Could not reach HTTP resource '{request.RequestUri}'.", ex);
}
}
private static HttpResponseMessage CreateResponse(HttpRequestMessage request, CachedHttpResponseInfo responseInfo)
{
var contentStream = new FileStream(
responseInfo.Resource!.ContentPath,
FileMode.Open,
FileAccess.Read,
FileShare.Read,
81920,
FileOptions.Asynchronous | FileOptions.SequentialScan);
var response = new HttpResponseMessage(HttpStatusCode.OK)
{
Content = new StreamContent(contentStream),
RequestMessage = request,
};
if (!string.IsNullOrWhiteSpace(responseInfo.Resource.ContentType))
{
response.Content.Headers.ContentType = MediaTypeHeaderValue.Parse(responseInfo.Resource.ContentType);
}
request.Options.Set(ResponseInfoKey, responseInfo);
return response;
}
private static HttpRequestMessage CloneRequest(HttpRequestMessage request, HttpResourceCacheMetadata? metadata)
{
var clone = new HttpRequestMessage(request.Method, request.RequestUri)
{
Version = request.Version,
VersionPolicy = request.VersionPolicy,
};
foreach (var header in request.Headers)
{
clone.Headers.TryAddWithoutValidation(header.Key, header.Value);
}
if (!string.IsNullOrWhiteSpace(metadata?.ETag) && !clone.Headers.Contains("If-None-Match"))
{
clone.Headers.TryAddWithoutValidation("If-None-Match", metadata.ETag);
}
if (metadata?.LastModifiedUtc is { } lastModifiedUtc && clone.Headers.IfModifiedSince is null)
{
clone.Headers.IfModifiedSince = lastModifiedUtc;
}
return clone;
}
private static bool CanCache(HttpRequestMessage request)
{
return request.Method == HttpMethod.Get
&& request.RequestUri is { } requestUri
&& IsSupportedHttpUri(requestUri);
}
private static bool IsSupportedHttpUri(Uri resourceUri)
{
return resourceUri.Scheme.Equals(Uri.UriSchemeHttp, StringComparison.OrdinalIgnoreCase)
|| resourceUri.Scheme.Equals(Uri.UriSchemeHttps, StringComparison.OrdinalIgnoreCase);
}
private static void LogFailedToCacheHttpResource(MEL.ILogger logger, string resourceUri, Exception exception)
{
LogFailedToCacheHttpResourceMessage(logger, resourceUri, exception);
}
private sealed record HttpResourceCacheRequestOptions(string? FileNameHint, bool ForceRefresh, TimeSpan? TimeToLiveOverride)
{
public static HttpResourceCacheRequestOptions Default { get; } = new(FileNameHint: null, ForceRefresh: false, TimeToLiveOverride: null);
}
internal sealed class CachedHttpResponseInfo(CachedHttpResource? resource, bool usedFallbackCache)
{
public static CachedHttpResponseInfo None { get; } = new(resource: null, usedFallbackCache: false);
public CachedHttpResource? Resource { get; } = resource;
public bool UsedFallbackCache { get; } = usedFallbackCache;
}
}

View File

@@ -0,0 +1,13 @@
// 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.Text.Json.Serialization;
using Microsoft.CmdPal.Common.Services.HttpCaching.Abstraction;
namespace Microsoft.CmdPal.Common.Services.HttpCaching;
[JsonSerializable(typeof(HttpResourceCacheMetadata))]
internal sealed partial class HttpResourceCacheJsonContext : JsonSerializerContext
{
}

View File

@@ -29,6 +29,12 @@ public interface IApplicationInfoService
/// </summary>
string ConfigDirectory { get; }
/// <summary>
/// Gets the directory path where application cache files are stored.
/// This location should be safe to recreate and should not be used for durable settings.
/// </summary>
string CacheDirectory { get; }
/// <summary>
/// Gets a value indicating whether the application is running with administrator privileges.
/// </summary>

View File

@@ -8,21 +8,61 @@ namespace Microsoft.CmdPal.Common.Services;
public interface IExtensionService
{
/// <summary>
/// Gets the currently cached installed Command Palette extensions.
/// </summary>
/// <param name="includeDisabledExtensions">True to include disabled extensions in the result.</param>
/// <returns>A sequence of installed Command Palette extensions from the current in-memory cache.</returns>
Task<IEnumerable<IExtensionWrapper>> GetInstalledExtensionsAsync(bool includeDisabledExtensions = false);
/// <summary>
/// Forces a fresh scan of installed Command Palette extensions and updates the in-memory cache.
/// </summary>
/// <param name="includeDisabledExtensions">True to include disabled extensions in the result.</param>
/// <returns>A sequence of installed Command Palette extensions after the cache has been rebuilt.</returns>
Task<IEnumerable<IExtensionWrapper>> RefreshInstalledExtensionsAsync(bool includeDisabledExtensions = false);
// Task<IEnumerable<string>> GetInstalledHomeWidgetPackageFamilyNamesAsync(bool includeDisabledExtensions = false);
/// <summary>
/// Gets the installed Command Palette extensions for a specific provider type.
/// </summary>
/// <param name="providerType">The provider type to match.</param>
/// <param name="includeDisabledExtensions">True to include disabled extensions in the result.</param>
/// <returns>A sequence of installed Command Palette extensions for the requested provider type.</returns>
Task<IEnumerable<IExtensionWrapper>> GetInstalledExtensionsAsync(Microsoft.CommandPalette.Extensions.ProviderType providerType, bool includeDisabledExtensions = false);
/// <summary>
/// Gets a cached installed extension by its unique id.
/// </summary>
/// <param name="extensionUniqueId">The unique id of the extension to look up.</param>
/// <returns>The cached extension if found; otherwise, null.</returns>
IExtensionWrapper? GetInstalledExtension(string extensionUniqueId);
/// <summary>
/// Signals running extensions to stop.
/// </summary>
Task SignalStopExtensionsAsync();
/// <summary>
/// Raised when one or more extensions are added to the installed set.
/// </summary>
event TypedEventHandler<IExtensionService, IEnumerable<IExtensionWrapper>>? OnExtensionAdded;
/// <summary>
/// Raised when one or more extensions are removed from the installed set.
/// </summary>
event TypedEventHandler<IExtensionService, IEnumerable<IExtensionWrapper>>? OnExtensionRemoved;
/// <summary>
/// Enables an installed extension by unique id.
/// </summary>
/// <param name="extensionUniqueId">The unique id of the extension to enable.</param>
void EnableExtension(string extensionUniqueId);
/// <summary>
/// Disables an installed extension by unique id.
/// </summary>
/// <param name="extensionUniqueId">The unique id of the extension to disable.</param>
void DisableExtension(string extensionUniqueId);
///// <summary>

View File

@@ -6,6 +6,8 @@ namespace Microsoft.CmdPal.Common.Services;
public interface IRunHistoryService
{
long RunCommand(string commandLine, string workingDir, bool asAdmin, ulong hwnd);
/// <summary>
/// Gets the run history.
/// </summary>
@@ -22,6 +24,13 @@ public interface IRunHistoryService
/// </summary>
/// <param name="item">The run history item to add.</param>
void AddRunHistoryItem(string item);
/// <summary>
/// Parses a command line into its components.
/// </summary>
ParseCommandlineResult ParseCommandline(string commandLine, string workingDirectory);
string QualifyCommandLineDirectory(string commandLine, string fullFilePath, string defaultDirectory);
}
public interface ITelemetryService
@@ -31,4 +40,16 @@ public interface ITelemetryService
void LogRunCommand(string command, bool asAdmin, bool success);
void LogOpenUri(string uri, bool isWeb, bool success);
void LogEvent(string eventName, IDictionary<string, object>? properties = null);
}
public struct ParseCommandlineResult
{
public int Result; // HRESULT
public bool IsUri;
public string FilePath;
public string Arguments;
public bool Success => Result == 0;
}

View File

@@ -1,4 +1,4 @@
// Copyright (c) Microsoft Corporation
// 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.
@@ -22,8 +22,7 @@ public sealed class ErrorReportSanitizer
private static IEnumerable<ISanitizationRuleProvider> BuildProviders()
{
// Order matters
return
[
return (ISanitizationRuleProvider[])[
new PiiRuleProvider(),
new UrlRuleProvider(),
new NetworkRuleProvider(),

View File

@@ -1,26 +1,25 @@
// Copyright (c) Microsoft Corporation
// 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.Collections.Generic;
using WindowsPackageManager.Interop;
namespace Microsoft.CmdPal.Ext.WinGet.WindowsPackageManager.Interop;
namespace Microsoft.CmdPal.Common.WinGet.Interop;
#nullable disable
internal sealed class ClassModel
{
/// <summary>
/// Gets the interface for the projected class type generated by CsWinRT
/// </summary>
public Type InterfaceType { get; init; }
/// <summary>
/// Gets the projected class type generated by CsWinRT
/// </summary>
public Type ProjectedClassType { get; init; }
/// <summary>
/// Gets the interface IID for the projected class.
/// </summary>
public Guid InterfaceId { get; init; }
/// <summary>
/// Gets the Clsids for each context (e.g. OutOfProcProd, OutOfProcDev)
/// </summary>
@@ -43,5 +42,5 @@ internal sealed class ClassModel
/// Get IID corresponding to the COM object
/// </summary>
/// <returns>IID.</returns>
public Guid GetIid() => InterfaceType.GUID;
public Guid GetIid() => InterfaceId;
}

View File

@@ -1,13 +1,12 @@
// Copyright (c) Microsoft Corporation
// 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.Collections.Generic;
using Microsoft.CmdPal.Ext.WinGet.WindowsPackageManager.Interop;
using Microsoft.Management.Deployment;
namespace WindowsPackageManager.Interop;
namespace Microsoft.CmdPal.Common.WinGet.Interop;
internal static class ClassesDefinition
{
@@ -16,7 +15,7 @@ internal static class ClassesDefinition
[typeof(PackageManager)] = new()
{
ProjectedClassType = typeof(PackageManager),
InterfaceType = typeof(IPackageManager),
InterfaceId = new Guid("B375E3B9-F2E0-5C93-87A7-B67497F7E593"),
Clsids = new Dictionary<ClsidContext, Guid>()
{
[ClsidContext.Prod] = new Guid("C53A4F16-787E-42A4-B304-29EFFB4BF597"),
@@ -27,7 +26,7 @@ internal static class ClassesDefinition
[typeof(FindPackagesOptions)] = new()
{
ProjectedClassType = typeof(FindPackagesOptions),
InterfaceType = typeof(IFindPackagesOptions),
InterfaceId = new Guid("A5270EDD-7DA7-57A3-BACE-F2593553561F"),
Clsids = new Dictionary<ClsidContext, Guid>()
{
[ClsidContext.Prod] = new Guid("572DED96-9C60-4526-8F92-EE7D91D38C1A"),
@@ -38,7 +37,7 @@ internal static class ClassesDefinition
[typeof(CreateCompositePackageCatalogOptions)] = new()
{
ProjectedClassType = typeof(CreateCompositePackageCatalogOptions),
InterfaceType = typeof(ICreateCompositePackageCatalogOptions),
InterfaceId = new Guid("21ABAA76-089D-51C5-A745-C85EEFE70116"),
Clsids = new Dictionary<ClsidContext, Guid>()
{
[ClsidContext.Prod] = new Guid("526534B8-7E46-47C8-8416-B1685C327D37"),
@@ -49,7 +48,7 @@ internal static class ClassesDefinition
[typeof(InstallOptions)] = new()
{
ProjectedClassType = typeof(InstallOptions),
InterfaceType = typeof(IInstallOptions),
InterfaceId = new Guid("6EE9DB69-AB48-5E72-A474-33A924CD23B3"),
Clsids = new Dictionary<ClsidContext, Guid>()
{
[ClsidContext.Prod] = new Guid("1095F097-EB96-453B-B4E6-1613637F3B14"),
@@ -60,7 +59,7 @@ internal static class ClassesDefinition
[typeof(UninstallOptions)] = new()
{
ProjectedClassType = typeof(UninstallOptions),
InterfaceType = typeof(IUninstallOptions),
InterfaceId = new Guid("3EBC67F0-8339-594B-8A42-F90B69D02BBE"),
Clsids = new Dictionary<ClsidContext, Guid>()
{
[ClsidContext.Prod] = new Guid("E1D9A11E-9F85-4D87-9C17-2B93143ADB8D"),
@@ -71,7 +70,7 @@ internal static class ClassesDefinition
[typeof(PackageMatchFilter)] = new()
{
ProjectedClassType = typeof(PackageMatchFilter),
InterfaceType = typeof(IPackageMatchFilter),
InterfaceId = new Guid("D981ECA3-4DE5-5AD7-967A-698C7D60FC3B"),
Clsids = new Dictionary<ClsidContext, Guid>()
{
[ClsidContext.Prod] = new Guid("D02C9DAF-99DC-429C-B503-4E504E4AB000"),

View File

@@ -1,8 +1,8 @@
// Copyright (c) Microsoft Corporation
// 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 WindowsPackageManager.Interop;
namespace Microsoft.CmdPal.Common.WinGet.Interop;
public enum ClsidContext
{

View File

@@ -1,11 +1,11 @@
// Copyright (c) Microsoft Corporation
// 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 Microsoft.Management.Deployment;
namespace WindowsPackageManager.Interop;
namespace Microsoft.CmdPal.Common.WinGet.Interop;
/// <summary>
/// Factory class for creating WinGet COM objects.

View File

@@ -1,4 +1,4 @@
// Copyright (c) Microsoft Corporation
// 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.
@@ -8,7 +8,7 @@ using Windows.Win32;
using Windows.Win32.System.Com;
using WinRT;
namespace WindowsPackageManager.Interop;
namespace Microsoft.CmdPal.Common.WinGet.Interop;
public class WindowsPackageManagerStandardFactory : WindowsPackageManagerFactory
{

View File

@@ -0,0 +1,9 @@
// 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.Common.WinGet.Models;
public sealed record WinGetNamedLink(
string Label,
string Url);

View File

@@ -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.
namespace Microsoft.CmdPal.Common.WinGet.Models;
public sealed record WinGetPackageDetails(
string? Name,
string? Version,
string? Summary,
string? Description,
string? Publisher,
string? PublisherUrl,
string? PublisherSupportUrl,
string? Author,
string? License,
string? LicenseUrl,
string? PackageUrl,
string? ReleaseNotes,
string? ReleaseNotesUrl,
string? IconUrl,
IReadOnlyList<WinGetNamedLink> DocumentationLinks,
IReadOnlyList<string> Tags);

View File

@@ -0,0 +1,9 @@
// 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.Common.WinGet.Models;
public sealed record WinGetPackageInfo(
WinGetPackageStatus Status,
WinGetPackageDetails? Details);

View File

@@ -0,0 +1,27 @@
// 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.Common.WinGet.Models;
public sealed record WinGetPackageOperation(
Guid OperationId,
string PackageId,
string PackageName,
WinGetPackageOperationKind Kind,
WinGetPackageOperationState State,
bool CanCancel,
bool IsIndeterminate,
uint? ProgressPercent,
ulong? BytesDownloaded,
ulong? BytesRequired,
string? ErrorMessage,
DateTimeOffset StartedAt,
DateTimeOffset UpdatedAt,
DateTimeOffset? CompletedAt)
{
public bool IsCompleted =>
State is WinGetPackageOperationState.Succeeded
or WinGetPackageOperationState.Failed
or WinGetPackageOperationState.Canceled;
}

View File

@@ -0,0 +1,11 @@
// 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.Common.WinGet.Models;
public enum WinGetPackageOperationKind
{
Install = 0,
Uninstall = 1,
}

View File

@@ -0,0 +1,10 @@
// 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.Common.WinGet.Models;
public sealed record WinGetPackageOperationResult(
bool Succeeded,
bool IsUnavailable,
string? ErrorMessage);

View File

@@ -0,0 +1,17 @@
// 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.Common.WinGet.Models;
public enum WinGetPackageOperationState
{
Queued = 0,
Downloading = 1,
Installing = 2,
Uninstalling = 3,
PostProcessing = 4,
Succeeded = 5,
Failed = 6,
Canceled = 7,
}

View File

@@ -0,0 +1,11 @@
// 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.Common.WinGet.Models;
public sealed record WinGetPackageStatus(
bool IsInstalled,
bool IsInstalledStateKnown,
bool IsUpdateAvailable,
bool IsUpdateStateKnown);

View File

@@ -0,0 +1,14 @@
// 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.Common.WinGet.Models;
[System.Diagnostics.CodeAnalysis.SuppressMessage("StyleCop.CSharp.MaintainabilityRules", "SA1649:File name should match first type name", Justification = "Generic result type.")]
public sealed record WinGetQueryResult<T>(
T? Value,
bool IsUnavailable,
string? ErrorMessage)
{
public bool IsSuccess => string.IsNullOrWhiteSpace(ErrorMessage);
}

View File

@@ -0,0 +1,9 @@
// 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.Common.WinGet.Models;
public sealed record WinGetServiceState(
bool IsAvailable,
string? Message);

View File

@@ -0,0 +1,44 @@
// 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.WinGet.Models;
namespace Microsoft.CmdPal.Common.WinGet.Services;
public interface IWinGetOperationTrackerService
{
/// <summary>
/// Gets the current and recently completed WinGet operations started by Command Palette.
/// </summary>
IReadOnlyList<WinGetPackageOperation> Operations { get; }
/// <summary>
/// Raised when a new tracked WinGet operation starts.
/// </summary>
event EventHandler<WinGetPackageOperationEventArgs>? OperationStarted;
/// <summary>
/// Raised when a tracked WinGet operation reports new progress.
/// </summary>
event EventHandler<WinGetPackageOperationEventArgs>? OperationUpdated;
/// <summary>
/// Raised when a tracked WinGet operation completes.
/// </summary>
event EventHandler<WinGetPackageOperationEventArgs>? OperationCompleted;
/// <summary>
/// Gets the newest tracked operation for a WinGet package id.
/// </summary>
/// <param name="packageId">The WinGet package id.</param>
/// <returns>The newest tracked operation for the package, or null when none is tracked.</returns>
WinGetPackageOperation? GetLatestOperation(string packageId);
/// <summary>
/// Requests cancellation for a tracked WinGet operation when supported.
/// </summary>
/// <param name="operationId">The tracked operation id.</param>
/// <returns>True when a cancellation request was issued; otherwise, false.</returns>
bool TryCancelOperation(Guid operationId);
}

View File

@@ -0,0 +1,77 @@
// 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.WinGet.Models;
using Microsoft.Management.Deployment;
namespace Microsoft.CmdPal.Common.WinGet.Services;
public interface IWinGetPackageManagerService
{
/// <summary>
/// Gets the current WinGet availability for this machine.
/// </summary>
WinGetServiceState State { get; }
/// <summary>
/// Searches WinGet packages using the shared package manager infrastructure.
/// </summary>
/// <param name="query">The search text.</param>
/// <param name="tag">An optional package tag filter.</param>
/// <param name="includeStoreCatalog">True to include the Store catalog in the composite search.</param>
/// <param name="resultLimit">The maximum number of results to return.</param>
/// <param name="cancellationToken">A token that cancels the search.</param>
/// <returns>A query result containing matching packages or availability information.</returns>
Task<WinGetQueryResult<IReadOnlyList<CatalogPackage>>> SearchPackagesAsync(
string query,
string? tag = null,
bool includeStoreCatalog = true,
uint resultLimit = 25,
CancellationToken cancellationToken = default);
/// <summary>
/// Resolves packages by WinGet package id.
/// </summary>
/// <param name="packageIds">The package ids to resolve.</param>
/// <param name="includeStoreCatalog">True to include the Store catalog in the lookup.</param>
/// <param name="cancellationToken">A token that cancels the lookup.</param>
/// <returns>A query result containing the resolved packages keyed by package id.</returns>
Task<WinGetQueryResult<IReadOnlyDictionary<string, CatalogPackage>>> GetPackagesByIdAsync(
IEnumerable<string> packageIds,
bool includeStoreCatalog = false,
CancellationToken cancellationToken = default);
/// <summary>
/// Installs or updates the provided package and refreshes package catalogs when possible.
/// </summary>
/// <param name="package">The package to install or update.</param>
/// <param name="skipDependencies">True to skip dependent packages when supported.</param>
/// <param name="progressHandler">An optional callback that receives install progress updates.</param>
/// <param name="cancellationToken">A token that cancels the install or update.</param>
/// <returns>The final result of the install or update operation.</returns>
Task<WinGetPackageOperationResult> InstallPackageAsync(
CatalogPackage package,
bool skipDependencies = false,
Action<InstallProgress>? progressHandler = null,
CancellationToken cancellationToken = default);
/// <summary>
/// Uninstalls the provided package and refreshes package catalogs when possible.
/// </summary>
/// <param name="package">The package to uninstall.</param>
/// <param name="progressHandler">An optional callback that receives uninstall progress updates.</param>
/// <param name="cancellationToken">A token that cancels the uninstall.</param>
/// <returns>The final result of the uninstall operation.</returns>
Task<WinGetPackageOperationResult> UninstallPackageAsync(
CatalogPackage package,
Action<UninstallProgress>? progressHandler = null,
CancellationToken cancellationToken = default);
/// <summary>
/// Refreshes WinGet package catalogs when supported and clears cached composite catalogs.
/// </summary>
/// <param name="cancellationToken">A token that cancels the refresh.</param>
/// <returns>True when catalog refresh was attempted successfully; otherwise, false.</returns>
Task<bool> RefreshCatalogsAsync(CancellationToken cancellationToken = default);
}

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 Microsoft.CmdPal.Common.WinGet.Models;
namespace Microsoft.CmdPal.Common.WinGet.Services;
public interface IWinGetPackageStatusService
{
/// <summary>
/// Tries to resolve WinGet package information for the provided package ids.
/// Returns null when WinGet lookups are unavailable.
/// </summary>
/// <param name="packageIds">The WinGet package ids to resolve.</param>
/// <param name="cancellationToken">A token that cancels the lookup.</param>
/// <returns>A package-info map keyed by package id, or null when status lookups are unavailable.</returns>
Task<IReadOnlyDictionary<string, WinGetPackageInfo>?> TryGetPackageInfosAsync(
IEnumerable<string> packageIds,
CancellationToken cancellationToken = default);
/// <summary>
/// Tries to resolve WinGet install/update status for the provided package ids.
/// Returns null when status detection is unavailable.
/// </summary>
/// <param name="packageIds">The WinGet package ids to inspect.</param>
/// <param name="cancellationToken">A token that cancels the lookup.</param>
/// <returns>A package-status map keyed by package id, or null when status detection is unavailable.</returns>
Task<IReadOnlyDictionary<string, WinGetPackageStatus>?> TryGetPackageStatusesAsync(
IEnumerable<string> packageIds,
CancellationToken cancellationToken = default);
}

View File

@@ -0,0 +1,248 @@
// 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.WinGet.Models;
namespace Microsoft.CmdPal.Common.WinGet.Services;
public sealed class WinGetOperationTrackerService : IWinGetOperationTrackerService
{
private const int MaxTrackedOperations = 100;
private static readonly StringComparer OrdinalIgnoreCase = StringComparer.OrdinalIgnoreCase;
private readonly Lock _operationsLock = new();
private readonly List<WinGetPackageOperation> _operations = [];
private readonly Dictionary<Guid, Action> _cancelCallbacks = [];
public event EventHandler<WinGetPackageOperationEventArgs>? OperationStarted;
public event EventHandler<WinGetPackageOperationEventArgs>? OperationUpdated;
public event EventHandler<WinGetPackageOperationEventArgs>? OperationCompleted;
public IReadOnlyList<WinGetPackageOperation> Operations
{
get
{
lock (_operationsLock)
{
return _operations.ToArray();
}
}
}
public WinGetPackageOperation? GetLatestOperation(string packageId)
{
if (string.IsNullOrWhiteSpace(packageId))
{
return null;
}
lock (_operationsLock)
{
for (var i = 0; i < _operations.Count; i++)
{
if (OrdinalIgnoreCase.Equals(_operations[i].PackageId, packageId))
{
return _operations[i];
}
}
}
return null;
}
internal WinGetPackageOperation StartOperation(string packageId, string packageName, WinGetPackageOperationKind kind)
{
var now = DateTimeOffset.UtcNow;
var operation = new WinGetPackageOperation(
OperationId: Guid.NewGuid(),
PackageId: packageId,
PackageName: packageName,
Kind: kind,
State: WinGetPackageOperationState.Queued,
CanCancel: false,
IsIndeterminate: true,
ProgressPercent: null,
BytesDownloaded: null,
BytesRequired: null,
ErrorMessage: null,
StartedAt: now,
UpdatedAt: now,
CompletedAt: null);
lock (_operationsLock)
{
_operations.Insert(0, operation);
TrimCompletedOperationsNoLock();
}
OperationStarted?.Invoke(this, new WinGetPackageOperationEventArgs(operation));
return operation;
}
public bool TryCancelOperation(Guid operationId)
{
Action? cancelCallback = null;
WinGetPackageOperation? updated = null;
lock (_operationsLock)
{
var index = FindOperationIndexNoLock(operationId);
if (index < 0 || _operations[index].IsCompleted || !_cancelCallbacks.Remove(operationId, out cancelCallback) || cancelCallback is null)
{
return false;
}
updated = _operations[index] with
{
CanCancel = false,
UpdatedAt = DateTimeOffset.UtcNow,
};
_operations[index] = updated;
}
OperationUpdated?.Invoke(this, new WinGetPackageOperationEventArgs(updated));
try
{
cancelCallback();
return true;
}
catch (Exception ex)
{
CoreLogger.LogWarning($"Failed to cancel WinGet operation '{operationId}': {ex.Message}");
return false;
}
}
internal WinGetPackageOperation? RegisterCancellationHandler(Guid operationId, Action cancelCallback)
{
ArgumentNullException.ThrowIfNull(cancelCallback);
WinGetPackageOperation? updated = null;
lock (_operationsLock)
{
var index = FindOperationIndexNoLock(operationId);
if (index < 0 || _operations[index].IsCompleted)
{
return null;
}
_cancelCallbacks[operationId] = cancelCallback;
updated = _operations[index] with
{
CanCancel = true,
UpdatedAt = DateTimeOffset.UtcNow,
};
_operations[index] = updated;
}
OperationUpdated?.Invoke(this, new WinGetPackageOperationEventArgs(updated));
return updated;
}
internal WinGetPackageOperation? UpdateOperation(
Guid operationId,
WinGetPackageOperationState state,
bool isIndeterminate,
uint? progressPercent = null,
ulong? bytesDownloaded = null,
ulong? bytesRequired = null)
{
WinGetPackageOperation? updated = null;
lock (_operationsLock)
{
var index = FindOperationIndexNoLock(operationId);
if (index < 0)
{
return null;
}
updated = _operations[index] with
{
State = state,
CanCancel = _operations[index].CanCancel,
IsIndeterminate = isIndeterminate,
ProgressPercent = progressPercent,
BytesDownloaded = bytesDownloaded,
BytesRequired = bytesRequired,
UpdatedAt = DateTimeOffset.UtcNow,
};
_operations[index] = updated;
}
OperationUpdated?.Invoke(this, new WinGetPackageOperationEventArgs(updated));
return updated;
}
internal WinGetPackageOperation? CompleteOperation(Guid operationId, WinGetPackageOperationState state, string? errorMessage = null)
{
WinGetPackageOperation? completed = null;
lock (_operationsLock)
{
var index = FindOperationIndexNoLock(operationId);
if (index < 0)
{
return null;
}
_cancelCallbacks.Remove(operationId);
var now = DateTimeOffset.UtcNow;
completed = _operations[index] with
{
State = state,
CanCancel = false,
IsIndeterminate = false,
ProgressPercent = state == WinGetPackageOperationState.Succeeded ? 100u : _operations[index].ProgressPercent,
ErrorMessage = errorMessage,
UpdatedAt = now,
CompletedAt = now,
};
_operations[index] = completed;
TrimCompletedOperationsNoLock();
}
OperationCompleted?.Invoke(this, new WinGetPackageOperationEventArgs(completed));
return completed;
}
private int FindOperationIndexNoLock(Guid operationId)
{
for (var i = 0; i < _operations.Count; i++)
{
if (_operations[i].OperationId == operationId)
{
return i;
}
}
return -1;
}
private void TrimCompletedOperationsNoLock()
{
if (_operations.Count <= MaxTrackedOperations)
{
return;
}
for (var i = _operations.Count - 1; i >= 0 && _operations.Count > MaxTrackedOperations; i--)
{
if (_operations[i].IsCompleted)
{
_operations.RemoveAt(i);
}
}
}
}

View File

@@ -0,0 +1,639 @@
// 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.Collections.ObjectModel;
using System.Runtime.InteropServices;
using Microsoft.CmdPal.Common.WinGet;
using Microsoft.CmdPal.Common.WinGet.Interop;
using Microsoft.CmdPal.Common.WinGet.Models;
using Microsoft.Management.Deployment;
using Windows.Foundation;
using Windows.Foundation.Metadata;
namespace Microsoft.CmdPal.Common.WinGet.Services;
public sealed class WinGetPackageManagerService : IWinGetPackageManagerService
{
private const string WinGetUnavailableMessage = "WinGet is unavailable. Install or repair App Installer to search and install packages.";
private const string WinGetCatalogUnavailableMessage = "WinGet couldn't connect to its package catalogs. Check App Installer and your internet connection, then try again.";
private static readonly StringComparer OrdinalIgnoreCase = StringComparer.OrdinalIgnoreCase;
private readonly Func<WindowsPackageManagerFactory?> _factoryProvider;
private readonly WinGetOperationTrackerService _operationTracker;
private readonly Lazy<InitializationState> _initialization;
private readonly object _allCatalogTaskLock = new();
private readonly object _wingetCatalogTaskLock = new();
private Task<WinGetQueryResult<PackageCatalog>>? _allCatalogTask;
private Task<WinGetQueryResult<PackageCatalog>>? _wingetCatalogTask;
public WinGetPackageManagerService()
: this(CreateFactory, new WinGetOperationTrackerService())
{
}
public WinGetPackageManagerService(WinGetOperationTrackerService operationTracker)
: this(CreateFactory, operationTracker)
{
}
internal WinGetPackageManagerService(Func<WindowsPackageManagerFactory?>? factoryProvider, WinGetOperationTrackerService? operationTracker = null)
{
_factoryProvider = factoryProvider ?? CreateFactory;
_operationTracker = operationTracker ?? new WinGetOperationTrackerService();
_initialization = new Lazy<InitializationState>(Initialize, LazyThreadSafetyMode.ExecutionAndPublication);
}
public WinGetServiceState State => _initialization.Value.State;
public async Task<WinGetQueryResult<IReadOnlyList<CatalogPackage>>> SearchPackagesAsync(
string query,
string? tag = null,
bool includeStoreCatalog = true,
uint resultLimit = 25,
CancellationToken cancellationToken = default)
{
if (string.IsNullOrWhiteSpace(query) && string.IsNullOrWhiteSpace(tag))
{
return new WinGetQueryResult<IReadOnlyList<CatalogPackage>>([], false, null);
}
var catalogResult = await GetCompositeCatalogResultAsync(includeStoreCatalog, cancellationToken).ConfigureAwait(false);
if (!catalogResult.IsSuccess || catalogResult.Value is null)
{
return new WinGetQueryResult<IReadOnlyList<CatalogPackage>>(null, catalogResult.IsUnavailable, catalogResult.ErrorMessage);
}
var initialization = _initialization.Value;
if (!initialization.State.IsAvailable || initialization.Factory is null)
{
return new WinGetQueryResult<IReadOnlyList<CatalogPackage>>(null, true, initialization.State.Message);
}
try
{
var options = initialization.Factory.CreateFindPackagesOptions();
options.ResultLimit = resultLimit;
if (!string.IsNullOrWhiteSpace(query))
{
var selector = initialization.Factory.CreatePackageMatchFilter();
selector.Field = PackageMatchField.CatalogDefault;
selector.Value = query;
selector.Option = PackageFieldMatchOption.ContainsCaseInsensitive;
options.Selectors.Add(selector);
}
if (!string.IsNullOrWhiteSpace(tag))
{
var tagFilter = initialization.Factory.CreatePackageMatchFilter();
tagFilter.Field = PackageMatchField.Tag;
tagFilter.Value = tag;
tagFilter.Option = PackageFieldMatchOption.ContainsCaseInsensitive;
options.Filters.Add(tagFilter);
}
var findResult = await Task.Run(() => catalogResult.Value.FindPackages(options), cancellationToken).ConfigureAwait(false);
if (findResult.Status != FindPackagesResultStatus.Ok)
{
return new WinGetQueryResult<IReadOnlyList<CatalogPackage>>(
null,
false,
$"WinGet search failed: {findResult.Status}");
}
Dictionary<string, CatalogPackage> results = new(OrdinalIgnoreCase);
for (var i = 0; i < findResult.Matches.Count; i++)
{
var package = findResult.Matches[i].CatalogPackage;
results.TryAdd(CreatePackageKey(package), package);
}
return new WinGetQueryResult<IReadOnlyList<CatalogPackage>>(
new ReadOnlyCollection<CatalogPackage>(results.Values.ToList()),
false,
null);
}
catch (Exception ex) when (ex is COMException or InvalidOperationException or TaskCanceledException)
{
CoreLogger.LogWarning($"WinGet search failed: {ex.Message}");
return new WinGetQueryResult<IReadOnlyList<CatalogPackage>>(null, false, ex.Message);
}
}
public async Task<WinGetQueryResult<IReadOnlyDictionary<string, CatalogPackage>>> GetPackagesByIdAsync(
IEnumerable<string> packageIds,
bool includeStoreCatalog = false,
CancellationToken cancellationToken = default)
{
var normalizedIds = NormalizePackageIds(packageIds);
if (normalizedIds.Count == 0)
{
return new WinGetQueryResult<IReadOnlyDictionary<string, CatalogPackage>>(
new Dictionary<string, CatalogPackage>(OrdinalIgnoreCase),
false,
null);
}
var catalogResult = await GetCompositeCatalogResultAsync(includeStoreCatalog, cancellationToken).ConfigureAwait(false);
if (!catalogResult.IsSuccess || catalogResult.Value is null)
{
return new WinGetQueryResult<IReadOnlyDictionary<string, CatalogPackage>>(null, catalogResult.IsUnavailable, catalogResult.ErrorMessage);
}
var initialization = _initialization.Value;
if (!initialization.State.IsAvailable || initialization.Factory is null)
{
return new WinGetQueryResult<IReadOnlyDictionary<string, CatalogPackage>>(null, true, initialization.State.Message);
}
try
{
var options = initialization.Factory.CreateFindPackagesOptions();
options.ResultLimit = (uint)normalizedIds.Count;
for (var i = 0; i < normalizedIds.Count; i++)
{
var selector = initialization.Factory.CreatePackageMatchFilter();
selector.Field = PackageMatchField.Id;
selector.Option = PackageFieldMatchOption.EqualsCaseInsensitive;
selector.Value = normalizedIds[i];
options.Selectors.Add(selector);
}
var findResult = await Task.Run(() => catalogResult.Value.FindPackages(options), cancellationToken).ConfigureAwait(false);
if (findResult.Status != FindPackagesResultStatus.Ok)
{
return new WinGetQueryResult<IReadOnlyDictionary<string, CatalogPackage>>(
null,
false,
$"WinGet package lookup failed: {findResult.Status}");
}
Dictionary<string, CatalogPackage> results = new(OrdinalIgnoreCase);
for (var i = 0; i < findResult.Matches.Count; i++)
{
var package = findResult.Matches[i].CatalogPackage;
if (!results.ContainsKey(package.Id))
{
results[package.Id] = package;
}
}
return new WinGetQueryResult<IReadOnlyDictionary<string, CatalogPackage>>(results, false, null);
}
catch (Exception ex) when (ex is COMException or InvalidOperationException or TaskCanceledException)
{
CoreLogger.LogWarning($"WinGet package lookup failed: {ex.Message}");
return new WinGetQueryResult<IReadOnlyDictionary<string, CatalogPackage>>(null, false, ex.Message);
}
}
public async Task<WinGetPackageOperationResult> InstallPackageAsync(
CatalogPackage package,
bool skipDependencies = false,
Action<InstallProgress>? progressHandler = null,
CancellationToken cancellationToken = default)
{
var trackedOperation = _operationTracker.StartOperation(
package.Id,
WinGetPackageMetadataHelper.GetPackageDisplayName(package),
WinGetPackageOperationKind.Install);
var initialization = _initialization.Value;
if (!initialization.State.IsAvailable || initialization.Factory is null || initialization.PackageManager is null)
{
_operationTracker.CompleteOperation(trackedOperation.OperationId, WinGetPackageOperationState.Failed, initialization.State.Message);
return new WinGetPackageOperationResult(false, true, initialization.State.Message);
}
try
{
cancellationToken.ThrowIfCancellationRequested();
var installOptions = initialization.Factory.CreateInstallOptions();
installOptions.PackageInstallScope = PackageInstallScope.Any;
installOptions.SkipDependencies = skipDependencies;
var operation = initialization.PackageManager.InstallPackageAsync(package, installOptions);
_operationTracker.RegisterCancellationHandler(trackedOperation.OperationId, operation.Cancel);
operation.Progress = new AsyncOperationProgressHandler<InstallResult, InstallProgress>((_, progress) =>
{
UpdateTrackedInstallOperation(trackedOperation.OperationId, progress);
progressHandler?.Invoke(progress);
});
await operation.AsTask().ConfigureAwait(false);
await RefreshCatalogsAsync(cancellationToken).ConfigureAwait(false);
_operationTracker.CompleteOperation(trackedOperation.OperationId, WinGetPackageOperationState.Succeeded);
return new WinGetPackageOperationResult(true, false, null);
}
catch (OperationCanceledException ex)
{
CoreLogger.LogWarning($"WinGet install canceled for '{package.Id}': {ex.Message}");
_operationTracker.CompleteOperation(trackedOperation.OperationId, WinGetPackageOperationState.Canceled, ex.Message);
return new WinGetPackageOperationResult(false, false, ex.Message);
}
catch (Exception ex) when (ex is COMException or InvalidOperationException or TaskCanceledException)
{
CoreLogger.LogWarning($"WinGet install failed for '{package.Id}': {ex.Message}");
_operationTracker.CompleteOperation(trackedOperation.OperationId, WinGetPackageOperationState.Failed, ex.Message);
return new WinGetPackageOperationResult(false, false, ex.Message);
}
}
public async Task<WinGetPackageOperationResult> UninstallPackageAsync(
CatalogPackage package,
Action<UninstallProgress>? progressHandler = null,
CancellationToken cancellationToken = default)
{
var trackedOperation = _operationTracker.StartOperation(
package.Id,
WinGetPackageMetadataHelper.GetPackageDisplayName(package),
WinGetPackageOperationKind.Uninstall);
var initialization = _initialization.Value;
if (!initialization.State.IsAvailable || initialization.Factory is null || initialization.PackageManager is null)
{
_operationTracker.CompleteOperation(trackedOperation.OperationId, WinGetPackageOperationState.Failed, initialization.State.Message);
return new WinGetPackageOperationResult(false, true, initialization.State.Message);
}
try
{
cancellationToken.ThrowIfCancellationRequested();
var uninstallOptions = initialization.Factory.CreateUninstallOptions();
uninstallOptions.PackageUninstallScope = PackageUninstallScope.Any;
var operation = initialization.PackageManager.UninstallPackageAsync(package, uninstallOptions);
_operationTracker.RegisterCancellationHandler(trackedOperation.OperationId, operation.Cancel);
operation.Progress = new AsyncOperationProgressHandler<UninstallResult, UninstallProgress>((_, progress) =>
{
UpdateTrackedUninstallOperation(trackedOperation.OperationId, progress);
progressHandler?.Invoke(progress);
});
await operation.AsTask().ConfigureAwait(false);
await RefreshCatalogsAsync(cancellationToken).ConfigureAwait(false);
_operationTracker.CompleteOperation(trackedOperation.OperationId, WinGetPackageOperationState.Succeeded);
return new WinGetPackageOperationResult(true, false, null);
}
catch (OperationCanceledException ex)
{
CoreLogger.LogWarning($"WinGet uninstall canceled for '{package.Id}': {ex.Message}");
_operationTracker.CompleteOperation(trackedOperation.OperationId, WinGetPackageOperationState.Canceled, ex.Message);
return new WinGetPackageOperationResult(false, false, ex.Message);
}
catch (Exception ex) when (ex is COMException or InvalidOperationException or TaskCanceledException)
{
CoreLogger.LogWarning($"WinGet uninstall failed for '{package.Id}': {ex.Message}");
_operationTracker.CompleteOperation(trackedOperation.OperationId, WinGetPackageOperationState.Failed, ex.Message);
return new WinGetPackageOperationResult(false, false, ex.Message);
}
}
public async Task<bool> RefreshCatalogsAsync(CancellationToken cancellationToken = default)
{
ClearCompositeCatalogCache();
var initialization = _initialization.Value;
if (!initialization.State.IsAvailable || initialization.AvailableCatalogs.Count == 0)
{
return false;
}
if (!ApiInformation.IsApiContractPresent("Microsoft.Management.Deployment.WindowsPackageManagerContract", 12))
{
return false;
}
try
{
for (var i = 0; i < initialization.AvailableCatalogs.Count; i++)
{
cancellationToken.ThrowIfCancellationRequested();
await initialization.AvailableCatalogs[i].RefreshPackageCatalogAsync().AsTask().ConfigureAwait(false);
}
return true;
}
catch (Exception ex) when (ex is COMException or InvalidOperationException or TaskCanceledException)
{
CoreLogger.LogWarning($"WinGet catalog refresh failed: {ex.Message}");
return false;
}
}
private static WindowsPackageManagerFactory? CreateFactory()
{
try
{
return new WindowsPackageManagerStandardFactory();
}
catch (Exception ex)
{
CoreLogger.LogWarning($"Failed to initialize WinGet API factory: {ex.Message}");
return null;
}
}
private InitializationState Initialize()
{
try
{
var factory = _factoryProvider();
if (factory is null)
{
return InitializationState.Unavailable(WinGetUnavailableMessage);
}
var packageManager = factory.CreatePackageManager();
var wingetCatalog = packageManager.GetPredefinedPackageCatalog(PredefinedPackageCatalog.OpenWindowsCatalog);
List<PackageCatalogReference> availableCatalogs = [wingetCatalog];
PackageCatalogReference? storeCatalog = null;
try
{
storeCatalog = packageManager.GetPredefinedPackageCatalog(PredefinedPackageCatalog.MicrosoftStore);
availableCatalogs.Add(storeCatalog);
}
catch (Exception ex)
{
CoreLogger.LogWarning($"Failed to initialize Microsoft Store catalog: {ex.Message}");
}
if (ApiInformation.IsApiContractPresent("Microsoft.Management.Deployment.WindowsPackageManagerContract", 8))
{
foreach (var catalogReference in availableCatalogs)
{
catalogReference.PackageCatalogBackgroundUpdateInterval = new(0);
}
}
return InitializationState.Available(
factory,
packageManager,
wingetCatalog,
storeCatalog,
availableCatalogs);
}
catch (Exception ex)
{
CoreLogger.LogWarning($"Failed to initialize WinGet package manager: {ex.Message}");
return InitializationState.Unavailable(WinGetUnavailableMessage);
}
}
private async Task<WinGetQueryResult<PackageCatalog>> GetCompositeCatalogResultAsync(bool includeStoreCatalog, CancellationToken cancellationToken)
{
Task<WinGetQueryResult<PackageCatalog>> task;
if (includeStoreCatalog)
{
lock (_allCatalogTaskLock)
{
_allCatalogTask ??= CreateCompositeCatalogAsync(includeStoreCatalog, cancellationToken);
task = _allCatalogTask;
}
}
else
{
lock (_wingetCatalogTaskLock)
{
_wingetCatalogTask ??= CreateCompositeCatalogAsync(includeStoreCatalog, cancellationToken);
task = _wingetCatalogTask;
}
}
var result = await task.ConfigureAwait(false);
if (!result.IsSuccess || result.Value is null)
{
ClearCachedCompositeCatalogTask(includeStoreCatalog, task);
}
return result;
}
private async Task<WinGetQueryResult<PackageCatalog>> CreateCompositeCatalogAsync(bool includeStoreCatalog, CancellationToken cancellationToken)
{
var initialization = _initialization.Value;
if (!initialization.State.IsAvailable || initialization.Factory is null || initialization.PackageManager is null || initialization.WingetCatalog is null)
{
return new WinGetQueryResult<PackageCatalog>(null, true, initialization.State.Message);
}
try
{
cancellationToken.ThrowIfCancellationRequested();
var options = initialization.Factory.CreateCreateCompositePackageCatalogOptions();
options.CompositeSearchBehavior = CompositeSearchBehavior.RemotePackagesFromAllCatalogs;
options.Catalogs.Add(initialization.WingetCatalog);
if (includeStoreCatalog && initialization.StoreCatalog is not null)
{
options.Catalogs.Add(initialization.StoreCatalog);
}
var compositeCatalogReference = initialization.PackageManager.CreateCompositePackageCatalog(options);
var connectResult = await compositeCatalogReference.ConnectAsync().AsTask().ConfigureAwait(false);
if (connectResult.Status != ConnectResultStatus.Ok || connectResult.PackageCatalog is null)
{
var message = connectResult.Status == ConnectResultStatus.CatalogError ?
WinGetCatalogUnavailableMessage :
$"WinGet catalog connection failed: {connectResult.Status}";
CoreLogger.LogWarning(message);
return new WinGetQueryResult<PackageCatalog>(null, false, message);
}
return new WinGetQueryResult<PackageCatalog>(connectResult.PackageCatalog, false, null);
}
catch (Exception ex) when (ex is COMException or InvalidOperationException or TaskCanceledException)
{
CoreLogger.LogWarning($"Failed to create WinGet composite catalog: {ex.Message}");
return new WinGetQueryResult<PackageCatalog>(null, false, ex.Message);
}
}
private void ClearCompositeCatalogCache()
{
lock (_allCatalogTaskLock)
{
_allCatalogTask = null;
}
lock (_wingetCatalogTaskLock)
{
_wingetCatalogTask = null;
}
}
private void ClearCachedCompositeCatalogTask(bool includeStoreCatalog, Task<WinGetQueryResult<PackageCatalog>> task)
{
if (includeStoreCatalog)
{
lock (_allCatalogTaskLock)
{
if (ReferenceEquals(_allCatalogTask, task))
{
_allCatalogTask = null;
}
}
}
else
{
lock (_wingetCatalogTaskLock)
{
if (ReferenceEquals(_wingetCatalogTask, task))
{
_wingetCatalogTask = null;
}
}
}
}
private static List<string> NormalizePackageIds(IEnumerable<string> packageIds)
{
List<string> normalized = [];
HashSet<string> seen = new(OrdinalIgnoreCase);
foreach (var candidate in packageIds)
{
var trimmed = ToNullIfWhiteSpace(candidate);
if (trimmed is null || !seen.Add(trimmed))
{
continue;
}
normalized.Add(trimmed);
}
return normalized;
}
private static string CreatePackageKey(CatalogPackage package)
{
var catalogId =
TryGetCatalogId(() => package.DefaultInstallVersion?.PackageCatalog?.Info?.Id)
?? TryGetCatalogId(() => package.InstalledVersion?.PackageCatalog?.Info?.Id)
?? string.Empty;
return string.Concat(package.Id, "\u001F", catalogId);
}
private static string? TryGetCatalogId(Func<string?> getter)
{
try
{
return getter();
}
catch
{
return null;
}
}
private static string? ToNullIfWhiteSpace(string? value)
{
if (string.IsNullOrWhiteSpace(value))
{
return null;
}
return value.Trim();
}
private void UpdateTrackedInstallOperation(Guid operationId, InstallProgress progress)
{
switch (progress.State)
{
case PackageInstallProgressState.Queued:
_operationTracker.UpdateOperation(operationId, WinGetPackageOperationState.Queued, isIndeterminate: true);
break;
case PackageInstallProgressState.Downloading:
{
var progressPercent = progress.BytesRequired > 0
? (uint?)Math.Min(100, (progress.BytesDownloaded * 100UL) / progress.BytesRequired)
: null;
_operationTracker.UpdateOperation(
operationId,
WinGetPackageOperationState.Downloading,
isIndeterminate: progress.BytesRequired == 0,
progressPercent: progressPercent,
bytesDownloaded: progress.BytesDownloaded,
bytesRequired: progress.BytesRequired);
break;
}
case PackageInstallProgressState.Installing:
_operationTracker.UpdateOperation(operationId, WinGetPackageOperationState.Installing, isIndeterminate: true);
break;
case PackageInstallProgressState.PostInstall:
case PackageInstallProgressState.Finished:
_operationTracker.UpdateOperation(operationId, WinGetPackageOperationState.PostProcessing, isIndeterminate: true);
break;
default:
break;
}
}
private void UpdateTrackedUninstallOperation(Guid operationId, UninstallProgress progress)
{
switch (progress.State)
{
case PackageUninstallProgressState.Queued:
_operationTracker.UpdateOperation(operationId, WinGetPackageOperationState.Queued, isIndeterminate: true);
break;
case PackageUninstallProgressState.Uninstalling:
_operationTracker.UpdateOperation(operationId, WinGetPackageOperationState.Uninstalling, isIndeterminate: true);
break;
case PackageUninstallProgressState.PostUninstall:
case PackageUninstallProgressState.Finished:
_operationTracker.UpdateOperation(operationId, WinGetPackageOperationState.PostProcessing, isIndeterminate: true);
break;
default:
break;
}
}
private sealed record InitializationState(
WinGetServiceState State,
WindowsPackageManagerFactory? Factory,
PackageManager? PackageManager,
PackageCatalogReference? WingetCatalog,
PackageCatalogReference? StoreCatalog,
IReadOnlyList<PackageCatalogReference> AvailableCatalogs)
{
public static InitializationState Available(
WindowsPackageManagerFactory factory,
PackageManager packageManager,
PackageCatalogReference wingetCatalog,
PackageCatalogReference? storeCatalog,
IReadOnlyList<PackageCatalogReference> availableCatalogs)
{
return new InitializationState(
new WinGetServiceState(true, Message: null),
factory,
packageManager,
wingetCatalog,
storeCatalog,
availableCatalogs);
}
public static InitializationState Unavailable(string message)
{
return new InitializationState(
new WinGetServiceState(false, message),
Factory: null,
PackageManager: null,
WingetCatalog: null,
StoreCatalog: null,
AvailableCatalogs: []);
}
}
}

View File

@@ -0,0 +1,276 @@
// 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.WinGet.Models;
using Microsoft.Management.Deployment;
namespace Microsoft.CmdPal.Common.WinGet.Services;
internal static class WinGetPackageMetadataHelper
{
private static readonly StringComparer OrdinalIgnoreCase = StringComparer.OrdinalIgnoreCase;
public static async Task<WinGetPackageStatus> InspectPackageStatusAsync(CatalogPackage package)
{
try
{
await package.CheckInstalledStatusAsync();
var isInstalled = package.InstalledVersion is not null;
return new WinGetPackageStatus(
IsInstalled: isInstalled,
IsInstalledStateKnown: true,
IsUpdateAvailable: isInstalled && package.IsUpdateAvailable,
IsUpdateStateKnown: true);
}
catch (Exception ex)
{
CoreLogger.LogWarning($"Failed to inspect package status '{package.Id}': {ex.Message}");
return new WinGetPackageStatus(
IsInstalled: false,
IsInstalledStateKnown: false,
IsUpdateAvailable: false,
IsUpdateStateKnown: false);
}
}
public static WinGetPackageDetails? TryBuildPackageDetails(CatalogPackage package)
{
try
{
var defaultVersion = TryGetRef(() => package.DefaultInstallVersion);
var installedVersion = TryGetRef(() => package.InstalledVersion);
var packageVersion = defaultVersion ?? installedVersion;
var packageName = ToNullIfWhiteSpace(TryGetString(() => package.Name));
var version = packageVersion is not null ? ToNullIfWhiteSpace(TryGetString(() => packageVersion.Version)) : null;
if (packageVersion is null)
{
if (packageName is null)
{
return null;
}
return new WinGetPackageDetails(
Name: packageName,
Version: version,
Summary: null,
Description: null,
Publisher: null,
PublisherUrl: null,
PublisherSupportUrl: null,
Author: null,
License: null,
LicenseUrl: null,
PackageUrl: null,
ReleaseNotes: null,
ReleaseNotesUrl: null,
IconUrl: null,
DocumentationLinks: [],
Tags: []);
}
var metadata = TryGetRef(() => packageVersion.GetCatalogPackageMetadata());
if (metadata is null)
{
if (packageName is null && version is null)
{
return null;
}
return new WinGetPackageDetails(
Name: packageName,
Version: version,
Summary: null,
Description: null,
Publisher: null,
PublisherUrl: null,
PublisherSupportUrl: null,
Author: null,
License: null,
LicenseUrl: null,
PackageUrl: null,
ReleaseNotes: null,
ReleaseNotesUrl: null,
IconUrl: null,
DocumentationLinks: [],
Tags: []);
}
List<WinGetNamedLink> documentationLinks = [];
var docs = TryGetRef(() => metadata.Documentations);
if (docs is not null)
{
for (var i = 0; i < docs.Count; i++)
{
var doc = docs[i];
var url = ToNullIfWhiteSpace(TryGetString(() => doc.DocumentUrl));
if (url is null || !Uri.TryCreate(url, UriKind.Absolute, out _))
{
continue;
}
var label = ToNullIfWhiteSpace(TryGetString(() => doc.DocumentLabel)) ?? url;
documentationLinks.Add(new WinGetNamedLink(label, url));
}
}
List<string> tags = [];
var metadataTags = TryGetRef(() => metadata.Tags);
if (metadataTags is not null)
{
for (var i = 0; i < metadataTags.Count; i++)
{
var tag = ToNullIfWhiteSpace(metadataTags[i]);
if (tag is null || ContainsIgnoreCase(tags, tag))
{
continue;
}
tags.Add(tag);
}
}
var iconUrl = TryResolveIconUrl(metadata);
var summary = ToNullIfWhiteSpace(TryGetString(() => metadata.ShortDescription));
var description = ToNullIfWhiteSpace(TryGetString(() => metadata.Description));
var releaseNotes = ToNullIfWhiteSpace(TryGetString(() => metadata.ReleaseNotes));
if (releaseNotes is not null && releaseNotes.Length > 800)
{
releaseNotes = string.Concat(releaseNotes.AsSpan(0, 800), "...");
}
var details = new WinGetPackageDetails(
Name: ToNullIfWhiteSpace(TryGetString(() => metadata.PackageName)) ?? packageName,
Version: version,
Summary: summary,
Description: description,
Publisher: ToNullIfWhiteSpace(TryGetString(() => metadata.Publisher)),
PublisherUrl: ValidateAbsoluteUri(TryGetString(() => metadata.PublisherUrl)),
PublisherSupportUrl: ValidateAbsoluteUri(TryGetString(() => metadata.PublisherSupportUrl)),
Author: ToNullIfWhiteSpace(TryGetString(() => metadata.Author)),
License: ToNullIfWhiteSpace(TryGetString(() => metadata.License)),
LicenseUrl: ValidateAbsoluteUri(TryGetString(() => metadata.LicenseUrl)),
PackageUrl: ValidateAbsoluteUri(TryGetString(() => metadata.PackageUrl)),
ReleaseNotes: releaseNotes,
ReleaseNotesUrl: ValidateAbsoluteUri(TryGetString(() => metadata.ReleaseNotesUrl)),
IconUrl: iconUrl,
DocumentationLinks: documentationLinks,
Tags: tags);
return HasDetailsContent(details) ? details : null;
}
catch (Exception ex)
{
CoreLogger.LogWarning($"Failed to build package metadata: {ex.Message}");
return null;
}
}
public static string GetPackageDisplayName(CatalogPackage package)
{
var name = ToNullIfWhiteSpace(TryGetString(() => package.Name));
return name ?? package.Id;
}
private static bool HasDetailsContent(WinGetPackageDetails details)
{
return !string.IsNullOrWhiteSpace(details.Name)
|| !string.IsNullOrWhiteSpace(details.Version)
|| !string.IsNullOrWhiteSpace(details.Summary)
|| !string.IsNullOrWhiteSpace(details.Description)
|| !string.IsNullOrWhiteSpace(details.Publisher)
|| !string.IsNullOrWhiteSpace(details.PublisherUrl)
|| !string.IsNullOrWhiteSpace(details.PublisherSupportUrl)
|| !string.IsNullOrWhiteSpace(details.Author)
|| !string.IsNullOrWhiteSpace(details.License)
|| !string.IsNullOrWhiteSpace(details.LicenseUrl)
|| !string.IsNullOrWhiteSpace(details.PackageUrl)
|| !string.IsNullOrWhiteSpace(details.ReleaseNotes)
|| !string.IsNullOrWhiteSpace(details.ReleaseNotesUrl)
|| !string.IsNullOrWhiteSpace(details.IconUrl)
|| details.DocumentationLinks.Count > 0
|| details.Tags.Count > 0;
}
private static string? TryResolveIconUrl(CatalogPackageMetadata metadata)
{
var icons = TryGetRef(() => metadata.Icons);
if (icons is null)
{
return null;
}
for (var i = 0; i < icons.Count; i++)
{
var icon = icons[i];
var url = ValidateAbsoluteUri(TryGetString(() => icon.Url));
if (url is not null)
{
return url;
}
}
return null;
}
private static T? TryGetRef<T>(Func<T> getter)
where T : class
{
try
{
return getter();
}
catch
{
return null;
}
}
private static string? TryGetString(Func<string> getter)
{
try
{
return getter();
}
catch
{
return null;
}
}
private static string? ValidateAbsoluteUri(string? value)
{
var normalized = ToNullIfWhiteSpace(value);
if (normalized is null || !Uri.TryCreate(normalized, UriKind.Absolute, out _))
{
return null;
}
return normalized;
}
private static string? ToNullIfWhiteSpace(string? value)
{
if (string.IsNullOrWhiteSpace(value))
{
return null;
}
return value.Trim();
}
private static bool ContainsIgnoreCase(IReadOnlyList<string> values, string candidate)
{
for (var i = 0; i < values.Count; i++)
{
if (OrdinalIgnoreCase.Equals(values[i], candidate))
{
return true;
}
}
return false;
}
}

View File

@@ -0,0 +1,12 @@
// 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.WinGet.Models;
namespace Microsoft.CmdPal.Common.WinGet.Services;
public sealed class WinGetPackageOperationEventArgs(WinGetPackageOperation operation) : EventArgs
{
public WinGetPackageOperation Operation { get; } = operation;
}

View File

@@ -0,0 +1,132 @@
// 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.Runtime.InteropServices;
using Microsoft.CmdPal.Common.WinGet.Models;
using Microsoft.Management.Deployment;
namespace Microsoft.CmdPal.Common.WinGet.Services;
public sealed class WinGetPackageStatusService : IWinGetPackageStatusService
{
private static readonly StringComparer OrdinalIgnoreCase = StringComparer.OrdinalIgnoreCase;
private readonly IWinGetPackageManagerService _packageManagerService;
public WinGetPackageStatusService(IWinGetPackageManagerService packageManagerService)
{
_packageManagerService = packageManagerService;
}
public async Task<IReadOnlyDictionary<string, WinGetPackageInfo>?> TryGetPackageInfosAsync(
IEnumerable<string> packageIds,
CancellationToken cancellationToken = default)
{
var normalizedIds = NormalizePackageIds(packageIds);
if (normalizedIds.Count == 0)
{
return new Dictionary<string, WinGetPackageInfo>(OrdinalIgnoreCase);
}
return await TryGetInfosViaWinGetApiAsync(normalizedIds, _packageManagerService, cancellationToken);
}
public async Task<IReadOnlyDictionary<string, WinGetPackageStatus>?> TryGetPackageStatusesAsync(
IEnumerable<string> packageIds,
CancellationToken cancellationToken = default)
{
var infos = await TryGetPackageInfosAsync(packageIds, cancellationToken);
if (infos is null)
{
return null;
}
Dictionary<string, WinGetPackageStatus> statuses = new(OrdinalIgnoreCase);
foreach (var pair in infos)
{
statuses[pair.Key] = pair.Value.Status;
}
return statuses;
}
private static async Task<IReadOnlyDictionary<string, WinGetPackageInfo>?> TryGetInfosViaWinGetApiAsync(
IReadOnlyList<string> packageIds,
IWinGetPackageManagerService packageManagerService,
CancellationToken cancellationToken)
{
var packagesResult = await packageManagerService.GetPackagesByIdAsync(packageIds, includeStoreCatalog: false, cancellationToken);
if (!packagesResult.IsSuccess || packagesResult.Value is null)
{
return null;
}
try
{
Dictionary<string, WinGetPackageInfo> results = new(OrdinalIgnoreCase);
for (var i = 0; i < packageIds.Count; i++)
{
var packageId = packageIds[i];
var status = new WinGetPackageStatus(
IsInstalled: false,
IsInstalledStateKnown: true,
IsUpdateAvailable: false,
IsUpdateStateKnown: true);
results[packageId] = new WinGetPackageInfo(status, Details: null);
}
foreach (var package in packagesResult.Value.Values)
{
if (!results.ContainsKey(package.Id))
{
continue;
}
results[package.Id] = await InspectPackageAsync(package);
}
return results;
}
catch (Exception ex) when (ex is InvalidOperationException or COMException or TaskCanceledException)
{
CoreLogger.LogWarning($"WinGet API package info query failed: {ex.Message}");
return null;
}
}
private static async Task<WinGetPackageInfo> InspectPackageAsync(CatalogPackage package)
{
var status = await WinGetPackageMetadataHelper.InspectPackageStatusAsync(package);
var details = WinGetPackageMetadataHelper.TryBuildPackageDetails(package);
return new WinGetPackageInfo(status, details);
}
private static List<string> NormalizePackageIds(IEnumerable<string> packageIds)
{
List<string> normalized = [];
HashSet<string> seen = new(OrdinalIgnoreCase);
foreach (var candidate in packageIds)
{
var trimmed = ToNullIfWhiteSpace(candidate);
if (trimmed is null || !seen.Add(trimmed))
{
continue;
}
normalized.Add(trimmed);
}
return normalized;
}
private static string? ToNullIfWhiteSpace(string? value)
{
if (string.IsNullOrWhiteSpace(value))
{
return null;
}
return value.Trim();
}
}

View File

@@ -0,0 +1,10 @@
// 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.Common.WinGet;
public static class WinGetPackageTags
{
public const string CommandPaletteExtension = "windows-commandpalette-extension";
}

View File

@@ -26,6 +26,7 @@ public class CommandPalettePageViewModelFactory
MainListPage listPage => new ListViewModel(listPage, _scheduler, host, providerContext, _contextMenuFactory) { IsRootPage = !nested, IsMainPage = true },
IListPage listPage => new ListViewModel(listPage, _scheduler, host, providerContext, _contextMenuFactory) { IsRootPage = !nested },
IContentPage contentPage => new CommandPaletteContentPageViewModel(contentPage, _scheduler, host, providerContext),
IParametersPage paramsPage => new ParametersPageViewModel(paramsPage, _scheduler, host, providerContext, _contextMenuFactory),
_ => null,
};
}

View File

@@ -496,16 +496,37 @@ public sealed class CommandProviderWrapper : ICommandProviderContext
this.CommandsChanged?.Invoke(this, new ItemsChangedEventArgs(-1));
}
public void PinDockBand(string commandId, IServiceProvider serviceProvider, Dock.DockPinSide side = Dock.DockPinSide.Start, bool? showTitles = null, bool? showSubtitles = null)
public void PinDockBand(string commandId, IServiceProvider serviceProvider, Dock.DockPinSide side = Dock.DockPinSide.Start, bool? showTitles = null, bool? showSubtitles = null, string? monitorDeviceId = null)
{
var settingsService = serviceProvider.GetRequiredService<ISettingsService>();
var settings = settingsService.Settings;
var dockSettings = settings.DockSettings;
// Prevent duplicate pins — check all sections
if (dockSettings.StartBands.Any(b => b.CommandId == commandId && b.ProviderId == this.ProviderId) ||
dockSettings.CenterBands.Any(b => b.CommandId == commandId && b.ProviderId == this.ProviderId) ||
dockSettings.EndBands.Any(b => b.CommandId == commandId && b.ProviderId == this.ProviderId))
// Prevent duplicate pins — check the target destination's bands.
// When pinning to a specific monitor, check that monitor's resolved bands
// (which include forked-from-global bands). Otherwise, check global bands.
DockMonitorConfig? targetConfig = null;
if (monitorDeviceId is not null)
{
foreach (var cfg in dockSettings.MonitorConfigs)
{
if (string.Equals(cfg.MonitorDeviceId, monitorDeviceId, StringComparison.OrdinalIgnoreCase))
{
targetConfig = cfg;
break;
}
}
}
var resolvedStart = targetConfig?.ResolveStartBands(dockSettings.StartBands) ?? dockSettings.StartBands;
var resolvedCenter = targetConfig?.ResolveCenterBands(dockSettings.CenterBands) ?? dockSettings.CenterBands;
var resolvedEnd = targetConfig?.ResolveEndBands(dockSettings.EndBands) ?? dockSettings.EndBands;
var alreadyPinned = resolvedStart.Any(b => b.CommandId == commandId && b.ProviderId == this.ProviderId) ||
resolvedCenter.Any(b => b.CommandId == commandId && b.ProviderId == this.ProviderId) ||
resolvedEnd.Any(b => b.CommandId == commandId && b.ProviderId == this.ProviderId);
if (alreadyPinned)
{
Logger.LogDebug($"Dock band '{commandId}' from provider '{this.ProviderId}' is already pinned; skipping.");
return;
@@ -519,6 +540,21 @@ public sealed class CommandProviderWrapper : ICommandProviderContext
ShowSubtitles = showSubtitles,
};
if (monitorDeviceId is not null)
{
PinDockBandToMonitor(settingsService, bandSettings, side, monitorDeviceId);
}
else
{
PinDockBandGlobal(settingsService, bandSettings, side);
}
// Raise CommandsChanged so the TopLevelCommandManager reloads our commands
this.CommandsChanged?.Invoke(this, new ItemsChangedEventArgs(-1));
}
private static void PinDockBandGlobal(ISettingsService settingsService, DockBandSettings bandSettings, Dock.DockPinSide side)
{
settingsService.UpdateSettings(
s =>
{
@@ -534,9 +570,59 @@ public sealed class CommandProviderWrapper : ICommandProviderContext
};
},
hotReload: false);
}
// Raise CommandsChanged so the TopLevelCommandManager reloads our commands
this.CommandsChanged?.Invoke(this, new ItemsChangedEventArgs(-1));
private static void PinDockBandToMonitor(ISettingsService settingsService, DockBandSettings bandSettings, Dock.DockPinSide side, string monitorDeviceId)
{
settingsService.UpdateSettings(
s =>
{
var dockSettings = s.DockSettings;
var configs = dockSettings.MonitorConfigs ?? System.Collections.Immutable.ImmutableList<DockMonitorConfig>.Empty;
// Find or create the monitor config
DockMonitorConfig? target = null;
var targetIndex = -1;
for (var i = 0; i < configs.Count; i++)
{
if (string.Equals(configs[i].MonitorDeviceId, monitorDeviceId, System.StringComparison.OrdinalIgnoreCase))
{
target = configs[i];
targetIndex = i;
break;
}
}
if (target is null)
{
// Monitor not yet in config; create and fork from global
target = new DockMonitorConfig { MonitorDeviceId = monitorDeviceId, Enabled = true };
target = target.ForkFromGlobal(dockSettings);
configs = configs.Add(target);
targetIndex = configs.Count - 1;
}
else if (!target.IsCustomized)
{
// Fork from global on first per-monitor customization
target = target.ForkFromGlobal(dockSettings);
}
// Add band to the appropriate section
target = side switch
{
Dock.DockPinSide.Center => target with { CenterBands = (target.CenterBands ?? System.Collections.Immutable.ImmutableList<DockBandSettings>.Empty).Add(bandSettings) },
Dock.DockPinSide.End => target with { EndBands = (target.EndBands ?? System.Collections.Immutable.ImmutableList<DockBandSettings>.Empty).Add(bandSettings) },
_ => target with { StartBands = (target.StartBands ?? System.Collections.Immutable.ImmutableList<DockBandSettings>.Empty).Add(bandSettings) },
};
configs = configs.SetItem(targetIndex, target);
return s with
{
DockSettings = dockSettings with { MonitorConfigs = configs },
};
},
hotReload: false);
}
public void UnpinDockBand(string commandId, IServiceProvider serviceProvider)

View File

@@ -128,6 +128,10 @@ public partial class CommandViewModel : ExtensionObjectViewModel
var iconInfo = model.Icon;
Icon = new(iconInfo);
Icon.InitializeProperties();
break;
case nameof(_properties):
UpdatePropertiesFromExtension(model as IExtendedAttributesProvider);
break;
}

View File

@@ -13,6 +13,7 @@ namespace Microsoft.CmdPal.UI.ViewModels.BuiltinCommands;
public sealed partial class BuiltInsCommandProvider : CommandProvider
{
private readonly OpenSettingsCommand openSettings = new();
private readonly OpenGallerySettingsCommand openGallerySettings = new();
private readonly QuitCommand quitCommand = new();
private readonly FallbackReloadItem _fallbackReloadItem = new();
private readonly FallbackLogItem _fallbackLogItem = new();
@@ -23,6 +24,7 @@ public sealed partial class BuiltInsCommandProvider : CommandProvider
public override ICommandItem[] TopLevelCommands() =>
[
new CommandItem(openSettings) { },
new CommandItem(openGallerySettings) { },
new CommandItem(_newExtension) { Title = _newExtension.Title },
];

View File

@@ -0,0 +1,20 @@
// 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.Toolkit;
namespace Microsoft.CmdPal.UI.ViewModels.BuiltinCommands;
public sealed partial class OpenGallerySettingsCommand : OpenSettingsCommand
{
public OpenGallerySettingsCommand()
: base(
settingsPageTag: "Gallery",
name: Properties.Resources.builtin_open_gallery_name,
glyph: "\uE719",
id: "com.microsoft.cmdpal.opengallerysettings") /* #no-spell-check-line */
{
Icon = IconHelpers.FromRelativePath("Assets\\Extension.svg");
}
}

View File

@@ -12,14 +12,31 @@ namespace Microsoft.CmdPal.UI.ViewModels.BuiltinCommands;
public partial class OpenSettingsCommand : InvokableCommand
{
public OpenSettingsCommand()
: this(
settingsPageTag: string.Empty,
name: Properties.Resources.builtin_open_settings_name,
glyph: "\uE713",
id: "com.microsoft.cmdpal.opensettings") /* #no-spell-check-line */
{
Name = Properties.Resources.builtin_open_settings_name;
Icon = new IconInfo("\uE713");
}
protected OpenSettingsCommand(
string settingsPageTag,
string name,
string glyph,
string id)
{
_settingsPageTag = settingsPageTag;
Name = name;
Icon = new IconInfo(glyph);
Id = id;
}
private readonly string _settingsPageTag;
public override ICommandResult Invoke()
{
WeakReferenceMessenger.Default.Send(new OpenSettingsMessage());
WeakReferenceMessenger.Default.Send(new OpenSettingsMessage(_settingsPageTag));
return CommandResult.KeepOpen();
}
}

View File

@@ -105,7 +105,18 @@ public sealed partial class DockBandViewModel : ExtensionObjectViewModel
/// </summary>
internal void SaveShowLabels()
{
ReplaceBandInSettings(_bandSettings with { ShowTitles = _showTitles, ShowSubtitles = _showSubtitles });
// Only write to settings if the label values actually changed from
// the snapshot. When multiple non-customized monitors share global
// bands, an unconditional save would overwrite changes made by
// another monitor's ViewModel (last-save-wins clobber).
var changed = _showTitlesSnapshot is null
|| _showTitles != _showTitlesSnapshot
|| _showSubtitles != _showSubtitlesSnapshot;
if (changed)
{
ReplaceBandInSettings(_bandSettings with { ShowTitles = _showTitles, ShowSubtitles = _showSubtitles });
}
_showTitlesSnapshot = null;
_showSubtitlesSnapshot = null;
}
@@ -135,15 +146,52 @@ public sealed partial class DockBandViewModel : ExtensionObjectViewModel
s =>
{
var dockSettings = s.DockSettings;
return s with
// Update in global bands
var updatedDock = dockSettings with
{
DockSettings = dockSettings with
{
StartBands = ReplaceBandInList(dockSettings.StartBands, commandId, newSettings),
CenterBands = ReplaceBandInList(dockSettings.CenterBands, commandId, newSettings),
EndBands = ReplaceBandInList(dockSettings.EndBands, commandId, newSettings),
},
StartBands = ReplaceBandInList(dockSettings.StartBands, commandId, newSettings),
CenterBands = ReplaceBandInList(dockSettings.CenterBands, commandId, newSettings),
EndBands = ReplaceBandInList(dockSettings.EndBands, commandId, newSettings),
};
// Also update in per-monitor bands for customized monitors
var configs = updatedDock.MonitorConfigs ?? ImmutableList<DockMonitorConfig>.Empty;
var configsChanged = false;
for (var i = 0; i < configs.Count; i++)
{
var config = configs[i];
if (!config.IsCustomized)
{
continue;
}
var start = config.StartBands ?? ImmutableList<DockBandSettings>.Empty;
var center = config.CenterBands ?? ImmutableList<DockBandSettings>.Empty;
var end = config.EndBands ?? ImmutableList<DockBandSettings>.Empty;
var newStart = ReplaceBandInList(start, commandId, newSettings);
var newCenter = ReplaceBandInList(center, commandId, newSettings);
var newEnd = ReplaceBandInList(end, commandId, newSettings);
if (newStart != start || newCenter != center || newEnd != end)
{
configs = configs.SetItem(i, config with
{
StartBands = newStart,
CenterBands = newCenter,
EndBands = newEnd,
});
configsChanged = true;
}
}
if (configsChanged)
{
updatedDock = updatedDock with { MonitorConfigs = configs };
}
return s with { DockSettings = updatedDock };
},
false);
_bandSettings = newSettings;

View File

@@ -0,0 +1,197 @@
// 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.Collections.Immutable;
using System.Globalization;
using System.Text;
using CommunityToolkit.Mvvm.ComponentModel;
using Microsoft.CmdPal.UI.ViewModels.Models;
using Microsoft.CmdPal.UI.ViewModels.Services;
using Microsoft.CmdPal.UI.ViewModels.Settings;
namespace Microsoft.CmdPal.UI.ViewModels.Dock;
/// <summary>
/// ViewModel wrapping a <see cref="DockMonitorConfig"/> paired with its
/// <see cref="MonitorInfo"/>. Exposes bindable properties for the monitor
/// config UI and persists changes through <see cref="ISettingsService"/>.
/// </summary>
public partial class DockMonitorConfigViewModel : ObservableObject
{
private static readonly CompositeFormat ResolutionFormat = CompositeFormat.Parse("{0} \u00D7 {1}");
private readonly MonitorInfo _monitorInfo;
private readonly ISettingsService _settingsService;
private readonly string _monitorDeviceId;
public DockMonitorConfigViewModel(
DockMonitorConfig config,
MonitorInfo monitorInfo,
ISettingsService settingsService)
{
_monitorInfo = monitorInfo;
_settingsService = settingsService;
_monitorDeviceId = config.MonitorDeviceId;
}
/// <summary>Gets the human-readable display name from the monitor hardware.</summary>
public string DisplayName => _monitorInfo.DisplayName;
/// <summary>Gets the stable device identifier for this monitor.</summary>
public string DeviceId => _monitorInfo.DeviceId;
/// <summary>Gets a value indicating whether this is the primary monitor.</summary>
public bool IsPrimary => _monitorInfo.IsPrimary;
/// <summary>Gets the monitor resolution formatted as "W × H".</summary>
public string Resolution => string.Format(
CultureInfo.CurrentCulture,
ResolutionFormat,
_monitorInfo.Bounds.Width,
_monitorInfo.Bounds.Height);
/// <summary>
/// Gets or sets a value indicating whether the dock is enabled on this monitor.
/// </summary>
public bool IsEnabled
{
get => GetConfig()?.Enabled ?? true;
set
{
UpdateConfig(c => c with { Enabled = value });
OnPropertyChanged();
}
}
/// <summary>
/// Gets or sets the side-override index for ComboBox binding.
/// 0 = "Use default" (inherit), 1 = Left, 2 = Top, 3 = Right, 4 = Bottom.
/// </summary>
public int SideOverrideIndex
{
get => GetConfig()?.Side switch
{
null => 0,
DockSide.Left => 1,
DockSide.Top => 2,
DockSide.Right => 3,
DockSide.Bottom => 4,
_ => 0,
};
set
{
var newSide = value switch
{
1 => (DockSide?)DockSide.Left,
2 => (DockSide?)DockSide.Top,
3 => (DockSide?)DockSide.Right,
4 => (DockSide?)DockSide.Bottom,
_ => null,
};
UpdateConfig(c => c with { Side = newSide });
OnPropertyChanged();
OnPropertyChanged(nameof(HasSideOverride));
}
}
/// <summary>Gets a value indicating whether this monitor has a per-monitor side override.</summary>
public bool HasSideOverride => GetConfig()?.Side is not null;
/// <summary>
/// Gets or sets a value indicating whether this monitor uses custom band pinning.
/// When toggled ON, forks band lists from global settings.
/// When toggled OFF, clears per-monitor bands.
/// </summary>
public bool IsCustomized
{
get => GetConfig()?.IsCustomized ?? false;
set
{
_settingsService.UpdateSettings(s =>
{
var dockSettings = s.DockSettings;
var configs = dockSettings.MonitorConfigs;
var index = FindConfigIndex(configs);
if (index < 0)
{
return s;
}
var config = configs[index];
DockMonitorConfig updated;
if (value)
{
updated = config.ForkFromGlobal(dockSettings);
}
else
{
updated = config with
{
IsCustomized = false,
StartBands = ImmutableList<DockBandSettings>.Empty,
CenterBands = ImmutableList<DockBandSettings>.Empty,
EndBands = ImmutableList<DockBandSettings>.Empty,
};
}
return s with
{
DockSettings = dockSettings with { MonitorConfigs = configs.SetItem(index, updated) },
};
});
OnPropertyChanged();
}
}
private DockMonitorConfig? GetConfig()
{
var configs = _settingsService.Settings.DockSettings.MonitorConfigs;
for (var i = 0; i < configs.Count; i++)
{
if (string.Equals(configs[i].MonitorDeviceId, _monitorDeviceId, StringComparison.OrdinalIgnoreCase))
{
return configs[i];
}
}
return null;
}
private void UpdateConfig(Func<DockMonitorConfig, DockMonitorConfig> transform)
{
_settingsService.UpdateSettings(s =>
{
var dockSettings = s.DockSettings;
var configs = dockSettings.MonitorConfigs;
var index = FindConfigIndex(configs);
if (index < 0)
{
return s;
}
var updated = transform(configs[index]);
return s with
{
DockSettings = dockSettings with { MonitorConfigs = configs.SetItem(index, updated) },
};
});
}
private int FindConfigIndex(ImmutableList<DockMonitorConfig> configs)
{
for (var i = 0; i < configs.Count; i++)
{
if (string.Equals(configs[i].MonitorDeviceId, _monitorDeviceId, StringComparison.OrdinalIgnoreCase))
{
return i;
}
}
return -1;
}
}

View File

@@ -14,15 +14,23 @@ using Microsoft.CommandPalette.Extensions.Toolkit;
namespace Microsoft.CmdPal.UI.ViewModels.Dock;
public sealed partial class DockViewModel
public sealed partial class DockViewModel : IDisposable
{
private readonly TopLevelCommandManager _topLevelCommandManager;
private readonly ISettingsService _settingsService;
private readonly DockPageContext _pageContext; // only to be used for our own context menu - not for dock bands themselves
private readonly IContextMenuFactory _contextMenuFactory;
private readonly string? _monitorDeviceId;
private DockSettings _settings;
private bool _isEditing;
private bool _disposed;
/// <summary>
/// Gets the monitor device identifier this dock is associated with, or <c>null</c>
/// for the default (single-monitor) dock.
/// </summary>
public string? MonitorDeviceId => _monitorDeviceId;
public TaskScheduler Scheduler { get; }
@@ -38,12 +46,14 @@ public sealed partial class DockViewModel
TopLevelCommandManager tlcManager,
IContextMenuFactory contextMenuFactory,
TaskScheduler scheduler,
ISettingsService settingsService)
ISettingsService settingsService,
string? monitorDeviceId = null)
{
_topLevelCommandManager = tlcManager;
_contextMenuFactory = contextMenuFactory;
_settingsService = settingsService;
_settings = _settingsService.Settings.DockSettings;
_monitorDeviceId = monitorDeviceId;
Scheduler = scheduler;
_pageContext = new(this);
@@ -72,17 +82,168 @@ public sealed partial class DockViewModel
public void UpdateSettings(DockSettings settings)
{
if (_isEditing)
{
Logger.LogDebug("DockViewModel.UpdateSettings skipped (edit in progress)");
return;
}
Logger.LogDebug($"DockViewModel.UpdateSettings");
_settings = settings;
SetupBands();
}
/// <summary>
/// Initializes bands from current settings. Call after the UI scheduler is ready
/// (i.e., after the DockWindow is shown) to ensure proper dispatcher access.
/// </summary>
public void InitializeBands() => SetupBands();
/// <summary>
/// Gets the active band lists for this dock instance. Returns per-monitor bands
/// when the associated monitor is customized; otherwise falls back to global bands.
/// </summary>
private (ImmutableList<DockBandSettings> Start, ImmutableList<DockBandSettings> Center, ImmutableList<DockBandSettings> End) GetActiveBands()
{
if (_monitorDeviceId is not null)
{
var config = FindMonitorConfig(_settings, _monitorDeviceId);
if (config is not null)
{
return (
config.ResolveStartBands(_settings.StartBands),
config.ResolveCenterBands(_settings.CenterBands),
config.ResolveEndBands(_settings.EndBands));
}
}
return (_settings.StartBands, _settings.CenterBands, _settings.EndBands);
}
/// <summary>
/// Returns an updated <see cref="DockSettings"/> with the given bands placed in the
/// correct location — per-monitor config when customized, or global otherwise.
/// </summary>
private DockSettings WithActiveBands(
ImmutableList<DockBandSettings> start,
ImmutableList<DockBandSettings> center,
ImmutableList<DockBandSettings> end)
{
if (_monitorDeviceId is not null)
{
var config = FindMonitorConfig(_settings, _monitorDeviceId);
if (config is not null && config.IsCustomized)
{
var updatedConfig = config with
{
StartBands = start,
CenterBands = center,
EndBands = end,
};
return _settings with
{
MonitorConfigs = ReplaceMonitorConfig(_settings.MonitorConfigs, updatedConfig),
};
}
}
return _settings with
{
StartBands = start,
CenterBands = center,
EndBands = end,
};
}
/// <summary>
/// Ensures the monitor associated with this dock has its own independent band lists.
/// If the monitor is not yet customized, forks bands from global settings.
/// Returns <c>true</c> if the fork was performed, <c>false</c> if already customized or no monitor.
/// </summary>
public bool EnsureMonitorForked()
{
if (_monitorDeviceId is null)
{
return false;
}
var config = FindMonitorConfig(_settings, _monitorDeviceId);
if (config is null || config.IsCustomized)
{
return false;
}
var forked = config.ForkFromGlobal(_settings);
_settings = _settings with
{
MonitorConfigs = ReplaceMonitorConfig(_settings.MonitorConfigs, forked),
};
SaveSettings();
return true;
}
/// <summary>
/// Gets the effective dock side for this instance, considering per-monitor overrides.
/// </summary>
public DockSide GetEffectiveSide()
{
if (_monitorDeviceId is not null)
{
var config = FindMonitorConfig(_settings, _monitorDeviceId);
if (config is not null)
{
return config.ResolveSide(_settings.Side);
}
}
return _settings.Side;
}
private static DockMonitorConfig? FindMonitorConfig(DockSettings settings, string deviceId)
{
var configs = settings.MonitorConfigs ?? System.Collections.Immutable.ImmutableList<DockMonitorConfig>.Empty;
foreach (var config in configs)
{
if (string.Equals(config.MonitorDeviceId, deviceId, System.StringComparison.OrdinalIgnoreCase))
{
return config;
}
}
return null;
}
private static ImmutableList<DockMonitorConfig> ReplaceMonitorConfig(
ImmutableList<DockMonitorConfig> configs,
DockMonitorConfig updated)
{
for (var i = 0; i < configs.Count; i++)
{
if (string.Equals(configs[i].MonitorDeviceId, updated.MonitorDeviceId, System.StringComparison.OrdinalIgnoreCase))
{
return configs.SetItem(i, updated);
}
}
return configs.Add(updated);
}
public void Dispose()
{
if (!_disposed)
{
_topLevelCommandManager.DockBands.CollectionChanged -= DockBands_CollectionChanged;
_disposed = true;
}
}
private void SetupBands()
{
Logger.LogDebug($"Setting up dock bands");
SetupBands(_settings.StartBands, StartItems);
SetupBands(_settings.CenterBands, CenterItems);
SetupBands(_settings.EndBands, EndItems);
var (start, center, end) = GetActiveBands();
SetupBands(start, StartItems);
SetupBands(center, CenterItems);
SetupBands(end, EndItems);
}
private void SetupBands(
@@ -207,42 +368,46 @@ public sealed partial class DockViewModel
public void SyncBandPosition(DockBandViewModel band, DockPinSide targetSide, int targetIndex)
{
var bandId = band.Id;
var dockSettings = _settings;
var (activeStart, activeCenter, activeEnd) = GetActiveBands();
var bandSettings = dockSettings.StartBands.FirstOrDefault(b => b.CommandId == bandId)
?? dockSettings.CenterBands.FirstOrDefault(b => b.CommandId == bandId)
?? dockSettings.EndBands.FirstOrDefault(b => b.CommandId == bandId);
var bandSettings = activeStart.FirstOrDefault(b => b.CommandId == bandId)
?? activeCenter.FirstOrDefault(b => b.CommandId == bandId)
?? activeEnd.FirstOrDefault(b => b.CommandId == bandId);
if (bandSettings == null)
{
return;
}
// Remove from all settings lists
var newDock = dockSettings with
{
StartBands = dockSettings.StartBands.RemoveAll(b => b.CommandId == bandId),
CenterBands = dockSettings.CenterBands.RemoveAll(b => b.CommandId == bandId),
EndBands = dockSettings.EndBands.RemoveAll(b => b.CommandId == bandId),
};
// Remove from all active band lists
var newStart = activeStart.RemoveAll(b => b.CommandId == bandId);
var newCenter = activeCenter.RemoveAll(b => b.CommandId == bandId);
var newEnd = activeEnd.RemoveAll(b => b.CommandId == bandId);
// Add to target settings list at the correct index
// Add to target list at the correct index
var targetList = targetSide switch
{
DockPinSide.Start => newDock.StartBands,
DockPinSide.Center => newDock.CenterBands,
DockPinSide.End => newDock.EndBands,
_ => newDock.StartBands,
DockPinSide.Start => newStart,
DockPinSide.Center => newCenter,
DockPinSide.End => newEnd,
_ => newStart,
};
var insertIndex = Math.Min(targetIndex, targetList.Count);
newDock = targetSide switch
switch (targetSide)
{
DockPinSide.Start => newDock with { StartBands = targetList.Insert(insertIndex, bandSettings) },
DockPinSide.Center => newDock with { CenterBands = targetList.Insert(insertIndex, bandSettings) },
DockPinSide.End => newDock with { EndBands = targetList.Insert(insertIndex, bandSettings) },
_ => newDock with { StartBands = targetList.Insert(insertIndex, bandSettings) },
};
_settings = newDock;
case DockPinSide.Start:
newStart = newStart.Insert(insertIndex, bandSettings);
break;
case DockPinSide.Center:
newCenter = newCenter.Insert(insertIndex, bandSettings);
break;
case DockPinSide.End:
default:
newEnd = newEnd.Insert(insertIndex, bandSettings);
break;
}
_settings = WithActiveBands(newStart, newCenter, newEnd);
}
/// <summary>
@@ -252,11 +417,11 @@ public sealed partial class DockViewModel
public void MoveBandWithoutSaving(DockBandViewModel band, DockPinSide targetSide, int targetIndex)
{
var bandId = band.Id;
var dockSettings = _settings;
var (activeStart, activeCenter, activeEnd) = GetActiveBands();
var bandSettings = dockSettings.StartBands.FirstOrDefault(b => b.CommandId == bandId)
?? dockSettings.CenterBands.FirstOrDefault(b => b.CommandId == bandId)
?? dockSettings.EndBands.FirstOrDefault(b => b.CommandId == bandId);
var bandSettings = activeStart.FirstOrDefault(b => b.CommandId == bandId)
?? activeCenter.FirstOrDefault(b => b.CommandId == bandId)
?? activeEnd.FirstOrDefault(b => b.CommandId == bandId);
if (bandSettings == null)
{
@@ -265,12 +430,9 @@ public sealed partial class DockViewModel
}
// Remove from all sides (settings)
var newDock = dockSettings with
{
StartBands = dockSettings.StartBands.RemoveAll(b => b.CommandId == bandId),
CenterBands = dockSettings.CenterBands.RemoveAll(b => b.CommandId == bandId),
EndBands = dockSettings.EndBands.RemoveAll(b => b.CommandId == bandId),
};
var newStart = activeStart.RemoveAll(b => b.CommandId == bandId);
var newCenter = activeCenter.RemoveAll(b => b.CommandId == bandId);
var newEnd = activeEnd.RemoveAll(b => b.CommandId == bandId);
// Remove from UI collections
StartItems.Remove(band);
@@ -282,8 +444,8 @@ public sealed partial class DockViewModel
{
case DockPinSide.Start:
{
var settingsIndex = Math.Min(targetIndex, newDock.StartBands.Count);
newDock = newDock with { StartBands = newDock.StartBands.Insert(settingsIndex, bandSettings) };
var settingsIndex = Math.Min(targetIndex, newStart.Count);
newStart = newStart.Insert(settingsIndex, bandSettings);
var uiIndex = Math.Min(targetIndex, StartItems.Count);
StartItems.Insert(uiIndex, band);
@@ -292,8 +454,8 @@ public sealed partial class DockViewModel
case DockPinSide.Center:
{
var settingsIndex = Math.Min(targetIndex, newDock.CenterBands.Count);
newDock = newDock with { CenterBands = newDock.CenterBands.Insert(settingsIndex, bandSettings) };
var settingsIndex = Math.Min(targetIndex, newCenter.Count);
newCenter = newCenter.Insert(settingsIndex, bandSettings);
var uiIndex = Math.Min(targetIndex, CenterItems.Count);
CenterItems.Insert(uiIndex, band);
@@ -302,8 +464,8 @@ public sealed partial class DockViewModel
case DockPinSide.End:
{
var settingsIndex = Math.Min(targetIndex, newDock.EndBands.Count);
newDock = newDock with { EndBands = newDock.EndBands.Insert(settingsIndex, bandSettings) };
var settingsIndex = Math.Min(targetIndex, newEnd.Count);
newEnd = newEnd.Insert(settingsIndex, bandSettings);
var uiIndex = Math.Min(targetIndex, EndItems.Count);
EndItems.Insert(uiIndex, band);
@@ -311,7 +473,7 @@ public sealed partial class DockViewModel
}
}
_settings = newDock;
_settings = WithActiveBands(newStart, newCenter, newEnd);
Logger.LogDebug($"Moved band {bandId} to {targetSide} at index {targetIndex} (not saved yet)");
}
@@ -331,29 +493,95 @@ public sealed partial class DockViewModel
// Preserve any per-band label edits made while in edit mode. Those edits are
// saved independently of reorder, so merge the latest band settings back into
// the local reordered snapshot before we persist dock settings.
var latestBandSettings = BuildBandSettingsLookup(_settingsService.Settings.DockSettings);
_settings = _settings with
{
StartBands = MergeBandSettings(_settings.StartBands, latestBandSettings),
CenterBands = MergeBandSettings(_settings.CenterBands, latestBandSettings),
EndBands = MergeBandSettings(_settings.EndBands, latestBandSettings),
};
var (latestStart, latestCenter, latestEnd) = GetActiveBandsFromSettings(_settingsService.Settings.DockSettings);
var latestBandSettings = BuildBandSettingsLookup(latestStart, latestCenter, latestEnd);
var (activeStart, activeCenter, activeEnd) = GetActiveBands();
_settings = WithActiveBands(
MergeBandSettings(activeStart, latestBandSettings),
MergeBandSettings(activeCenter, latestBandSettings),
MergeBandSettings(activeEnd, latestBandSettings));
_snapshotDockSettings = null;
_snapshotBandViewModels = null;
// Save without hotReload to avoid triggering SettingsChanged → SetupBands,
// which could race with stale DockBands_CollectionChanged work items and
// re-add bands that were just unpinned.
_settingsService.UpdateSettings(s => s with { DockSettings = _settings }, false);
// Extract the final merged bands for this monitor
var (myStart, myCenter, myEnd) = GetActiveBands();
// Save only this monitor's bands into the CURRENT persisted settings,
// preserving other monitors' changes. Without this, each DockViewModel's
// save would overwrite the entire DockSettings, causing the last save to
// clobber changes from monitors that saved earlier.
_settingsService.UpdateSettings(
s =>
{
var currentDock = s.DockSettings;
if (_monitorDeviceId is not null)
{
var config = FindMonitorConfig(currentDock, _monitorDeviceId);
if (config is not null && config.IsCustomized)
{
var updatedConfig = config with
{
StartBands = myStart,
CenterBands = myCenter,
EndBands = myEnd,
};
var configs = currentDock.MonitorConfigs ?? ImmutableList<DockMonitorConfig>.Empty;
return s with
{
DockSettings = currentDock with
{
MonitorConfigs = ReplaceMonitorConfig(configs, updatedConfig),
},
};
}
}
return s with
{
DockSettings = currentDock with
{
StartBands = myStart,
CenterBands = myCenter,
EndBands = myEnd,
},
};
},
false);
// Refresh local settings from persisted state so all monitors' changes are visible
_settings = _settingsService.Settings.DockSettings;
_isEditing = false;
Logger.LogDebug("Saved band order to settings");
}
private static Dictionary<string, DockBandSettings> BuildBandSettingsLookup(DockSettings dockSettings)
/// <summary>
/// Gets active bands from a given DockSettings, considering this dock's monitor.
/// </summary>
private (ImmutableList<DockBandSettings> Start, ImmutableList<DockBandSettings> Center, ImmutableList<DockBandSettings> End) GetActiveBandsFromSettings(DockSettings dockSettings)
{
if (_monitorDeviceId is not null)
{
var config = FindMonitorConfig(dockSettings, _monitorDeviceId);
if (config is not null)
{
return (
config.ResolveStartBands(dockSettings.StartBands),
config.ResolveCenterBands(dockSettings.CenterBands),
config.ResolveEndBands(dockSettings.EndBands));
}
}
return (dockSettings.StartBands, dockSettings.CenterBands, dockSettings.EndBands);
}
private static Dictionary<string, DockBandSettings> BuildBandSettingsLookup(
ImmutableList<DockBandSettings> start,
ImmutableList<DockBandSettings> center,
ImmutableList<DockBandSettings> end)
{
var lookup = new Dictionary<string, DockBandSettings>(StringComparer.Ordinal);
foreach (var band in dockSettings.StartBands.Concat(dockSettings.CenterBands).Concat(dockSettings.EndBands))
foreach (var band in start.Concat(center).Concat(end))
{
lookup[band.CommandId] = band;
}
@@ -450,13 +678,13 @@ public sealed partial class DockViewModel
return;
}
var dockSettings = _settings;
var (activeStart, activeCenter, activeEnd) = GetActiveBands();
StartItems.Clear();
CenterItems.Clear();
EndItems.Clear();
foreach (var bandSettings in dockSettings.StartBands)
foreach (var bandSettings in activeStart)
{
if (_snapshotBandViewModels.TryGetValue(bandSettings.CommandId, out var bandVM))
{
@@ -464,7 +692,7 @@ public sealed partial class DockViewModel
}
}
foreach (var bandSettings in dockSettings.CenterBands)
foreach (var bandSettings in activeCenter)
{
if (_snapshotBandViewModels.TryGetValue(bandSettings.CommandId, out var bandVM))
{
@@ -472,7 +700,7 @@ public sealed partial class DockViewModel
}
}
foreach (var bandSettings in dockSettings.EndBands)
foreach (var bandSettings in activeEnd)
{
if (_snapshotBandViewModels.TryGetValue(bandSettings.CommandId, out var bandVM))
{
@@ -483,7 +711,7 @@ public sealed partial class DockViewModel
private void RebuildUICollections()
{
var dockSettings = _settings;
var (activeStart, activeCenter, activeEnd) = GetActiveBands();
// Create a lookup of all current band ViewModels
var allBands = StartItems.Concat(CenterItems).Concat(EndItems).ToDictionary(b => b.Id);
@@ -492,7 +720,7 @@ public sealed partial class DockViewModel
CenterItems.Clear();
EndItems.Clear();
foreach (var bandSettings in dockSettings.StartBands)
foreach (var bandSettings in activeStart)
{
if (allBands.TryGetValue(bandSettings.CommandId, out var bandVM))
{
@@ -500,7 +728,7 @@ public sealed partial class DockViewModel
}
}
foreach (var bandSettings in dockSettings.CenterBands)
foreach (var bandSettings in activeCenter)
{
if (allBands.TryGetValue(bandSettings.CommandId, out var bandVM))
{
@@ -508,7 +736,7 @@ public sealed partial class DockViewModel
}
}
foreach (var bandSettings in dockSettings.EndBands)
foreach (var bandSettings in activeEnd)
{
if (allBands.TryGetValue(bandSettings.CommandId, out var bandVM))
{
@@ -561,6 +789,7 @@ public sealed partial class DockViewModel
// Create settings for the new band
var bandSettings = new DockBandSettings { ProviderId = topLevel.CommandProviderId, CommandId = bandId };
var dockSettings = _settings;
var (activeStart, activeCenter, activeEnd) = GetActiveBands();
// Create the band view model
var bandVm = CreateBandItem(bandSettings, topLevel.ItemViewModel);
@@ -569,15 +798,15 @@ public sealed partial class DockViewModel
switch (targetSide)
{
case DockPinSide.Start:
_settings = dockSettings with { StartBands = dockSettings.StartBands.Add(bandSettings) };
_settings = WithActiveBands(activeStart.Add(bandSettings), activeCenter, activeEnd);
StartItems.Add(bandVm);
break;
case DockPinSide.Center:
_settings = dockSettings with { CenterBands = dockSettings.CenterBands.Add(bandSettings) };
_settings = WithActiveBands(activeStart, activeCenter.Add(bandSettings), activeEnd);
CenterItems.Add(bandVm);
break;
case DockPinSide.End:
_settings = dockSettings with { EndBands = dockSettings.EndBands.Add(bandSettings) };
_settings = WithActiveBands(activeStart, activeCenter, activeEnd.Add(bandSettings));
EndItems.Add(bandVm);
break;
}
@@ -600,15 +829,13 @@ public sealed partial class DockViewModel
public void UnpinBand(DockBandViewModel band)
{
var bandId = band.Id;
var dockSettings = _settings;
var (activeStart, activeCenter, activeEnd) = GetActiveBands();
// Remove from settings
_settings = dockSettings with
{
StartBands = dockSettings.StartBands.RemoveAll(b => b.CommandId == bandId),
CenterBands = dockSettings.CenterBands.RemoveAll(b => b.CommandId == bandId),
EndBands = dockSettings.EndBands.RemoveAll(b => b.CommandId == bandId),
};
_settings = WithActiveBands(
activeStart.RemoveAll(b => b.CommandId == bandId),
activeCenter.RemoveAll(b => b.CommandId == bandId),
activeEnd.RemoveAll(b => b.CommandId == bandId));
// Remove from UI collections
StartItems.Remove(band);
@@ -670,14 +897,16 @@ public sealed partial class DockViewModel
private void EmitDockConfiguration()
{
var isDockEnabled = _settingsService.Settings.EnableDock;
var dockSide = isDockEnabled ? _settings.Side.ToString().ToLowerInvariant() : "none";
var dockSide = isDockEnabled ? GetEffectiveSide().ToString().ToLowerInvariant() : "none";
var (activeStart, activeCenter, activeEnd) = GetActiveBands();
static string FormatBands(ImmutableList<DockBandSettings> bands) =>
string.Join("\n", bands.Select(b => $"{b.ProviderId}/{b.CommandId}"));
var startBands = isDockEnabled ? FormatBands(_settings.StartBands) : string.Empty;
var centerBands = isDockEnabled ? FormatBands(_settings.CenterBands) : string.Empty;
var endBands = isDockEnabled ? FormatBands(_settings.EndBands) : string.Empty;
var startBands = isDockEnabled ? FormatBands(activeStart) : string.Empty;
var centerBands = isDockEnabled ? FormatBands(activeCenter) : string.Empty;
var endBands = isDockEnabled ? FormatBands(activeEnd) : string.Empty;
WeakReferenceMessenger.Default.Send(new TelemetryDockConfigurationMessage(
isDockEnabled, dockSide, startBands, centerBands, endBands));

View File

@@ -0,0 +1,960 @@
// 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.Text;
using CommunityToolkit.Mvvm.ComponentModel;
using CommunityToolkit.Mvvm.Input;
using Microsoft.CmdPal.Common.ExtensionGallery.Models;
using Microsoft.CmdPal.Common.WinGet.Models;
using Microsoft.CmdPal.Common.WinGet.Services;
using Microsoft.CmdPal.UI.ViewModels.Properties;
using Microsoft.CommandPalette.Extensions.Toolkit;
using Microsoft.Extensions.Logging;
using Microsoft.UI.Xaml.Media;
using Microsoft.UI.Xaml.Media.Imaging;
namespace Microsoft.CmdPal.UI.ViewModels.Gallery;
public sealed partial class ExtensionGalleryItemViewModel : ObservableObject
{
private static readonly Uri PlaceholderIconUri = new("ms-appx:///Assets/Icons/ExtensionIconPlaceholder.png");
private static readonly StringComparer OrdinalIgnoreCase = StringComparer.OrdinalIgnoreCase;
private static readonly IReadOnlyList<string> EmptyTags = [];
private static readonly Action<ILogger, Exception?> LogWinGetInstallFailedMessage =
LoggerMessage.Define(
LogLevel.Error,
new EventId(1, nameof(LogWinGetInstallFailed)),
"WinGet install/update failed.");
private static readonly Action<ILogger, string, Exception?> LogIconLoadFailedMessage =
LoggerMessage.Define<string>(
LogLevel.Error,
new EventId(2, nameof(LogIconLoadFailed)),
"Failed to load icon from '{IconUri}'.");
private const string SourceTypeWinGet = "winget";
private const string SourceTypeStore = "msstore";
private const string SourceTypeUrl = "url";
private const string SourceTypeGitHub = "github";
private const string SourceTypeWebsite = "website";
private const string SourceTypeUnknown = "unknown";
private readonly GalleryExtensionEntry _entry;
private readonly ILogger<ExtensionGalleryItemViewModel> _logger;
private readonly IWinGetPackageManagerService? _winGetPackageManagerService;
private readonly IWinGetOperationTrackerService? _winGetOperationTrackerService;
private readonly IWinGetPackageStatusService? _winGetPackageStatusService;
private readonly IReadOnlyDictionary<string, GalleryInstallSource> _installSourcesByType;
private readonly IReadOnlyDictionary<string, GallerySourceViewModel> _sourcesByKind;
public ExtensionGalleryItemViewModel(
GalleryExtensionEntry entry,
ILogger<ExtensionGalleryItemViewModel> logger,
IWinGetPackageManagerService? winGetPackageManagerService = null,
IWinGetPackageStatusService? winGetPackageStatusService = null,
IWinGetOperationTrackerService? winGetOperationTrackerService = null)
{
_entry = entry;
_logger = logger;
_winGetPackageManagerService = winGetPackageManagerService;
_winGetPackageStatusService = winGetPackageStatusService;
_winGetOperationTrackerService = winGetOperationTrackerService;
_installSourcesByType = BuildInstallSourceLookup(entry.InstallSources);
(Sources, _sourcesByKind) = BuildSourceInfos(_installSourcesByType, entry.Homepage);
Screenshots = BuildScreenshots(entry.ScreenshotUrls);
var resolvedIconUri = ResolveIconUri();
IconUri = resolvedIconUri ?? PlaceholderIconUri;
}
public string Id => _entry.Id;
public string Title => _entry.Title;
public string DisplayTitle => !string.IsNullOrWhiteSpace(Title) ? Title : Id;
public string Description => _entry.Description;
public string DisplayDescription => !string.IsNullOrWhiteSpace(Description) ? Description : Resources.gallery_item_no_description;
public string? ShortDescription => _entry.ShortDescription;
public string DisplayShortDescription => !string.IsNullOrWhiteSpace(ShortDescription) ? ShortDescription : string.Empty;
public string AuthorName => _entry.Author?.Name ?? string.Empty;
public string DisplayAuthorName => !string.IsNullOrWhiteSpace(AuthorName) ? AuthorName : Resources.gallery_item_unknown_author;
public IReadOnlyList<string> Tags => _entry.Tags ?? EmptyTags;
public bool HasTags => Tags.Count > 0;
public string TagsText => BuildTagsText(Tags);
public string? AuthorUrl => _entry.Author?.Url;
public string? Homepage => _entry.Homepage;
public Uri IconUri { get; }
public ImageSource IconSource
{
get => field ??= CreateImageSource(IconUri);
private set => SetProperty(ref field, value);
}
public IReadOnlyList<ExtensionGalleryScreenshotViewModel> Screenshots { get; }
public bool HasScreenshots => Screenshots.Count > 0;
public IReadOnlyList<GallerySourceViewModel> Sources { get; }
public bool HasWinGetSource => HasSource(SourceTypeWinGet);
public bool HasStoreSource => HasSource(SourceTypeStore);
public bool HasUrlSource => _installSourcesByType.ContainsKey(SourceTypeUrl) && !string.IsNullOrWhiteSpace(InstallUrl);
public bool HasHomepage => !string.IsNullOrWhiteSpace(Homepage);
public bool HasAuthorUrl => !string.IsNullOrWhiteSpace(AuthorUrl);
public bool HasGitHubSource => HasSource(SourceTypeGitHub);
public bool HasWebsiteSource => HasSource(SourceTypeWebsite);
public bool HasUnknownSource => HasSource(SourceTypeUnknown);
public bool HasAnySourceDetails => Sources.Count > 0;
public List<GallerySourceViewModel> SourcesWithDetails
{
get
{
List<GallerySourceViewModel> withDetails = [];
for (var i = 0; i < Sources.Count; i++)
{
if (Sources[i].HasDetails)
{
withDetails.Add(Sources[i]);
}
}
return withDetails;
}
}
public bool HasSourceMetadataDetails => SourcesWithDetails.Count > 0;
public bool HasKnownSourceIndicator => Sources.Any(s => s.IsKnown);
public bool ShowUnknownSourceIndicator => HasUnknownSource || !HasKnownSourceIndicator;
public bool HasActionableSourceDetails => HasStoreSource || HasWinGetSource || HasHomepage || HasUrlSource;
public bool ShowNoSourceDetails => !HasActionableSourceDetails;
public string UnknownSourceTooltip => HasUnknownSource
? Resources.gallery_item_unknown_source_unsupported_tooltip
: Resources.gallery_item_unknown_source_unavailable_tooltip;
public string NoSourceMenuText => Resources.gallery_item_no_source_menu_text;
public string NoSourceDetailsText => Resources.gallery_item_no_source_details_text;
public string? WinGetId => GetSource(SourceTypeWinGet)?.Id;
public string? StoreId => GetSource(SourceTypeStore)?.Id;
public string? InstallUrl => GetSource(SourceTypeGitHub)?.Uri ?? GetSource(SourceTypeWebsite)?.Uri;
public string WinGetInstallCommand => !string.IsNullOrWhiteSpace(WinGetId) ? $"winget install --id {WinGetId}" : string.Empty;
public bool CanCopyWinGetInstallCommand => !string.IsNullOrWhiteSpace(WinGetInstallCommand);
public string WinGetTooltip => !string.IsNullOrWhiteSpace(WinGetId)
? FormatResource(Resources.gallery_item_winget_tooltip_with_id, WinGetId)
: Resources.gallery_item_winget_tooltip;
public string StoreTooltip => !string.IsNullOrWhiteSpace(StoreId)
? FormatResource(Resources.gallery_item_store_tooltip_with_id, StoreId)
: Resources.gallery_item_store_tooltip;
public string GitHubTooltip => GetSource(SourceTypeGitHub)?.Uri ?? Resources.gallery_item_github_source;
public string WebsiteTooltip => GetSource(SourceTypeWebsite)?.Uri ?? Homepage ?? Resources.gallery_item_website_source;
public string WinGetMenuText => !string.IsNullOrWhiteSpace(WinGetId)
? FormatResource(Resources.gallery_item_winget_menu_text_with_id, WinGetId)
: Resources.gallery_item_winget_menu_text;
public string StoreMenuText => !string.IsNullOrWhiteSpace(StoreId)
? FormatResource(Resources.gallery_item_store_menu_text_with_id, StoreId)
: Resources.gallery_item_store_menu_text;
public string GitHubMenuText => Resources.gallery_item_github_source;
public string WebsiteMenuText => Resources.gallery_item_website_source;
public string? PackageFamilyName => _entry.Detection?.PackageFamilyName;
public bool IsWinGetAvailable => _winGetPackageManagerService?.State.IsAvailable ?? false;
public string? WinGetUnavailableMessage => HasWinGetSource && !IsWinGetAvailable ? _winGetPackageManagerService?.State.Message : null;
public bool ShowWinGetUnavailableMessage => !string.IsNullOrWhiteSpace(WinGetUnavailableMessage);
public bool ShowInstallViaWinGetButton => HasWinGetSource && (!IsInstalled || IsUpdateAvailable);
public bool CanInstallViaWinGet => ShowInstallViaWinGetButton && IsWinGetAvailable && !IsWinGetActionInProgress;
public string InstallViaWinGetText => IsUpdateAvailable
? Resources.gallery_item_update_action
: Resources.gallery_item_install_action;
public bool ShowCancelWinGetActionButton => IsWinGetActionInProgress && CanCancelWinGetAction;
public bool ShowWinGetActionControls => ShowInstallViaWinGetButton || IsWinGetActionInProgress;
[ObservableProperty]
public partial bool IsInstalled { get; set; }
[ObservableProperty]
public partial bool IsInstalledStateKnown { get; set; }
[ObservableProperty]
public partial bool IsUpdateAvailable { get; set; }
[ObservableProperty]
public partial bool IsUpdateStateKnown { get; set; }
[ObservableProperty]
public partial bool IsWinGetActionInProgress { get; set; }
[ObservableProperty]
public partial bool CanCancelWinGetAction { get; set; }
[ObservableProperty]
public partial string? WinGetActionMessage { get; set; }
[ObservableProperty]
public partial bool IsWinGetActionIndeterminate { get; set; }
[ObservableProperty]
public partial double WinGetActionProgressValue { get; set; }
public bool ShowInstalledBadge => IsInstalled && !IsUpdateAvailable;
public bool ShowInstallButton => !ShowInstalledBadge;
public bool ShowUpdateBadge => IsUpdateAvailable;
public bool ShowWinGetActionIndicator => IsWinGetActionInProgress;
public bool ShowWinGetActionStatus => IsWinGetActionInProgress && !string.IsNullOrWhiteSpace(WinGetActionMessage);
public string InstallStatusText =>
IsUpdateAvailable
? Resources.gallery_item_install_status_update_available
: IsInstalled
? Resources.gallery_item_install_status_installed
: IsInstalledStateKnown
? Resources.gallery_item_install_status_not_installed
: Resources.gallery_item_install_status_unavailable;
public string WinGetStatusText =>
!HasWinGetSource
? string.Empty
: IsUpdateAvailable
? Resources.gallery_item_winget_status_update_available
: IsInstalled
? Resources.gallery_item_winget_status_installed
: IsInstalledStateKnown
? Resources.gallery_item_winget_status_not_installed
: Resources.gallery_item_winget_status_unavailable;
public bool ShowWinGetStatusDetails => HasWinGetSource && !AreStatusTextsEquivalent(InstallStatusText, WinGetStatusText);
public bool HasWinGetActionMessage => !string.IsNullOrWhiteSpace(WinGetActionMessage);
public void ApplyWinGetPackageInfo(WinGetPackageInfo packageInfo)
{
IsInstalled = IsInstalled || packageInfo.Status.IsInstalled;
IsInstalledStateKnown = IsInstalledStateKnown || packageInfo.Status.IsInstalledStateKnown;
IsUpdateAvailable = packageInfo.Status.IsUpdateAvailable;
IsUpdateStateKnown = packageInfo.Status.IsUpdateStateKnown;
if (packageInfo.Details is null)
{
return;
}
ApplySourceDetails(SourceTypeWinGet, CreateSourceDetails(packageInfo.Details));
}
[RelayCommand]
private void OpenHomepage()
{
if (!string.IsNullOrEmpty(Homepage))
{
ShellHelpers.OpenInShell(Homepage);
}
}
[RelayCommand]
private void OpenAuthorPage()
{
if (!string.IsNullOrEmpty(AuthorUrl))
{
ShellHelpers.OpenInShell(AuthorUrl);
}
}
[RelayCommand]
private void InstallViaStore()
{
if (!string.IsNullOrEmpty(StoreId))
{
ShellHelpers.OpenInShell($"ms-windows-store://pdp/?ProductId={StoreId}");
}
}
[RelayCommand]
private void OpenInstallUrl()
{
if (!string.IsNullOrEmpty(InstallUrl))
{
ShellHelpers.OpenInShell(InstallUrl);
}
}
[RelayCommand]
private static void OpenInstalledApps()
{
ShellHelpers.OpenInShell("ms-settings:appsfeatures");
}
[RelayCommand]
private void CopyWinGetInstall()
{
if (string.IsNullOrWhiteSpace(WinGetInstallCommand))
{
return;
}
ClipboardHelper.SetText(WinGetInstallCommand);
}
[RelayCommand(CanExecute = nameof(CanInstallViaWinGet))]
private async Task InstallViaWinGetAsync()
{
if (_winGetPackageManagerService is null || string.IsNullOrWhiteSpace(WinGetId))
{
return;
}
IsWinGetActionInProgress = true;
IsWinGetActionIndeterminate = true;
WinGetActionProgressValue = 0;
WinGetActionMessage = IsUpdateAvailable
? Resources.gallery_item_winget_action_updating
: Resources.gallery_item_winget_action_installing;
try
{
var packagesResult = await _winGetPackageManagerService.GetPackagesByIdAsync([WinGetId], includeStoreCatalog: false);
if (!packagesResult.IsSuccess)
{
WinGetActionMessage = packagesResult.ErrorMessage ?? Resources.gallery_item_winget_action_resolve_failed;
return;
}
if (packagesResult.Value is null || !packagesResult.Value.TryGetValue(WinGetId, out var package))
{
WinGetActionMessage = Resources.gallery_item_winget_action_package_not_found;
return;
}
var installResult = await _winGetPackageManagerService.InstallPackageAsync(package, skipDependencies: true);
if (!installResult.Succeeded)
{
WinGetActionMessage = installResult.ErrorMessage ?? Resources.gallery_item_winget_action_install_failed;
}
}
catch (Exception ex)
{
LogWinGetInstallFailed(_logger, ex);
throw;
}
finally
{
IsWinGetActionInProgress = false;
IsWinGetActionIndeterminate = false;
WinGetActionProgressValue = 0;
}
}
[RelayCommand(CanExecute = nameof(CanCancelWinGetAction))]
private void CancelWinGetAction()
{
if (_winGetOperationTrackerService is null || string.IsNullOrWhiteSpace(WinGetId))
{
return;
}
var operation = _winGetOperationTrackerService.GetLatestOperation(WinGetId);
if (operation is null || operation.IsCompleted || !operation.CanCancel)
{
CanCancelWinGetAction = false;
return;
}
if (!_winGetOperationTrackerService.TryCancelOperation(operation.OperationId))
{
CanCancelWinGetAction = false;
}
}
public void ApplyTrackedOperation(WinGetPackageOperation operation)
{
if (string.IsNullOrWhiteSpace(WinGetId) || !string.Equals(WinGetId, operation.PackageId, StringComparison.OrdinalIgnoreCase))
{
return;
}
CanCancelWinGetAction = operation.CanCancel && !operation.IsCompleted;
var treatAsUpdate = IsInstalled || IsUpdateAvailable;
switch (operation.State)
{
case WinGetPackageOperationState.Queued:
IsWinGetActionInProgress = true;
IsWinGetActionIndeterminate = true;
WinGetActionProgressValue = 0;
WinGetActionMessage = operation.Kind == WinGetPackageOperationKind.Uninstall
? Resources.gallery_item_winget_action_queued_uninstall
: treatAsUpdate
? Resources.gallery_item_winget_action_queued_update
: Resources.gallery_item_winget_action_queued_install;
break;
case WinGetPackageOperationState.Downloading:
IsWinGetActionInProgress = true;
IsWinGetActionIndeterminate = !operation.ProgressPercent.HasValue;
WinGetActionProgressValue = operation.ProgressPercent ?? 0;
WinGetActionMessage = operation.ProgressPercent is uint progressPercent
? FormatResource(Resources.gallery_item_winget_action_downloading_with_progress, progressPercent)
: Resources.gallery_item_winget_action_downloading;
break;
case WinGetPackageOperationState.Installing:
IsWinGetActionInProgress = true;
IsWinGetActionIndeterminate = true;
WinGetActionProgressValue = 0;
WinGetActionMessage = treatAsUpdate
? Resources.gallery_item_winget_action_updating
: Resources.gallery_item_winget_action_installing;
break;
case WinGetPackageOperationState.Uninstalling:
IsWinGetActionInProgress = true;
IsWinGetActionIndeterminate = true;
WinGetActionProgressValue = 0;
WinGetActionMessage = Resources.gallery_item_winget_action_uninstalling;
break;
case WinGetPackageOperationState.PostProcessing:
IsWinGetActionInProgress = true;
IsWinGetActionIndeterminate = true;
WinGetActionProgressValue = 0;
WinGetActionMessage = Resources.gallery_item_winget_action_finishing;
break;
case WinGetPackageOperationState.Succeeded:
IsWinGetActionInProgress = false;
IsWinGetActionIndeterminate = false;
WinGetActionProgressValue = 100;
WinGetActionMessage = operation.Kind == WinGetPackageOperationKind.Uninstall
? Resources.gallery_item_winget_action_succeeded_uninstall
: treatAsUpdate
? Resources.gallery_item_winget_action_succeeded_update
: Resources.gallery_item_winget_action_succeeded_install;
ApplyOptimisticTrackedCompletion(operation.Kind);
break;
case WinGetPackageOperationState.Canceled:
IsWinGetActionInProgress = false;
IsWinGetActionIndeterminate = false;
WinGetActionProgressValue = 0;
WinGetActionMessage = Resources.gallery_item_winget_action_canceled;
break;
case WinGetPackageOperationState.Failed:
IsWinGetActionInProgress = false;
IsWinGetActionIndeterminate = false;
WinGetActionProgressValue = 0;
WinGetActionMessage = operation.ErrorMessage ?? Resources.gallery_item_winget_action_failed;
break;
}
}
private Uri? ResolveIconUri()
{
var iconUrl = ToNullIfWhiteSpace(_entry.IconUrl);
if (iconUrl is null)
{
return null;
}
if (!Uri.TryCreate(iconUrl, UriKind.Absolute, out var resolvedIconUri))
{
return null;
}
return IsSupportedIconUri(resolvedIconUri) ? resolvedIconUri : null;
}
private static bool IsSupportedIconUri(Uri uri)
{
return uri.Scheme.Equals(Uri.UriSchemeHttp, StringComparison.OrdinalIgnoreCase)
|| uri.Scheme.Equals(Uri.UriSchemeHttps, StringComparison.OrdinalIgnoreCase)
|| uri.Scheme.Equals(Uri.UriSchemeFile, StringComparison.OrdinalIgnoreCase)
|| uri.Scheme.Equals("ms-appx", StringComparison.OrdinalIgnoreCase);
}
private static IReadOnlyList<ExtensionGalleryScreenshotViewModel> BuildScreenshots(List<string>? screenshotUrls)
{
if (screenshotUrls is null || screenshotUrls.Count == 0)
{
return [];
}
List<ExtensionGalleryScreenshotViewModel> screenshots = [];
HashSet<string> seenUris = new(OrdinalIgnoreCase);
for (var i = 0; i < screenshotUrls.Count; i++)
{
var screenshotUrl = ToNullIfWhiteSpace(screenshotUrls[i]);
if (screenshotUrl is null
|| !Uri.TryCreate(screenshotUrl, UriKind.Absolute, out var screenshotUri)
|| !IsSupportedIconUri(screenshotUri)
|| !seenUris.Add(screenshotUri.AbsoluteUri))
{
continue;
}
screenshots.Add(new ExtensionGalleryScreenshotViewModel(screenshotUri, screenshots.Count));
}
return screenshots;
}
private GallerySourceViewModel? GetSource(string sourceKind)
{
return _sourcesByKind.TryGetValue(sourceKind, out var source) ? source : null;
}
private bool HasSource(string sourceKind)
{
return _sourcesByKind.ContainsKey(sourceKind);
}
private static IReadOnlyDictionary<string, GalleryInstallSource> BuildInstallSourceLookup(List<GalleryInstallSource>? installSources)
{
Dictionary<string, GalleryInstallSource> lookup = new(OrdinalIgnoreCase);
if (installSources is null)
{
return lookup;
}
foreach (var installSource in installSources)
{
var normalizedType = NormalizeSourceType(installSource.Type);
if (normalizedType is null || lookup.ContainsKey(normalizedType))
{
continue;
}
lookup[normalizedType] = installSource;
}
return lookup;
}
private static (IReadOnlyList<GallerySourceViewModel> SourceList, IReadOnlyDictionary<string, GallerySourceViewModel> SourceByKind) BuildSourceInfos(
IReadOnlyDictionary<string, GalleryInstallSource> installSourcesByType,
string? homepage)
{
Dictionary<string, GallerySourceViewModel> sourcesByKind = new(OrdinalIgnoreCase);
foreach (var installSource in installSourcesByType.Values)
{
var source = CreateSourceFromInstallSource(installSource);
if (source is null)
{
continue;
}
UpsertSource(sourcesByKind, source);
}
if (TryCreateSourceFromUri(homepage, out var homepageSource))
{
UpsertSource(sourcesByKind, homepageSource);
}
var orderedSources = sourcesByKind
.Values
.OrderBy(source => GetSortOrder(source.Kind))
.ThenBy(source => source.DisplayName, StringComparer.CurrentCultureIgnoreCase)
.ToList();
return (orderedSources, sourcesByKind);
}
private static int GetSortOrder(string sourceKind)
{
return sourceKind.ToLowerInvariant() switch
{
SourceTypeStore => 0,
SourceTypeWinGet => 1,
SourceTypeGitHub => 2,
SourceTypeWebsite => 3,
SourceTypeUnknown => 99,
_ => 98,
};
}
private static void UpsertSource(IDictionary<string, GallerySourceViewModel> sourcesByKind, GallerySourceViewModel source)
{
if (sourcesByKind.TryGetValue(source.Kind, out var existing))
{
sourcesByKind[source.Kind] = MergeSource(existing, source);
return;
}
sourcesByKind[source.Kind] = source;
}
private static GallerySourceViewModel MergeSource(GallerySourceViewModel existing, GallerySourceViewModel incoming)
{
return new GallerySourceViewModel(
existing.Kind,
existing.DisplayName,
!string.IsNullOrWhiteSpace(existing.Id) ? existing.Id : incoming.Id,
!string.IsNullOrWhiteSpace(existing.Uri) ? existing.Uri : incoming.Uri,
existing.IsKnown || incoming.IsKnown);
}
private static GallerySourceViewModel CreateSourceViewModel(
string kind,
string displayName,
string? id,
string? uri,
bool isKnown)
{
return new GallerySourceViewModel(
kind,
displayName,
id,
uri,
isKnown);
}
private static GallerySourceViewModel? CreateSourceFromInstallSource(GalleryInstallSource installSource)
{
var normalizedType = NormalizeSourceType(installSource.Type);
if (normalizedType is null)
{
return null;
}
return normalizedType switch
{
SourceTypeWinGet => CreateSourceViewModel(
SourceTypeWinGet,
Resources.gallery_item_source_name_winget,
installSource.Id,
uri: null,
isKnown: true),
SourceTypeStore => CreateSourceViewModel(
SourceTypeStore,
Resources.gallery_item_source_name_store,
installSource.Id,
uri: null,
isKnown: true),
SourceTypeUrl => CreateSourceFromUrl(installSource.Uri),
_ => CreateSourceViewModel(
SourceTypeUnknown,
FormatResource(Resources.gallery_item_source_name_unknown, normalizedType),
installSource.Id,
installSource.Uri,
isKnown: false),
};
}
private static GallerySourceViewModel CreateSourceFromUrl(string? url)
{
if (IsGitHubUri(url))
{
return CreateSourceViewModel(
SourceTypeGitHub,
Resources.gallery_item_source_name_github,
id: null,
uri: url,
isKnown: true);
}
return CreateSourceViewModel(
SourceTypeWebsite,
Resources.gallery_item_source_name_website,
id: null,
uri: url,
isKnown: true);
}
private static bool TryCreateSourceFromUri(string? uriValue, out GallerySourceViewModel source)
{
source = default!;
if (string.IsNullOrWhiteSpace(uriValue) || !Uri.TryCreate(uriValue, UriKind.Absolute, out _))
{
return false;
}
source = CreateSourceFromUrl(uriValue);
return true;
}
private static string? NormalizeSourceType(string? sourceType)
{
if (string.IsNullOrWhiteSpace(sourceType))
{
return null;
}
return sourceType.Trim().ToLowerInvariant();
}
private void ApplySourceDetails(string sourceKind, IReadOnlyList<GallerySourceDetailItemViewModel> details)
{
if (!_sourcesByKind.TryGetValue(sourceKind, out var source))
{
return;
}
source.SetDetails(details);
OnPropertyChanged(nameof(SourcesWithDetails));
OnPropertyChanged(nameof(HasSourceMetadataDetails));
}
private static List<GallerySourceDetailItemViewModel> CreateSourceDetails(WinGetPackageDetails details)
{
List<GallerySourceDetailItemViewModel> rows = [];
AddDetail(rows, Resources.gallery_source_detail_summary_label, details.Summary, uri: null);
AddDetail(rows, Resources.gallery_source_detail_description_label, details.Description, uri: null);
AddDetail(rows, Resources.gallery_source_detail_version_label, details.Version, uri: null);
AddDetail(rows, Resources.gallery_source_detail_package_label, details.Name, uri: null);
AddDetail(rows, Resources.gallery_source_detail_publisher_label, details.Publisher, details.PublisherUrl);
AddDetail(rows, Resources.gallery_source_detail_author_label, details.Author, uri: null);
AddDetail(rows, Resources.gallery_source_detail_license_label, details.License, details.LicenseUrl);
AddDetail(rows, Resources.gallery_source_detail_support_label, null, details.PublisherSupportUrl);
AddDetail(rows, Resources.gallery_source_detail_package_page_label, null, details.PackageUrl);
AddDetail(rows, Resources.gallery_source_detail_release_notes_label, details.ReleaseNotes, details.ReleaseNotesUrl);
for (var i = 0; i < details.DocumentationLinks.Count; i++)
{
var link = details.DocumentationLinks[i];
AddDetail(rows, link.Label, null, link.Url);
}
AddDetail(rows, Resources.gallery_source_detail_tags_label, BuildTagsText(details.Tags), uri: null);
return rows;
}
private static void AddDetail(ICollection<GallerySourceDetailItemViewModel> target, string label, string? value, string? uri)
{
var normalizedValue = ToNullIfWhiteSpace(value);
var normalizedUri = TryCreateUri(uri);
if (normalizedValue is null && normalizedUri is null)
{
return;
}
target.Add(new GallerySourceDetailItemViewModel(label, normalizedValue ?? normalizedUri!.AbsoluteUri, normalizedUri));
}
private static Uri? TryCreateUri(string? value)
{
if (string.IsNullOrWhiteSpace(value) || !Uri.TryCreate(value, UriKind.Absolute, out var uri))
{
return null;
}
return uri;
}
private static string? ToNullIfWhiteSpace(string? value)
{
if (string.IsNullOrWhiteSpace(value))
{
return null;
}
return value.Trim();
}
private static string BuildTagsText(IReadOnlyList<string> tags)
{
if (tags.Count == 0)
{
return string.Empty;
}
StringBuilder builder = new();
for (var i = 0; i < tags.Count; i++)
{
if (string.IsNullOrWhiteSpace(tags[i]))
{
continue;
}
if (builder.Length > 0)
{
builder.Append(", ");
}
builder.Append(tags[i]);
}
return builder.ToString();
}
private static bool IsGitHubUri(string? value)
{
if (string.IsNullOrWhiteSpace(value) || !Uri.TryCreate(value, UriKind.Absolute, out var uri))
{
return false;
}
return uri.Host.Equals("github.com", StringComparison.OrdinalIgnoreCase)
|| uri.Host.EndsWith(".github.com", StringComparison.OrdinalIgnoreCase);
}
private static bool AreStatusTextsEquivalent(string first, string second)
{
return string.Equals(NormalizeStatusText(first), NormalizeStatusText(second), StringComparison.OrdinalIgnoreCase);
}
private static string FormatResource(string format, params object?[] args)
{
return string.Format(System.Globalization.CultureInfo.CurrentCulture, format, args);
}
private static string NormalizeStatusText(string value)
{
return string.IsNullOrWhiteSpace(value) ? string.Empty : value.Trim().TrimEnd('.');
}
public async Task RefreshWinGetPackageInfoAsync(WinGetPackageOperationKind completedOperationKind = WinGetPackageOperationKind.Install)
{
if (_winGetPackageStatusService is not null && !string.IsNullOrWhiteSpace(WinGetId))
{
var infos = await _winGetPackageStatusService.TryGetPackageInfosAsync([WinGetId]);
if (infos is not null && infos.TryGetValue(WinGetId, out var packageInfo))
{
ApplyWinGetPackageInfo(packageInfo);
return;
}
}
IsInstalled = completedOperationKind != WinGetPackageOperationKind.Uninstall;
IsInstalledStateKnown = true;
IsUpdateAvailable = false;
IsUpdateStateKnown = true;
}
private void ApplyOptimisticTrackedCompletion(WinGetPackageOperationKind completedOperationKind)
{
IsInstalled = completedOperationKind != WinGetPackageOperationKind.Uninstall;
IsInstalledStateKnown = true;
IsUpdateAvailable = false;
IsUpdateStateKnown = true;
}
private ImageSource CreateImageSource(Uri iconUri)
{
try
{
return new BitmapImage(iconUri);
}
catch (Exception ex)
{
LogIconLoadFailed(_logger, iconUri.AbsoluteUri, ex);
return new BitmapImage(PlaceholderIconUri);
}
}
private static void LogWinGetInstallFailed(ILogger logger, Exception exception)
{
LogWinGetInstallFailedMessage(logger, exception);
}
private static void LogIconLoadFailed(ILogger logger, string iconUri, Exception exception)
{
LogIconLoadFailedMessage(logger, iconUri, exception);
}
partial void OnIsInstalledChanged(bool value)
{
OnPropertyChanged(nameof(ShowInstalledBadge));
OnPropertyChanged(nameof(ShowInstallButton));
OnPropertyChanged(nameof(InstallStatusText));
OnPropertyChanged(nameof(WinGetStatusText));
OnPropertyChanged(nameof(ShowWinGetStatusDetails));
OnPropertyChanged(nameof(ShowInstallViaWinGetButton));
OnPropertyChanged(nameof(CanInstallViaWinGet));
OnPropertyChanged(nameof(InstallViaWinGetText));
OnPropertyChanged(nameof(ShowWinGetActionControls));
InstallViaWinGetCommand.NotifyCanExecuteChanged();
}
partial void OnIsInstalledStateKnownChanged(bool value)
{
OnPropertyChanged(nameof(InstallStatusText));
OnPropertyChanged(nameof(WinGetStatusText));
OnPropertyChanged(nameof(ShowWinGetStatusDetails));
}
partial void OnIsUpdateAvailableChanged(bool value)
{
OnPropertyChanged(nameof(ShowInstalledBadge));
OnPropertyChanged(nameof(ShowInstallButton));
OnPropertyChanged(nameof(ShowUpdateBadge));
OnPropertyChanged(nameof(InstallStatusText));
OnPropertyChanged(nameof(WinGetStatusText));
OnPropertyChanged(nameof(ShowWinGetStatusDetails));
OnPropertyChanged(nameof(ShowInstallViaWinGetButton));
OnPropertyChanged(nameof(CanInstallViaWinGet));
OnPropertyChanged(nameof(InstallViaWinGetText));
OnPropertyChanged(nameof(ShowWinGetActionControls));
InstallViaWinGetCommand.NotifyCanExecuteChanged();
}
partial void OnIsWinGetActionInProgressChanged(bool value)
{
OnPropertyChanged(nameof(CanInstallViaWinGet));
OnPropertyChanged(nameof(ShowWinGetActionIndicator));
OnPropertyChanged(nameof(ShowWinGetActionStatus));
OnPropertyChanged(nameof(ShowCancelWinGetActionButton));
OnPropertyChanged(nameof(ShowWinGetActionControls));
InstallViaWinGetCommand.NotifyCanExecuteChanged();
CancelWinGetActionCommand.NotifyCanExecuteChanged();
}
partial void OnCanCancelWinGetActionChanged(bool value)
{
OnPropertyChanged(nameof(ShowCancelWinGetActionButton));
CancelWinGetActionCommand.NotifyCanExecuteChanged();
}
partial void OnWinGetActionMessageChanged(string? value)
{
OnPropertyChanged(nameof(HasWinGetActionMessage));
OnPropertyChanged(nameof(ShowWinGetActionStatus));
}
}

View File

@@ -0,0 +1,41 @@
// 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.ExtensionGallery.Models;
using Microsoft.CmdPal.Common.WinGet.Services;
using Microsoft.Extensions.Logging;
namespace Microsoft.CmdPal.UI.ViewModels.Gallery;
public sealed class ExtensionGalleryItemViewModelFactory
{
private readonly ILogger<ExtensionGalleryItemViewModel> _logger;
private readonly IWinGetPackageManagerService? _winGetPackageManagerService;
private readonly IWinGetOperationTrackerService? _winGetOperationTrackerService;
private readonly IWinGetPackageStatusService? _winGetPackageStatusService;
public ExtensionGalleryItemViewModelFactory(
ILogger<ExtensionGalleryItemViewModel> logger,
IWinGetPackageManagerService? winGetPackageManagerService = null,
IWinGetPackageStatusService? winGetPackageStatusService = null,
IWinGetOperationTrackerService? winGetOperationTrackerService = null)
{
_logger = logger;
_winGetPackageManagerService = winGetPackageManagerService;
_winGetPackageStatusService = winGetPackageStatusService;
_winGetOperationTrackerService = winGetOperationTrackerService;
}
public ExtensionGalleryItemViewModel Create(GalleryExtensionEntry entry)
{
ArgumentNullException.ThrowIfNull(entry);
return new ExtensionGalleryItemViewModel(
entry,
_logger,
_winGetPackageManagerService,
_winGetPackageStatusService,
_winGetOperationTrackerService);
}
}

View File

@@ -0,0 +1,41 @@
// 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.Text;
using Microsoft.UI.Xaml.Media;
using Microsoft.UI.Xaml.Media.Imaging;
using Resources = Microsoft.CmdPal.UI.ViewModels.Properties.Resources;
namespace Microsoft.CmdPal.UI.ViewModels.Gallery;
public sealed class ExtensionGalleryScreenshotViewModel
{
private static readonly CompositeFormat DisplayNameFormat
= CompositeFormat.Parse(Resources.gallery_screenshot_display_name!);
public ExtensionGalleryScreenshotViewModel(Uri uri, int index)
{
ArgumentNullException.ThrowIfNull(uri);
Uri = uri;
Index = index;
DisplayName = string.Format(System.Globalization.CultureInfo.CurrentCulture, DisplayNameFormat, index + 1);
}
public Uri Uri { get; }
public int Index { get; }
public string DisplayName { get; }
public ImageSource ImageSource => field ??= CreateImageSource(Uri);
private static ImageSource CreateImageSource(Uri uri)
{
BitmapImage bitmap = new();
bitmap.DecodePixelWidth = 720;
bitmap.UriSource = uri;
return bitmap;
}
}

View File

@@ -0,0 +1,13 @@
// 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.Gallery;
public enum ExtensionGallerySortOption
{
Featured = 0,
Name = 1,
Author = 2,
InstallationStatus = 3,
}

View File

@@ -0,0 +1,766 @@
// 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.Collections.ObjectModel;
using System.Globalization;
using System.Text;
using CommunityToolkit.Mvvm.ComponentModel;
using CommunityToolkit.Mvvm.Input;
using Microsoft.CmdPal.Common.ExtensionGallery.Models;
using Microsoft.CmdPal.Common.ExtensionGallery.Services;
using Microsoft.CmdPal.Common.Services;
using Microsoft.CmdPal.Common.WinGet.Models;
using Microsoft.CmdPal.Common.WinGet.Services;
using Microsoft.CmdPal.UI.ViewModels.Properties;
using Microsoft.CommandPalette.Extensions.Toolkit;
using Microsoft.Extensions.Logging;
namespace Microsoft.CmdPal.UI.ViewModels.Gallery;
public sealed partial class ExtensionGalleryViewModel : ObservableObject, IDisposable
{
private const string WinGetSourceType = "winget";
private const string GenericErrorIconGlyph = "\u26A0";
private const string RateLimitedErrorIconGlyph = "\U0001F984";
private static readonly TimeSpan WinGetRefreshTimeout = TimeSpan.FromSeconds(5);
private static readonly StringComparer SortStringComparer = StringComparer.CurrentCultureIgnoreCase;
private static readonly CompositeFormat LabelGalleryExtensionsAvailable
= CompositeFormat.Parse(Resources.gallery_n_extensions_available!);
private static readonly CompositeFormat LabelGalleryExtensionsFound
= CompositeFormat.Parse(Resources.gallery_n_extensions_found!);
private static readonly Action<ILogger, Exception?> LogCheckInstalledExtensionsError =
LoggerMessage.Define(
LogLevel.Error,
new EventId(1, nameof(LogCheckInstalledExtensionsError)),
"Failed to check installed extensions");
private static readonly Action<ILogger, Exception?> LogRefreshWinGetCatalogsError =
LoggerMessage.Define(
LogLevel.Error,
new EventId(2, nameof(LogRefreshWinGetCatalogsError)),
"Failed to refresh WinGet catalogs");
private static readonly Action<ILogger, Exception?> LogCheckWinGetPackageStatusError =
LoggerMessage.Define(
LogLevel.Error,
new EventId(3, nameof(LogCheckWinGetPackageStatusError)),
"Failed to check WinGet package status");
private readonly IExtensionGalleryService _galleryService;
private readonly IExtensionService _extensionService;
private readonly ILogger<ExtensionGalleryViewModel> _logger;
private readonly ExtensionGalleryItemViewModelFactory _galleryExtensionViewModelFactory;
private readonly IWinGetPackageManagerService? _winGetPackageManagerService;
private readonly IWinGetOperationTrackerService? _winGetOperationTrackerService;
private readonly IWinGetPackageStatusService? _winGetPackageStatusService;
private readonly TaskScheduler _uiScheduler;
private readonly Lock _entriesLock = new();
private readonly List<ExtensionGalleryItemViewModel> _allEntries = [];
private readonly Dictionary<string, List<ExtensionGalleryItemViewModel>> _entriesByWinGetId = new(StringComparer.OrdinalIgnoreCase);
private CancellationTokenSource _cts = new();
private bool _disposed;
public ObservableCollection<ExtensionGalleryItemViewModel> FilteredEntries { get; } = [];
[ObservableProperty]
public partial IReadOnlyList<Uri> CarouselIconUris { get; set; } = [];
private string _searchText = string.Empty;
public string SearchText
{
get => _searchText;
set
{
if (_searchText != value)
{
_searchText = value;
OnPropertyChanged();
ApplyFilter();
}
}
}
public string ItemCounterText
{
get
{
var hasQuery = !string.IsNullOrWhiteSpace(_searchText);
int count;
if (hasQuery)
{
count = FilteredEntries.Count;
}
else
{
lock (_entriesLock)
{
count = _allEntries.Count;
}
}
var format = hasQuery ? LabelGalleryExtensionsFound : LabelGalleryExtensionsAvailable;
return string.Format(CultureInfo.CurrentCulture, format, count);
}
}
[ObservableProperty]
public partial bool IsLoading { get; set; }
[ObservableProperty]
public partial bool HasError { get; set; }
[ObservableProperty]
public partial string? ErrorMessage { get; set; }
[ObservableProperty]
public partial bool FromCache { get; set; }
[ObservableProperty]
public partial bool UsedFallbackCache { get; set; }
[ObservableProperty]
public partial bool IsRateLimitedError { get; set; }
[ObservableProperty]
public partial ExtensionGallerySortOption SelectedSortOption { get; set; } = ExtensionGallerySortOption.Featured;
public bool ShowNoResultsPanel => !HasError && !string.IsNullOrWhiteSpace(_searchText) && FilteredEntries.Count == 0;
public bool HasResults => !IsLoading && !ShowNoResultsPanel && FilteredEntries.Count > 0;
public bool ShowErrorSurface => HasError && FilteredEntries.Count == 0;
public bool ShowErrorInfoBar => HasError && !ShowErrorSurface;
public string ErrorDisplayIconGlyph => IsRateLimitedError ? RateLimitedErrorIconGlyph : GenericErrorIconGlyph;
public string ErrorDisplayTitle => IsRateLimitedError
? Resources.gallery_error_rate_limited_title
: Resources.gallery_error_generic_title;
public string ErrorDisplayMessage => IsRateLimitedError
? Resources.gallery_error_rate_limited_message
: !string.IsNullOrWhiteSpace(ErrorMessage)
? ErrorMessage
: Resources.gallery_error_generic_message;
public bool IsCustomFeed => _galleryService.IsCustomFeed;
public string CustomFeedUrl => _galleryService.GetBaseUrl();
public bool IsSortByFeaturedSelected => SelectedSortOption == ExtensionGallerySortOption.Featured;
public bool IsSortByNameSelected => SelectedSortOption == ExtensionGallerySortOption.Name;
public bool IsSortByAuthorSelected => SelectedSortOption == ExtensionGallerySortOption.Author;
public bool IsSortByInstallationStatusSelected => SelectedSortOption == ExtensionGallerySortOption.InstallationStatus;
public ExtensionGalleryViewModel(
IExtensionGalleryService galleryService,
IExtensionService extensionService,
ILogger<ExtensionGalleryViewModel> logger,
ExtensionGalleryItemViewModelFactory galleryExtensionViewModelFactory,
IWinGetPackageManagerService? winGetPackageManagerService = null,
IWinGetPackageStatusService? winGetPackageStatusService = null,
IWinGetOperationTrackerService? winGetOperationTrackerService = null,
TaskScheduler? uiScheduler = null)
{
_galleryService = galleryService;
_extensionService = extensionService;
_logger = logger;
_galleryExtensionViewModelFactory = galleryExtensionViewModelFactory;
_winGetPackageManagerService = winGetPackageManagerService;
_winGetPackageStatusService = winGetPackageStatusService;
_winGetOperationTrackerService = winGetOperationTrackerService;
_uiScheduler = uiScheduler ?? TaskScheduler.Current;
RefreshCommand = new AsyncRelayCommand(RefreshAsync);
if (_winGetOperationTrackerService is not null)
{
_winGetOperationTrackerService.OperationStarted += OnWinGetOperationStarted;
_winGetOperationTrackerService.OperationUpdated += OnWinGetOperationUpdated;
_winGetOperationTrackerService.OperationCompleted += OnWinGetOperationCompleted;
}
}
public IAsyncRelayCommand RefreshCommand { get; }
[RelayCommand]
private void SortByFeatured()
{
SelectedSortOption = ExtensionGallerySortOption.Featured;
}
[RelayCommand]
private void SortByName()
{
SelectedSortOption = ExtensionGallerySortOption.Name;
}
[RelayCommand]
private void SortByAuthor()
{
SelectedSortOption = ExtensionGallerySortOption.Author;
}
[RelayCommand]
private void SortByInstallationStatus()
{
SelectedSortOption = ExtensionGallerySortOption.InstallationStatus;
}
public async Task LoadAsync()
{
await FetchCoreAsync(_galleryService.FetchExtensionsAsync, refreshInstallationStatus: false);
}
private async Task RefreshAsync()
{
await FetchCoreAsync(_galleryService.RefreshAsync, refreshInstallationStatus: true);
}
private async Task FetchCoreAsync(Func<CancellationToken, Task<GalleryFetchResult>> fetchFunc, bool refreshInstallationStatus)
{
var cts = ResetCancellation();
IsLoading = true;
HasError = false;
ErrorMessage = null;
FromCache = false;
UsedFallbackCache = false;
IsRateLimitedError = false;
NotifyStateChanged();
try
{
var result = await RunInBackgroundAsync(() => fetchFunc(cts.Token), cts.Token);
cts.Token.ThrowIfCancellationRequested();
ApplyEntries(result.Extensions);
HasError = result.HasError;
ErrorMessage = result.ErrorMessage;
FromCache = result.FromCache;
UsedFallbackCache = result.UsedFallbackCache;
IsRateLimitedError = result.IsRateLimited;
ApplyFilter();
StartBackgroundRefresh(refreshInstallationStatus, cts.Token);
}
catch (OperationCanceledException)
{
// Cancelled by navigation or dispose — not an error
}
catch (Exception ex)
{
HasError = true;
ErrorMessage = ex.Message;
IsRateLimitedError = false;
}
finally
{
IsLoading = false;
NotifyStateChanged();
}
}
private void ApplyEntries(IReadOnlyList<GalleryExtensionEntry> entries)
{
lock (_entriesLock)
{
_allEntries.Clear();
foreach (var entry in entries)
{
_allEntries.Add(CreateEntryViewModel(entry));
}
RebuildWinGetEntryIndex();
}
ApplyCurrentWinGetOperations();
UpdateCarouselIcons();
}
private void UpdateCarouselIcons()
{
List<Uri> candidates;
lock (_entriesLock)
{
candidates = new List<Uri>(_allEntries.Count);
foreach (var entry in _allEntries)
{
if (entry.IconUri.Scheme != "ms-appx")
{
candidates.Add(entry.IconUri);
}
}
}
if (candidates.Count == 0)
{
CarouselIconUris = [];
return;
}
// Fisher-Yates shuffle for random selection
var rng = Random.Shared;
for (var i = candidates.Count - 1; i > 0; i--)
{
var j = rng.Next(i + 1);
(candidates[i], candidates[j]) = (candidates[j], candidates[i]);
}
// Take up to VisibleCount + buffer for smooth wrapping
var count = Math.Min(candidates.Count, 12);
CarouselIconUris = candidates.GetRange(0, count);
}
private void StartBackgroundRefresh(
bool refreshInstallationStatus,
CancellationToken cancellationToken)
{
_ = CheckInstalledAsync(
cancellationToken,
refreshInstalledExtensions: refreshInstallationStatus,
refreshWinGetCatalogs: refreshInstallationStatus);
}
private CancellationTokenSource ResetCancellation()
{
var oldCts = _cts;
var newCts = new CancellationTokenSource();
_cts = newCts;
oldCts.Cancel();
oldCts.Dispose();
return newCts;
}
private async Task CheckInstalledAsync(
CancellationToken cancellationToken,
bool refreshInstalledExtensions = false,
bool refreshWinGetCatalogs = false)
{
List<ExtensionGalleryItemViewModel> snapshot;
try
{
var installedExtensions = refreshInstalledExtensions
? await RunInBackgroundAsync(
() => _extensionService.RefreshInstalledExtensionsAsync(includeDisabledExtensions: true),
cancellationToken)
: await RunInBackgroundAsync(
() => _extensionService.GetInstalledExtensionsAsync(includeDisabledExtensions: true),
cancellationToken);
cancellationToken.ThrowIfCancellationRequested();
var installedPfns = new HashSet<string>(
installedExtensions
.Select(e => e.PackageFamilyName)
.Where(pfn => !string.IsNullOrEmpty(pfn)),
StringComparer.OrdinalIgnoreCase);
lock (_entriesLock)
{
snapshot = [.. _allEntries];
}
foreach (var entry in snapshot)
{
if (!string.IsNullOrEmpty(entry.PackageFamilyName))
{
entry.IsInstalled = installedPfns.Contains(entry.PackageFamilyName);
entry.IsInstalledStateKnown = true;
}
}
QueueApplyFilter();
}
catch (OperationCanceledException)
{
// Cancelled — non-critical
}
catch (Exception ex)
{
// Non-critical; leave IsInstalled as false
LogCheckInstalledExtensionsError(_logger, ex);
}
if (_winGetPackageStatusService is null)
{
return;
}
if (refreshWinGetCatalogs && _winGetPackageManagerService is not null && _winGetPackageManagerService.State.IsAvailable)
{
try
{
using var refreshCts = CancellationTokenSource.CreateLinkedTokenSource(cancellationToken);
refreshCts.CancelAfter(WinGetRefreshTimeout);
await RunInBackgroundAsync(
() => _winGetPackageManagerService.RefreshCatalogsAsync(refreshCts.Token),
refreshCts.Token);
refreshCts.Token.ThrowIfCancellationRequested();
}
catch (OperationCanceledException)
{
return;
}
catch (Exception ex)
{
LogRefreshWinGetCatalogsError(_logger, ex);
return;
}
}
try
{
lock (_entriesLock)
{
snapshot = [.. _allEntries];
}
var wingetIds = snapshot
.Select(entry => entry.WinGetId)
.Where(static id => !string.IsNullOrWhiteSpace(id))
.Distinct(StringComparer.OrdinalIgnoreCase)
.Cast<string>()
.ToArray();
if (wingetIds.Length == 0)
{
return;
}
using var wingetCts = CancellationTokenSource.CreateLinkedTokenSource(cancellationToken);
wingetCts.CancelAfter(WinGetRefreshTimeout);
var wingetInfos = await RunInBackgroundAsync(
() => _winGetPackageStatusService.TryGetPackageInfosAsync(wingetIds, wingetCts.Token),
wingetCts.Token);
wingetCts.Token.ThrowIfCancellationRequested();
if (wingetInfos is null)
{
return;
}
foreach (var entry in snapshot)
{
if (string.IsNullOrWhiteSpace(entry.WinGetId))
{
continue;
}
if (!wingetInfos.TryGetValue(entry.WinGetId, out var packageInfo))
{
continue;
}
entry.ApplyWinGetPackageInfo(packageInfo);
}
QueueApplyFilter();
}
catch (OperationCanceledException)
{
// Cancelled or timed out — non-critical.
}
catch (Exception ex)
{
// Non-critical; keep the gallery visible with its existing state.
LogCheckWinGetPackageStatusError(_logger, ex);
}
}
private ExtensionGalleryItemViewModel CreateEntryViewModel(GalleryExtensionEntry entry)
{
return _galleryExtensionViewModelFactory.Create(entry);
}
private void ApplyFilter()
{
List<ExtensionGalleryItemViewModel> snapshot;
lock (_entriesLock)
{
snapshot = [.. _allEntries];
}
var filtered = ListHelpers.FilterList(snapshot, _searchText, Matches).ToList();
SortEntries(filtered);
ListHelpers.InPlaceUpdateList(FilteredEntries, filtered);
NotifyStateChanged();
}
private void SortEntries(List<ExtensionGalleryItemViewModel> entries)
{
switch (SelectedSortOption)
{
case ExtensionGallerySortOption.Name:
entries.Sort(CompareByName);
break;
case ExtensionGallerySortOption.Author:
entries.Sort(CompareByAuthor);
break;
case ExtensionGallerySortOption.InstallationStatus:
entries.Sort(CompareByInstallationStatus);
break;
}
}
private static int Matches(string query, ExtensionGalleryItemViewModel item)
{
if (string.IsNullOrWhiteSpace(query))
{
return 100;
}
return Contains(item.Title, query)
|| Contains(item.Description, query)
|| Contains(item.AuthorName, query)
|| Contains(item.Tags, query)
? 100
: 0;
}
private static bool Contains(string? haystack, string needle)
{
return !string.IsNullOrEmpty(haystack) && haystack.Contains(needle, StringComparison.OrdinalIgnoreCase);
}
private static bool Contains(IReadOnlyList<string>? values, string needle)
{
if (values is null || values.Count == 0)
{
return false;
}
for (var i = 0; i < values.Count; i++)
{
if (!string.IsNullOrWhiteSpace(values[i]) && values[i].Contains(needle, StringComparison.OrdinalIgnoreCase))
{
return true;
}
}
return false;
}
private void NotifyStateChanged()
{
OnPropertyChanged(nameof(ItemCounterText));
OnPropertyChanged(nameof(HasResults));
OnPropertyChanged(nameof(ShowNoResultsPanel));
OnPropertyChanged(nameof(ShowErrorSurface));
OnPropertyChanged(nameof(ShowErrorInfoBar));
OnPropertyChanged(nameof(ErrorDisplayIconGlyph));
OnPropertyChanged(nameof(ErrorDisplayTitle));
OnPropertyChanged(nameof(ErrorDisplayMessage));
}
private void RebuildWinGetEntryIndex()
{
_entriesByWinGetId.Clear();
foreach (var entry in _allEntries)
{
if (string.IsNullOrWhiteSpace(entry.WinGetId))
{
continue;
}
if (!_entriesByWinGetId.TryGetValue(entry.WinGetId, out var entries))
{
entries = [];
_entriesByWinGetId[entry.WinGetId] = entries;
}
entries.Add(entry);
}
}
private void ApplyCurrentWinGetOperations()
{
if (_winGetOperationTrackerService is null)
{
return;
}
List<ExtensionGalleryItemViewModel> snapshot;
lock (_entriesLock)
{
snapshot = [.. _allEntries];
}
foreach (var entry in snapshot)
{
if (string.IsNullOrWhiteSpace(entry.WinGetId))
{
continue;
}
var operation = _winGetOperationTrackerService.GetLatestOperation(entry.WinGetId);
if (operation is not null)
{
entry.ApplyTrackedOperation(operation);
}
}
}
private void OnWinGetOperationStarted(object? sender, WinGetPackageOperationEventArgs e)
{
QueueTrackedOperationApplication(e.Operation, refreshPackageStatus: false);
}
private void OnWinGetOperationUpdated(object? sender, WinGetPackageOperationEventArgs e)
{
QueueTrackedOperationApplication(e.Operation, refreshPackageStatus: false);
}
private void OnWinGetOperationCompleted(object? sender, WinGetPackageOperationEventArgs e)
{
QueueTrackedOperationApplication(e.Operation, refreshPackageStatus: true);
}
private void QueueTrackedOperationApplication(WinGetPackageOperation operation, bool refreshPackageStatus)
{
_ = Task.Factory.StartNew(
async () => await ApplyTrackedOperationAsync(operation, refreshPackageStatus),
CancellationToken.None,
TaskCreationOptions.DenyChildAttach,
_uiScheduler).Unwrap();
}
private async Task ApplyTrackedOperationAsync(WinGetPackageOperation operation, bool refreshPackageStatus)
{
List<ExtensionGalleryItemViewModel>? entries;
lock (_entriesLock)
{
if (!_entriesByWinGetId.TryGetValue(operation.PackageId, out entries))
{
return;
}
// Snapshot to iterate outside the lock
entries = [.. entries];
}
foreach (var entry in entries)
{
entry.ApplyTrackedOperation(operation);
}
QueueApplyFilter();
if (!refreshPackageStatus || !operation.IsCompleted || operation.State != WinGetPackageOperationState.Succeeded)
{
return;
}
foreach (var entry in entries)
{
await entry.RefreshWinGetPackageInfoAsync(operation.Kind);
}
}
private void QueueApplyFilter()
{
_ = Task.Factory.StartNew(
ApplyFilter,
CancellationToken.None,
TaskCreationOptions.DenyChildAttach,
_uiScheduler);
}
private static Task<T> RunInBackgroundAsync<T>(Func<Task<T>> operation, CancellationToken cancellationToken)
{
return Task.Run(operation, cancellationToken);
}
private static int CompareByName(ExtensionGalleryItemViewModel left, ExtensionGalleryItemViewModel right)
{
var result = SortStringComparer.Compare(left.DisplayTitle, right.DisplayTitle);
if (result != 0)
{
return result;
}
result = SortStringComparer.Compare(left.DisplayAuthorName, right.DisplayAuthorName);
if (result != 0)
{
return result;
}
return SortStringComparer.Compare(left.Id, right.Id);
}
private static int CompareByAuthor(ExtensionGalleryItemViewModel left, ExtensionGalleryItemViewModel right)
{
var result = SortStringComparer.Compare(left.DisplayAuthorName, right.DisplayAuthorName);
if (result != 0)
{
return result;
}
return CompareByName(left, right);
}
private static int CompareByInstallationStatus(ExtensionGalleryItemViewModel left, ExtensionGalleryItemViewModel right)
{
var result = GetInstallationStatusSortRank(left).CompareTo(GetInstallationStatusSortRank(right));
if (result != 0)
{
return result;
}
return CompareByName(left, right);
}
private static int GetInstallationStatusSortRank(ExtensionGalleryItemViewModel entry)
{
if (entry.IsUpdateAvailable)
{
return 0;
}
if (entry.IsInstalled)
{
return 1;
}
if (entry.IsInstalledStateKnown)
{
return 2;
}
return 3;
}
partial void OnSelectedSortOptionChanged(ExtensionGallerySortOption value)
{
OnPropertyChanged(nameof(IsSortByFeaturedSelected));
OnPropertyChanged(nameof(IsSortByNameSelected));
OnPropertyChanged(nameof(IsSortByAuthorSelected));
OnPropertyChanged(nameof(IsSortByInstallationStatusSelected));
ApplyFilter();
}
public void Dispose()
{
if (_disposed)
{
return;
}
_disposed = true;
_cts.Cancel();
_cts.Dispose();
if (_winGetOperationTrackerService is not null)
{
_winGetOperationTrackerService.OperationStarted -= OnWinGetOperationStarted;
_winGetOperationTrackerService.OperationUpdated -= OnWinGetOperationUpdated;
_winGetOperationTrackerService.OperationCompleted -= OnWinGetOperationCompleted;
}
}
}

View File

@@ -0,0 +1,25 @@
// 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.Gallery;
public sealed class GallerySourceDetailItemViewModel
{
public string Label { get; }
public string Value { get; }
public Uri? LinkUri { get; }
public bool HasLink => LinkUri is not null;
public bool HasNoLink => !HasLink;
public GallerySourceDetailItemViewModel(string label, string value, Uri? linkUri)
{
Label = label;
Value = value;
LinkUri = linkUri;
}
}

View File

@@ -0,0 +1,55 @@
// 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.Collections.ObjectModel;
using System.Collections.Specialized;
using CommunityToolkit.Mvvm.ComponentModel;
namespace Microsoft.CmdPal.UI.ViewModels.Gallery;
public sealed partial class GallerySourceViewModel : ObservableObject
{
public string Kind { get; }
public string DisplayName { get; }
public string? Id { get; }
public string? Uri { get; }
public bool IsKnown { get; }
public ObservableCollection<GallerySourceDetailItemViewModel> Details { get; } = [];
public bool HasDetails => Details.Count > 0;
public GallerySourceViewModel(
string kind,
string displayName,
string? id,
string? uri,
bool isKnown)
{
Kind = kind;
DisplayName = displayName;
Id = id;
Uri = uri;
IsKnown = isKnown;
Details.CollectionChanged += OnDetailsCollectionChanged;
}
public void SetDetails(IReadOnlyList<GallerySourceDetailItemViewModel> details)
{
Details.Clear();
for (var i = 0; i < details.Count; i++)
{
Details.Add(details[i]);
}
}
private void OnDetailsCollectionChanged(object? sender, NotifyCollectionChangedEventArgs e)
{
OnPropertyChanged(nameof(HasDetails));
}
}

View File

@@ -92,6 +92,8 @@ public partial class ListViewModel : PageViewModel, IDisposable
public bool IsMainPage { get; init; }
public bool IsTokenSearch { get; private set; }
public bool HasCustomDebounceLogic => IsMainPage;
private bool _isDynamic;
@@ -957,6 +959,11 @@ public partial class ListViewModel : PageViewModel, IDisposable
Filters?.InitializeProperties();
UpdateProperty(nameof(Filters));
if (model is IExtendedAttributesProvider haveProperties)
{
LoadExtendedAttributes(haveProperties.GetProperties().AsReadOnly());
}
FetchItems(true);
model.ItemsChanged += Model_ItemsChanged;
}
@@ -972,6 +979,17 @@ public partial class ListViewModel : PageViewModel, IDisposable
};
}
private void LoadExtendedAttributes(IReadOnlyDictionary<string, object> properties)
{
// Check if this is a token page
if (properties.TryGetValue("TokenSearch", out var isTokenSearchObj) &&
isTokenSearchObj is bool isTokenSearch)
{
IsTokenSearch = isTokenSearch;
UpdateProperty(nameof(IsTokenSearch));
}
}
public void LoadMoreIfNeeded()
{
var model = _model.Unsafe;

View File

@@ -0,0 +1,12 @@
// 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;
/// <summary>
/// Broadcast when the user exits dock edit mode on any monitor.
/// All DockControls should respond by saving or discarding their changes.
/// </summary>
/// <param name="Discard">True to discard changes; false to save them.</param>
public record ExitDockEditModeMessage(bool Discard);

View File

@@ -0,0 +1,10 @@
// Copyright (c) Microsoft Corporation
// The Microsoft Corporation licenses this file to you under the MIT license.
// See the LICENSE file in the project root for more information.
using System.ComponentModel;
using Microsoft.CommandPalette.Extensions;
namespace Microsoft.CmdPal.UI.ViewModels.Messages;
public record FocusParamMessage(ParameterValueRunViewModel Parameter);

View File

@@ -12,4 +12,5 @@ public record PinToDockMessage(
bool Pin,
DockPinSide Side = DockPinSide.Start,
bool? ShowTitles = null,
bool? ShowSubtitles = null);
bool? ShowSubtitles = null,
string? MonitorDeviceId = null);

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.Collections.Generic;
using Microsoft.CmdPal.UI.ViewModels.Settings;
namespace Microsoft.CmdPal.UI.ViewModels.Messages;
@@ -12,4 +13,5 @@ public record ShowPinToDockDialogMessage(
string Title,
string Subtitle,
IconInfoViewModel? Icon,
DockSide DockSide);
DockSide DockSide,
IReadOnlyList<Microsoft.CmdPal.UI.ViewModels.Models.MonitorInfo>? AvailableMonitors = null);

View File

@@ -194,24 +194,20 @@ public partial class ExtensionService : IExtensionService, IDisposable
await _getInstalledExtensionsLock.WaitAsync();
try
{
if (_installedExtensions.Count == 0)
{
var extensions = await GetInstalledAppExtensionsAsync();
foreach (var extension in extensions)
{
try
{
var wrappers = await CreateWrappersForExtension(extension);
UpdateExtensionsListsFromWrappers(wrappers);
}
catch (Exception ex)
{
Logger.LogError($"Failed to load extension '{extension.DisplayName}': {ex.Message}");
}
}
}
return await GetInstalledExtensionsAsyncUnderLock(includeDisabledExtensions, refresh: false);
}
finally
{
_getInstalledExtensionsLock.Release();
}
}
return includeDisabledExtensions ? _installedExtensions : _enabledExtensions;
public async Task<IEnumerable<IExtensionWrapper>> RefreshInstalledExtensionsAsync(bool includeDisabledExtensions = false)
{
await _getInstalledExtensionsLock.WaitAsync();
try
{
return await GetInstalledExtensionsAsyncUnderLock(includeDisabledExtensions, refresh: true);
}
finally
{
@@ -240,6 +236,65 @@ public partial class ExtensionService : IExtensionService, IDisposable
}
}
private static async Task<IEnumerable<IExtensionWrapper>> GetInstalledExtensionsAsyncUnderLock(bool includeDisabledExtensions, bool refresh)
{
if (refresh)
{
await RebuildInstalledExtensionsCacheAsync();
}
else if (_installedExtensions.Count == 0)
{
var extensions = await GetInstalledAppExtensionsAsync();
foreach (var extension in extensions)
{
try
{
var wrappers = await CreateWrappersForExtension(extension);
UpdateExtensionsListsFromWrappers(wrappers);
}
catch (Exception ex)
{
Logger.LogError($"Failed to load extension '{extension.DisplayName}': {ex.Message}", ex);
}
}
}
return includeDisabledExtensions ? _installedExtensions : _enabledExtensions;
}
private static async Task RebuildInstalledExtensionsCacheAsync()
{
var previouslyEnabledExtensionIds = new HashSet<string>(
_enabledExtensions.Select(static extension => extension.ExtensionUniqueId),
StringComparer.Ordinal);
var previouslyInstalledExtensionIds = new HashSet<string>(
_installedExtensions.Select(static extension => extension.ExtensionUniqueId),
StringComparer.Ordinal);
var extensions = await GetInstalledAppExtensionsAsync();
List<ExtensionWrapper> refreshedWrappers = [];
foreach (var extension in extensions)
{
var wrappers = await CreateWrappersForExtension(extension);
refreshedWrappers.AddRange(wrappers);
}
_installedExtensions.Clear();
_enabledExtensions.Clear();
foreach (var extensionWrapper in refreshedWrappers)
{
_installedExtensions.Add(extensionWrapper);
var wasPreviouslyInstalled = previouslyInstalledExtensionIds.Contains(extensionWrapper.ExtensionUniqueId);
var shouldBeEnabled = !wasPreviouslyInstalled || previouslyEnabledExtensionIds.Contains(extensionWrapper.ExtensionUniqueId);
if (shouldBeEnabled)
{
_enabledExtensions.Add(extensionWrapper);
}
}
}
private static async Task<List<ExtensionWrapper>> CreateWrappersForExtension(AppExtension extension)
{
var (cmdPalProvider, classIds) = await GetCmdPalExtensionPropertiesAsync(extension);

View File

@@ -192,7 +192,7 @@ public class ExtensionWrapper : IExtensionWrapper
}
else if (supportedProviders is T singleProviderSupported)
{
return [singleProviderSupported];
return (T[])[singleProviderSupported];
}
return Enumerable.Empty<T>();

View File

@@ -0,0 +1,39 @@
// 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.Collections.Generic;
namespace Microsoft.CmdPal.UI.ViewModels.Models;
/// <summary>
/// Service for enumerating and tracking connected display monitors.
/// </summary>
public interface IMonitorService
{
/// <summary>
/// Gets all currently connected monitors.
/// </summary>
IReadOnlyList<MonitorInfo> GetMonitors();
/// <summary>
/// Gets a specific monitor by its device identifier.
/// </summary>
MonitorInfo? GetMonitorByDeviceId(string deviceId);
/// <summary>
/// Gets the primary monitor.
/// </summary>
MonitorInfo? GetPrimaryMonitor();
/// <summary>
/// Invalidates the cached monitor list and raises <see cref="MonitorsChanged"/>.
/// Call this when a display settings change is detected (e.g. WM_DISPLAYCHANGE).
/// </summary>
void NotifyMonitorsChanged();
/// <summary>
/// Raised when the set of connected monitors changes (connect, disconnect, or resolution change).
/// </summary>
event System.EventHandler? MonitorsChanged;
}

View File

@@ -0,0 +1,49 @@
// 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.Text.Json.Serialization;
namespace Microsoft.CmdPal.UI.ViewModels.Models;
/// <summary>
/// Represents a physical display monitor connected to the system.
/// </summary>
public sealed record MonitorInfo
{
/// <summary>
/// Gets the device identifier (e.g. <c>\\.\DISPLAY1</c>).
/// </summary>
public required string DeviceId { get; init; }
/// <summary>
/// Gets the human-readable display name (e.g. <c>DELL U2723QE</c>).
/// </summary>
public required string DisplayName { get; init; }
/// <summary>
/// Gets the full monitor rectangle in virtual-screen coordinates.
/// </summary>
public required ScreenRect Bounds { get; init; }
/// <summary>
/// Gets the work area (excludes the taskbar) in virtual-screen coordinates.
/// </summary>
public required ScreenRect WorkArea { get; init; }
/// <summary>
/// Gets the DPI value for this monitor (e.g. 96, 120, 144, 192).
/// </summary>
public required uint Dpi { get; init; }
/// <summary>
/// Gets a value indicating whether this is the primary monitor.
/// </summary>
public required bool IsPrimary { get; init; }
/// <summary>
/// Gets the scale factor for this monitor (e.g. 1.0 = 100%, 1.5 = 150%).
/// </summary>
[JsonIgnore]
public double ScaleFactor => Dpi / 96.0;
}

View File

@@ -0,0 +1,15 @@
// 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.Models;
/// <summary>
/// Represents the bounds of a monitor in virtual-screen coordinates.
/// </summary>
public readonly record struct ScreenRect(int Left, int Top, int Right, int Bottom)
{
public int Width => Right - Left;
public int Height => Bottom - Top;
}

View File

@@ -171,7 +171,7 @@ public partial class PageViewModel : ExtensionObjectViewModel, IPageContext
Icon = new(page.Icon);
Icon.InitializeProperties();
HasSearchBox = page is IListPage;
HasSearchBox = (page is IListPage) || (page is IParametersPage);
// Let the UI know about our initial properties too.
UpdateProperty(nameof(Name));

View File

@@ -0,0 +1,770 @@
// 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.Collections.ObjectModel;
using CommunityToolkit.Mvvm.Input;
using CommunityToolkit.Mvvm.Messaging;
using Microsoft.CmdPal.Common;
using Microsoft.CmdPal.Common.Messages;
using Microsoft.CmdPal.UI.ViewModels.Messages;
using Microsoft.CmdPal.UI.ViewModels.Models;
using Microsoft.CommandPalette.Extensions;
using Microsoft.CommandPalette.Extensions.Toolkit;
namespace Microsoft.CmdPal.UI.ViewModels;
/// <summary>
/// View models for parameters. This file has both the viewmodels for all the
/// different run types, and the page view model.
/// </summary>
#pragma warning disable SA1402 // File may only contain a single type
#pragma warning disable SA1649 // File name should match first type name
/// <summary>
/// Base class for all parameter run view models. This includes both labels and
/// parameters that accept values.
/// </summary>
public abstract partial class ParameterRunViewModel : ExtensionObjectViewModel
{
private ExtensionObject<IParameterRun> _model;
internal InitializedState Initialized { get; set; } = InitializedState.Uninitialized;
protected bool IsInitialized => IsInErrorState || Initialized.HasFlag(InitializedState.Initialized);
public bool IsInErrorState => Initialized.HasFlag(InitializedState.Error);
internal ParameterRunViewModel(IParameterRun model, WeakReference<IPageContext> context)
: base(context)
{
_model = new(model);
}
public override void InitializeProperties()
{
if (IsInitialized)
{
return;
}
var model = _model.Unsafe;
if (model == null)
{
return;
}
model.PropChanged += Model_PropChanged;
}
private void Model_PropChanged(object sender, IPropChangedEventArgs args)
{
try
{
CoreLogger.LogDebug($"[{GetType().Name}] PropChanged: {args.PropertyName}");
FetchProperty(args.PropertyName);
}
catch (Exception ex)
{
ShowException(ex);
}
}
protected virtual void FetchProperty(string propertyName)
{
// Override in derived classes
}
}
/// <summary>
/// View model for label runs. This is a non-interactive run that just displays
/// text.
/// </summary>
public partial class LabelRunViewModel : ParameterRunViewModel
{
private ExtensionObject<ILabelRun> _model;
public string Text { get; set; } = string.Empty;
public LabelRunViewModel(ILabelRun labelRun, WeakReference<IPageContext> context)
: base(labelRun, context)
{
_model = new(labelRun);
}
public override void InitializeProperties()
{
base.InitializeProperties();
var labelRun = _model.Unsafe;
if (labelRun == null)
{
return;
}
Text = labelRun.Text;
UpdateProperty(nameof(Text));
Initialized = InitializedState.Initialized;
}
protected override void FetchProperty(string propertyName)
{
var model = this._model.Unsafe;
if (model is null)
{
return; // throw?
}
switch (propertyName)
{
case nameof(ILabelRun.Text):
Text = model.Text;
break;
}
UpdateProperty(propertyName);
}
}
public partial class ParameterValueRunViewModel : ParameterRunViewModel
{
private ExtensionObject<IParameterValueRun> _model;
public string PlaceholderText { get; protected set; } = string.Empty;
public bool NeedsValue { get; protected set; }
public ParameterValueRunViewModel(IParameterValueRun valueRun, WeakReference<IPageContext> context)
: base(valueRun, context)
{
_model = new(valueRun);
}
public override void InitializeProperties()
{
base.InitializeProperties();
var valueRun = _model.Unsafe;
if (valueRun == null)
{
return;
}
PlaceholderText = valueRun.PlaceholderText;
NeedsValue = valueRun.NeedsValue;
UpdateProperty(nameof(PlaceholderText));
UpdateProperty(nameof(NeedsValue));
Initialized = InitializedState.Initialized;
}
protected override void FetchProperty(string propertyName)
{
// Don't bother with calling base class, because it is a no-op
var model = this._model.Unsafe;
if (model is null)
{
return; // throw?
}
switch (propertyName)
{
case nameof(IParameterValueRun.PlaceholderText):
PlaceholderText = model.PlaceholderText;
break;
case nameof(IParameterValueRun.NeedsValue):
NeedsValue = model.NeedsValue;
break;
}
UpdateProperty(propertyName);
}
}
public partial class StringParameterRunViewModel : ParameterValueRunViewModel
{
private ExtensionObject<IStringParameterRun> _model;
private string _modelText = string.Empty;
public string TextForUI { get => _modelText; set => SetTextFromUi(value); }
public StringParameterRunViewModel(IStringParameterRun stringRun, WeakReference<IPageContext> context)
: base(stringRun, context)
{
_model = new(stringRun);
}
public override void InitializeProperties()
{
base.InitializeProperties();
var stringRun = _model.Unsafe;
if (stringRun == null)
{
return;
}
_modelText = stringRun.Text;
UpdateProperty(nameof(TextForUI));
}
public void SetTextFromUi(string value)
{
if (value != _modelText)
{
_modelText = value;
_ = Task.Run(() =>
{
var stringRun = _model.Unsafe;
if (stringRun != null)
{
stringRun.Text = value;
}
});
}
}
protected override void FetchProperty(string propertyName)
{
var model = this._model.Unsafe;
if (model is null)
{
return; // throw?
}
switch (propertyName)
{
case nameof(IStringParameterRun.Text):
var newText = model.Text;
if (newText != _modelText)
{
_modelText = newText;
UpdateProperty(nameof(TextForUI));
}
else
{
return;
}
break;
}
// call the base class at the end, because ParameterValueRunViewModel
// will handle calling UpdateProperty for the property name
base.FetchProperty(propertyName);
}
}
public partial class CommandParameterRunViewModel : ParameterValueRunViewModel, IDisposable
{
private ExtensionObject<ICommandParameterRun> _model;
private ListViewModel? _listViewModel;
private CommandViewModel? _commandViewModel;
private AppExtensionHost _extensionHost;
private ICommandProviderContext _providerContext;
private IContextMenuFactory _contextMenuFactory;
public bool IsListParameter => _listViewModel != null;
public ListViewModel? ListViewModel => _listViewModel;
public string DisplayText { get; set; } = string.Empty;
public IconInfoViewModel Icon { get; set; } = new(null);
public string ButtonLabel => !string.IsNullOrEmpty(DisplayText) ? DisplayText : string.Empty;
/// <summary>
/// Gets or sets a value indicating whether the user is actively editing this
/// list parameter (browsing the list to pick a new value). This is separate
/// from NeedsValue — a param can have a value but still be in editing mode.
/// </summary>
public bool IsEditing { get; set; }
/// <summary>
/// Gets a value indicating whether the textbox (rather than the button)
/// should be shown. True when the param still needs a value, or the user
/// is actively re-picking.
/// </summary>
public bool ShowTextBox => NeedsValue || IsEditing;
/// <summary>
/// Enters editing mode — switches the UI from button to textbox so the
/// user can browse the list to re-pick a value.
/// </summary>
public void BeginEditing()
{
CoreLogger.LogDebug($"[CommandParameterRunVM] BeginEditing");
IsEditing = true;
UpdateProperty(nameof(IsEditing), nameof(ShowTextBox));
}
/// <summary>
/// Exits editing mode — switches the UI back to button if the param
/// already has a value.
/// </summary>
public void CancelEditing()
{
CoreLogger.LogDebug($"[CommandParameterRunVM] CancelEditing (was editing: {IsEditing})");
IsEditing = false;
UpdateProperty(nameof(IsEditing), nameof(ShowTextBox));
}
/// <summary>
/// Raised when the extension updates value-related properties (DisplayText,
/// Icon, or NeedsValue). Used by ParametersPageViewModel to exit editing
/// mode and advance focus.
/// </summary>
public event EventHandler? ValueChanged;
public string SearchBoxText
{
get => GetSearchText();
set => SetSearchText(value);
}
public CommandParameterRunViewModel(ICommandParameterRun commandRun, WeakReference<IPageContext> context, AppExtensionHost extensionHost, ICommandProviderContext providerContext, IContextMenuFactory contextMenuFactory)
: base(commandRun, context)
{
_model = new(commandRun);
_extensionHost = extensionHost;
_providerContext = providerContext;
_contextMenuFactory = contextMenuFactory;
}
public override void InitializeProperties()
{
base.InitializeProperties();
var commandRun = _model.Unsafe;
if (commandRun == null)
{
return;
}
DisplayText = commandRun.DisplayText;
Icon = new(commandRun.Icon);
if (Icon is not null)
{
Icon.InitializeProperties();
}
GetHwndMessage msg = new();
WeakReferenceMessenger.Default.Send(msg);
var command = commandRun.GetSelectValueCommand((ulong)msg.Hwnd);
if (command == null)
{
}
else if (command is IListPage list)
{
if (PageContext.TryGetTarget(out var pageContext))
{
_listViewModel = new ListViewModel(list, pageContext.Scheduler, _extensionHost, _providerContext, _contextMenuFactory);
_listViewModel.InitializeProperties();
}
}
else if (command is IInvokableCommand invokable)
{
_commandViewModel = new CommandViewModel(invokable, this.PageContext);
_commandViewModel.InitializeProperties();
}
UpdateProperty(nameof(DisplayText));
UpdateProperty(nameof(Icon));
}
protected override void FetchProperty(string propertyName)
{
var model = this._model.Unsafe;
if (model is null)
{
return; // throw?
}
switch (propertyName)
{
case nameof(ICommandParameterRun.DisplayText):
DisplayText = model.DisplayText;
UpdateProperty(nameof(ButtonLabel));
break;
case nameof(ICommandParameterRun.Icon):
Icon = new(model.Icon);
if (Icon is not null)
{
Icon.InitializeProperties();
}
break;
}
// call the base class at the end, because ParameterValueRunViewModel
// will handle calling UpdateProperty for the property name
base.FetchProperty(propertyName);
// When the extension updates any value-related property, exit editing
// mode and notify the page.
if (propertyName is nameof(NeedsValue)
or nameof(ICommandParameterRun.DisplayText)
or nameof(ICommandParameterRun.Icon))
{
CoreLogger.LogDebug($"[CommandParameterRunVM] Value-related prop changed: {propertyName}, NeedsValue={NeedsValue}, IsEditing={IsEditing}, raising ValueChanged (has subscribers: {ValueChanged != null})");
CancelEditing();
ValueChanged?.Invoke(this, EventArgs.Empty);
}
else
{
CoreLogger.LogDebug($"[CommandParameterRunVM] FetchProperty: {propertyName} (not value-related, no ValueChanged)");
}
}
private string GetSearchText()
{
return _listViewModel?.SearchText ?? string.Empty;
}
private void SetSearchText(string value)
{
_listViewModel?.SearchTextBox = value;
}
[RelayCommand]
public void Invoke()
{
if (_commandViewModel == null)
{
return;
}
PerformCommandMessage m = new(this._commandViewModel.Model);
WeakReferenceMessenger.Default.Send(m);
}
public void Dispose()
{
GC.SuppressFinalize(this);
_listViewModel?.Dispose();
}
}
public partial class ParametersPageViewModel : PageViewModel, IDisposable
{
private ExtensionObject<IParametersPage> _model;
public override bool IsInitialized
{
get => base.IsInitialized; protected set
{
base.IsInitialized = value;
UpdateCommand();
}
}
public List<ParameterRunViewModel> Items { get; set; } = [];
public CommandItemViewModel Command { get; private set; }
public bool ShowCommand =>
IsInitialized &&
IsLoading == false &&
!NeedsAnyValues()
;
private ListViewModel? _activeListViewModel;
public ListViewModel? ActiveListViewModel
{
get => _activeListViewModel;
private set
{
if (_activeListViewModel != value)
{
_activeListViewModel = value;
UpdateProperty(nameof(ActiveListViewModel));
UpdateProperty(nameof(HasActiveList));
}
}
}
public bool HasActiveList => _activeListViewModel != null;
private CommandParameterRunViewModel? _activeListParam;
public void SetActiveListParameter(CommandParameterRunViewModel? param)
{
CoreLogger.LogDebug($"[ParametersPageVM] SetActiveListParameter: {(param != null ? "setting" : "clearing")} (was {(_activeListParam != null ? "set" : "null")})");
_activeListParam = param;
ActiveListViewModel = param?.ListViewModel;
}
private readonly Lock _listLock = new();
private readonly IContextMenuFactory _contextMenuFactory;
public ParametersPageViewModel(IParametersPage model, TaskScheduler scheduler, AppExtensionHost host, ICommandProviderContext providerContext, IContextMenuFactory contextMenuFactory)
: base(model, scheduler, host, providerContext)
{
_model = new(model);
_contextMenuFactory = contextMenuFactory;
Command = new(new(null), PageContext, _contextMenuFactory);
}
private void Model_ItemsChanged(object sender, IItemsChangedEventArgs args) => FetchItems();
//// Run on background thread, from InitializeAsync
public override void InitializeProperties()
{
base.InitializeProperties();
var model = _model.Unsafe;
if (model is null)
{
return; // throw?
}
Command = new(new(model.Command), PageContext, _contextMenuFactory);
Command.SlowInitializeProperties();
FetchItems();
}
//// Run on background thread, from InitializeAsync or Model_ItemsChanged
private void FetchItems()
{
// Collect all the items into new viewmodels
Collection<ParameterRunViewModel> newViewModels = [];
try
{
var newItems = _model.Unsafe!.Parameters;
CoreLogger.LogDebug($"Fetched {newItems.Length} objects");
foreach (var item in newItems)
{
ParameterRunViewModel? itemVm = item switch
{
ILabelRun labelRun => new LabelRunViewModel(labelRun, PageContext),
IStringParameterRun stringRun => new StringParameterRunViewModel(stringRun, PageContext),
ICommandParameterRun commandRun => new CommandParameterRunViewModel(commandRun, PageContext, this.ExtensionHost, this.ProviderContext, _contextMenuFactory),
_ => null,
};
var t = itemVm?.ToString() ?? "unknown";
CoreLogger.LogDebug($"Parameter item was a {t}");
if (itemVm != null)
{
itemVm.InitializeProperties();
newViewModels.Add(itemVm);
itemVm.PropertyChanged += ItemPropertyChanged;
if (itemVm is CommandParameterRunViewModel cmdParamVm)
{
cmdParamVm.ValueChanged += ListParamValueChanged;
}
}
else
{
CoreLogger.LogError("Unexpected parameter type");
}
}
// Update the Items collection on the UI thread
List<ParameterRunViewModel> removedItems = [];
lock (_listLock)
{
// Now that we have new ViewModels for everything from the
// extension, smartly update our list of VMs
ListHelpers.InPlaceUpdateList(Items, newViewModels, out removedItems);
// DO NOT ThrowIfCancellationRequested AFTER THIS! If you do,
// you'll clean up list items that we've now transferred into
// .Items
}
// If we removed items, we need to clean them up, to remove our event handlers
foreach (var removedItem in removedItems)
{
removedItem.PropertyChanged -= ItemPropertyChanged;
if (removedItem is CommandParameterRunViewModel removedCmdParam)
{
removedCmdParam.ValueChanged -= ListParamValueChanged;
}
removedItem.SafeCleanup();
}
}
catch (Exception)
{
// Handle exceptions (e.g., log them)
}
DoOnUiThread(
() =>
{
CoreLogger.LogDebug($"raising parameter items changed, {Items.Count} parameters");
OnPropertyChanged(nameof(Items)); // This _could_ be promoted to a dedicated ItemsUpdated event if needed
UpdateCommand();
WeakReferenceMessenger.Default.Send(new FocusSearchBoxMessage());
});
}
private void UpdateCommand()
{
var showCommand = ShowCommand;
CoreLogger.LogDebug($"showCommand:{showCommand}");
UpdateProperty(nameof(ShowCommand));
if (!showCommand || Command.Model.Unsafe is null)
{
return;
}
UpdateProperty(nameof(Command));
DoOnUiThread(
() =>
{
WeakReferenceMessenger.Default.Send<UpdateCommandBarMessage>(new(Command));
});
}
private void ItemPropertyChanged(object? sender, System.ComponentModel.PropertyChangedEventArgs e)
{
CoreLogger.LogDebug($"[ParametersPageVM] ItemPropertyChanged: {e.PropertyName} from {sender?.GetType().Name}, _activeListParam set: {_activeListParam != null}");
if (e.PropertyName == nameof(ParameterValueRunViewModel.NeedsValue))
{
// Marshal to UI thread — PropChanged events from the extension
// arrive on a background thread, but FocusNextParameter sends a
// message that ultimately touches UI controls.
DoOnUiThread(() =>
{
// First-time pick for a list param (NeedsValue true -> false).
if (sender is CommandParameterRunViewModel cmdParam &&
cmdParam == _activeListParam &&
!cmdParam.NeedsValue)
{
CoreLogger.LogDebug($"[ParametersPageVM] First-time list param pick, clearing active list");
SetActiveListParameter(null);
FocusNextParameter(cmdParam);
}
UpdateCommand();
});
}
}
private void ListParamValueChanged(object? sender, EventArgs e)
{
CoreLogger.LogDebug($"[ParametersPageVM] ListParamValueChanged from {sender?.GetType().Name}, _activeListParam set: {_activeListParam != null}, same: {sender == _activeListParam}");
// Marshal to UI thread — ValueChanged is raised from the extension's
// PropChanged callback on a background thread, but FocusNextParameter
// sends a message that ultimately calls ContainerFromItem on a UI control.
DoOnUiThread(() =>
{
// The extension confirmed a value on a list param. If it's the
// active one (whether first pick or re-pick), clear the list and
// move focus forward.
if (sender is CommandParameterRunViewModel cmdParam &&
cmdParam == _activeListParam &&
!cmdParam.NeedsValue)
{
CoreLogger.LogDebug($"[ParametersPageVM] Clearing active list param after value change");
SetActiveListParameter(null);
FocusNextParameter(cmdParam);
UpdateCommand();
}
else
{
CoreLogger.LogDebug($"[ParametersPageVM] ListParamValueChanged: no action (NeedsValue={(sender as CommandParameterRunViewModel)?.NeedsValue}, isActive={sender == _activeListParam})");
}
});
}
private bool NeedsAnyValues()
{
lock (_listLock)
{
foreach (var item in Items)
{
if (item is ParameterValueRunViewModel val &&
val.NeedsValue)
{
return true;
}
}
}
return false;
}
public void TrySubmit()
{
if (ShowCommand)
{
PerformCommandMessage m = new(this.Command.Command.Model);
WeakReferenceMessenger.Default.Send(m);
}
}
public void FocusNextParameter(ParameterValueRunViewModel lastParam)
{
lock (_listLock)
{
var found = false;
ParameterValueRunViewModel? firstWithoutValue = null;
foreach (var param in Items)
{
if (param == lastParam)
{
found = true;
continue;
}
else if (param is ParameterValueRunViewModel pv)
{
if (found)
{
WeakReferenceMessenger.Default.Send(new FocusParamMessage(pv));
return;
}
else if (firstWithoutValue is null && pv.NeedsValue)
{
firstWithoutValue = pv;
}
}
}
if (firstWithoutValue is not null)
{
WeakReferenceMessenger.Default.Send(new FocusParamMessage(firstWithoutValue));
}
}
}
public void Dispose()
{
GC.SuppressFinalize(this);
}
protected override void UnsafeCleanup()
{
base.UnsafeCleanup();
lock (_listLock)
{
foreach (var item in Items)
{
item.SafeCleanup();
}
Items.Clear();
}
}
}
#pragma warning restore SA1649 // File name should match first type name
#pragma warning restore SA1402 // File may only contain a single type

View File

@@ -174,6 +174,9 @@
<data name="builtin_open_settings_name" xml:space="preserve">
<value>Open Command Palette settings</value>
</data>
<data name="builtin_open_gallery_name" xml:space="preserve">
<value>Find and install Command Palette extensions</value>
</data>
<data name="builtin_create_extension_success" xml:space="preserve">
<value>Successfully created your new extension!</value>
</data>
@@ -299,4 +302,282 @@
<data name="home_sections_commands_title" xml:space="preserve">
<value>Commands</value>
</data>
</root>
<data name="gallery_n_extensions_available" xml:space="preserve">
<value>{0} extensions available</value>
<comment>{0}=number of extensions</comment>
</data>
<data name="gallery_n_extensions_found" xml:space="preserve">
<value>{0} extensions found</value>
<comment>{0}=number of extensions matching search</comment>
</data>
<data name="gallery_error_generic_title" xml:space="preserve">
<value>Failed to load extensions</value>
</data>
<data name="gallery_error_generic_message" xml:space="preserve">
<value>We couldn't load the extension gallery right now. Please try again in a little while.</value>
</data>
<data name="gallery_error_rate_limited_title" xml:space="preserve">
<value>The gallery is taking a breather</value>
</data>
<data name="gallery_error_rate_limited_message" xml:space="preserve">
<value>We hit the extension gallery rate limit. Please try again in a little while.</value>
</data>
<data name="gallery_item_no_description" xml:space="preserve">
<value>No description available.</value>
</data>
<data name="gallery_item_unknown_author" xml:space="preserve">
<value>Unknown author</value>
</data>
<data name="gallery_item_unknown_source_unsupported_tooltip" xml:space="preserve">
<value>This extension has source metadata with an unsupported source type.</value>
</data>
<data name="gallery_item_unknown_source_unavailable_tooltip" xml:space="preserve">
<value>Source metadata is not available yet.</value>
</data>
<data name="gallery_item_no_source_menu_text" xml:space="preserve">
<value>Source metadata not available</value>
</data>
<data name="gallery_item_no_source_details_text" xml:space="preserve">
<value>This extension does not currently expose install or link metadata in the gallery feed.</value>
</data>
<data name="gallery_item_winget_tooltip_with_id" xml:space="preserve">
<value>WinGet package: {0}</value>
<comment>{0}=WinGet package identifier</comment>
</data>
<data name="gallery_item_winget_tooltip" xml:space="preserve">
<value>Available on WinGet</value>
</data>
<data name="gallery_item_store_tooltip_with_id" xml:space="preserve">
<value>Microsoft Store product: {0}</value>
<comment>{0}=Microsoft Store product identifier</comment>
</data>
<data name="gallery_item_store_tooltip" xml:space="preserve">
<value>Available on Microsoft Store</value>
</data>
<data name="gallery_item_github_source" xml:space="preserve">
<value>GitHub source</value>
</data>
<data name="gallery_item_website_source" xml:space="preserve">
<value>Website source</value>
</data>
<data name="gallery_item_winget_menu_text_with_id" xml:space="preserve">
<value>WinGet: {0}</value>
<comment>{0}=WinGet package identifier</comment>
</data>
<data name="gallery_item_winget_menu_text" xml:space="preserve">
<value>WinGet</value>
</data>
<data name="gallery_item_store_menu_text_with_id" xml:space="preserve">
<value>Microsoft Store: {0}</value>
<comment>{0}=Microsoft Store product identifier</comment>
</data>
<data name="gallery_item_store_menu_text" xml:space="preserve">
<value>Microsoft Store</value>
</data>
<data name="gallery_item_update_action" xml:space="preserve">
<value>Update</value>
</data>
<data name="gallery_item_install_action" xml:space="preserve">
<value>Install</value>
</data>
<data name="gallery_item_install_status_update_available" xml:space="preserve">
<value>Update available</value>
</data>
<data name="gallery_item_install_status_installed" xml:space="preserve">
<value>Installed</value>
</data>
<data name="gallery_item_install_status_not_installed" xml:space="preserve">
<value>Not installed</value>
</data>
<data name="gallery_item_install_status_unavailable" xml:space="preserve">
<value>Install status unavailable</value>
</data>
<data name="gallery_item_winget_status_update_available" xml:space="preserve">
<value>Installed, update available.</value>
</data>
<data name="gallery_item_winget_status_installed" xml:space="preserve">
<value>Installed.</value>
</data>
<data name="gallery_item_winget_status_not_installed" xml:space="preserve">
<value>Not installed.</value>
</data>
<data name="gallery_item_winget_status_unavailable" xml:space="preserve">
<value>WinGet status unavailable.</value>
</data>
<data name="gallery_item_winget_action_updating" xml:space="preserve">
<value>Updating with WinGet...</value>
</data>
<data name="gallery_item_winget_action_installing" xml:space="preserve">
<value>Installing with WinGet...</value>
</data>
<data name="gallery_item_winget_action_resolve_failed" xml:space="preserve">
<value>WinGet couldn't resolve this package.</value>
</data>
<data name="gallery_item_winget_action_package_not_found" xml:space="preserve">
<value>The WinGet package couldn't be found.</value>
</data>
<data name="gallery_item_winget_action_install_failed" xml:space="preserve">
<value>The WinGet install failed.</value>
</data>
<data name="gallery_item_winget_action_queued_uninstall" xml:space="preserve">
<value>Queued for WinGet uninstall...</value>
</data>
<data name="gallery_item_winget_action_queued_update" xml:space="preserve">
<value>Queued for WinGet update...</value>
</data>
<data name="gallery_item_winget_action_queued_install" xml:space="preserve">
<value>Queued for WinGet install...</value>
</data>
<data name="gallery_item_winget_action_downloading_with_progress" xml:space="preserve">
<value>Downloading with WinGet... {0}%</value>
<comment>{0}=download percent</comment>
</data>
<data name="gallery_item_winget_action_downloading" xml:space="preserve">
<value>Downloading with WinGet...</value>
</data>
<data name="gallery_item_winget_action_uninstalling" xml:space="preserve">
<value>Uninstalling with WinGet...</value>
</data>
<data name="gallery_item_winget_action_finishing" xml:space="preserve">
<value>Finishing WinGet operation...</value>
</data>
<data name="gallery_item_winget_action_succeeded_uninstall" xml:space="preserve">
<value>Extension uninstalled with WinGet.</value>
</data>
<data name="gallery_item_winget_action_succeeded_update" xml:space="preserve">
<value>Extension updated with WinGet.</value>
</data>
<data name="gallery_item_winget_action_succeeded_install" xml:space="preserve">
<value>Extension installed with WinGet.</value>
</data>
<data name="gallery_item_winget_action_canceled" xml:space="preserve">
<value>The WinGet operation was canceled.</value>
</data>
<data name="gallery_item_winget_action_failed" xml:space="preserve">
<value>The WinGet operation failed.</value>
</data>
<data name="gallery_item_source_name_winget" xml:space="preserve">
<value>WinGet</value>
</data>
<data name="gallery_item_source_name_store" xml:space="preserve">
<value>Microsoft Store</value>
</data>
<data name="gallery_item_source_name_github" xml:space="preserve">
<value>GitHub</value>
</data>
<data name="gallery_item_source_name_website" xml:space="preserve">
<value>Website</value>
</data>
<data name="gallery_item_source_name_unknown" xml:space="preserve">
<value>Source: {0}</value>
<comment>{0}=unsupported source type name from the gallery feed</comment>
</data>
<data name="gallery_source_detail_summary_label" xml:space="preserve">
<value>Summary</value>
</data>
<data name="gallery_source_detail_description_label" xml:space="preserve">
<value>Description</value>
</data>
<data name="gallery_source_detail_version_label" xml:space="preserve">
<value>Version</value>
</data>
<data name="gallery_source_detail_tags_label" xml:space="preserve">
<value>Tags</value>
</data>
<data name="gallery_source_detail_package_label" xml:space="preserve">
<value>Package</value>
</data>
<data name="gallery_source_detail_publisher_label" xml:space="preserve">
<value>Publisher</value>
</data>
<data name="gallery_source_detail_author_label" xml:space="preserve">
<value>Author</value>
</data>
<data name="gallery_source_detail_license_label" xml:space="preserve">
<value>License</value>
</data>
<data name="gallery_source_detail_support_label" xml:space="preserve">
<value>Support</value>
</data>
<data name="gallery_source_detail_package_page_label" xml:space="preserve">
<value>Package page</value>
</data>
<data name="gallery_source_detail_release_notes_label" xml:space="preserve">
<value>Release notes</value>
</data>
<data name="gallery_screenshot_display_name" xml:space="preserve">
<value>Screenshot {0}</value>
<comment>{0}=screenshot number</comment>
</data>
<data name="winget_operation_detail_progress" xml:space="preserve">
<value>{0} of {1}</value>
<comment>{0}=downloaded size, {1}=total size</comment>
</data>
<data name="winget_operation_detail_updated" xml:space="preserve">
<value>Updated {0:t}</value>
<comment>{0}=local completion time</comment>
</data>
<data name="winget_operation_size_bytes" xml:space="preserve">
<value>{0} B</value>
<comment>{0}=byte count</comment>
</data>
<data name="winget_operation_size_gigabytes" xml:space="preserve">
<value>{0:F1} GB</value>
<comment>{0}=gigabyte count</comment>
</data>
<data name="winget_operation_size_kilobytes" xml:space="preserve">
<value>{0:F1} KB</value>
<comment>{0}=kilobyte count</comment>
</data>
<data name="winget_operation_size_megabytes" xml:space="preserve">
<value>{0:F1} MB</value>
<comment>{0}=megabyte count</comment>
</data>
<data name="winget_operation_status_canceled" xml:space="preserve">
<value>Canceled</value>
</data>
<data name="winget_operation_status_downloading" xml:space="preserve">
<value>Downloading</value>
</data>
<data name="winget_operation_status_downloading_percent" xml:space="preserve">
<value>Downloading {0}%</value>
<comment>{0}=download percent</comment>
</data>
<data name="winget_operation_status_failed" xml:space="preserve">
<value>Failed</value>
</data>
<data name="winget_operation_status_installing" xml:space="preserve">
<value>Installing</value>
</data>
<data name="winget_operation_status_post_processing" xml:space="preserve">
<value>Finalizing</value>
</data>
<data name="winget_operation_status_queued_install" xml:space="preserve">
<value>Queued to install</value>
</data>
<data name="winget_operation_status_queued_uninstall" xml:space="preserve">
<value>Queued to uninstall</value>
</data>
<data name="winget_operation_status_succeeded_install" xml:space="preserve">
<value>Installed</value>
</data>
<data name="winget_operation_status_succeeded_uninstall" xml:space="preserve">
<value>Uninstalled</value>
</data>
<data name="winget_operation_status_uninstalling" xml:space="preserve">
<value>Uninstalling</value>
</data>
<data name="winget_operations_flyout_active_header" xml:space="preserve">
<value>Downloads and installs</value>
</data>
<data name="winget_operations_in_progress_plural" xml:space="preserve">
<value>{0} WinGet operations in progress</value>
<comment>{0}=active operation count</comment>
</data>
<data name="winget_operations_in_progress_single" xml:space="preserve">
<value>1 WinGet operation in progress</value>
</data>
<data name="winget_operations_recent_activity" xml:space="preserve">
<value>Recent WinGet activity</value>
</data>
</root>

View File

@@ -14,7 +14,6 @@ public sealed partial class DefaultCommandProviderCache : ICommandProviderCache,
private const string CacheFileName = "commandProviderCache.json";
private readonly Dictionary<string, CommandProviderCacheItem> _cache = new(StringComparer.Ordinal);
private readonly Lock _sync = new();
private readonly SupersedingAsyncGate _saveGate;

View File

@@ -2,12 +2,14 @@
// 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 System.Text.Json;
using System.Text.Json.Nodes;
using System.Text.Json.Serialization.Metadata;
using ManagedCommon;
using Microsoft.CmdPal.Common.Services;
using Microsoft.CmdPal.UI.ViewModels.Settings;
using Windows.Foundation;
namespace Microsoft.CmdPal.UI.ViewModels.Services;

View File

@@ -13,7 +13,8 @@ namespace Microsoft.CmdPal.UI.ViewModels.Settings;
/// <summary>
/// Settings for the Dock. These are settings for _the whole dock_. Band-specific
/// settings are in <see cref="DockBandSettings"/>.
/// settings are in <see cref="DockBandSettings"/>. Per-monitor overrides are
/// stored in <see cref="MonitorConfigs"/>.
/// </summary>
public record DockSettings
{
@@ -92,11 +93,164 @@ public record DockSettings
public bool ShowLabels { get; init; } = true;
/// <summary>
/// Gets the per-monitor dock configurations. Each entry overrides global
/// settings for a specific display. Empty by default (all monitors use global).
/// </summary>
private ImmutableList<DockMonitorConfig>? _monitorConfigs = ImmutableList<DockMonitorConfig>.Empty;
public ImmutableList<DockMonitorConfig> MonitorConfigs
{
get => _monitorConfigs ?? ImmutableList<DockMonitorConfig>.Empty;
init => _monitorConfigs = value ?? ImmutableList<DockMonitorConfig>.Empty;
}
/// <summary>
/// Gets the dock side override for a specific monitor, or <c>null</c> if the
/// monitor has no override (inherits global <see cref="Side"/>).
/// </summary>
public DockSide? GetSideForMonitor(string deviceId)
{
foreach (var cfg in MonitorConfigs)
{
if (string.Equals(cfg.MonitorDeviceId, deviceId, StringComparison.OrdinalIgnoreCase))
{
return cfg.Side;
}
}
return null;
}
[JsonIgnore]
public IEnumerable<(string ProviderId, string CommandId)> AllPinnedCommands =>
StartBands.Select(b => (b.ProviderId, b.CommandId))
.Concat(CenterBands.Select(b => (b.ProviderId, b.CommandId)))
.Concat(EndBands.Select(b => (b.ProviderId, b.CommandId)));
public IEnumerable<(string ProviderId, string CommandId)> AllPinnedCommands
{
get
{
// Start with global bands
var result = StartBands.Select(b => (b.ProviderId, b.CommandId))
.Concat(CenterBands.Select(b => (b.ProviderId, b.CommandId)))
.Concat(EndBands.Select(b => (b.ProviderId, b.CommandId)));
// Include per-monitor bands so that commands pinned to specific
// monitors are loaded as TopLevelViewModels and appear in the dock.
var configs = MonitorConfigs ?? ImmutableList<DockMonitorConfig>.Empty;
foreach (var config in configs)
{
if (config.IsCustomized)
{
var start = config.StartBands ?? ImmutableList<DockBandSettings>.Empty;
var center = config.CenterBands ?? ImmutableList<DockBandSettings>.Empty;
var end = config.EndBands ?? ImmutableList<DockBandSettings>.Empty;
result = result
.Concat(start.Select(b => (b.ProviderId, b.CommandId)))
.Concat(center.Select(b => (b.ProviderId, b.CommandId)))
.Concat(end.Select(b => (b.ProviderId, b.CommandId)));
}
}
return result;
}
}
}
/// <summary>
/// Per-monitor configuration for the dock. Each monitor can override the global
/// dock side, enable/disable its dock, and optionally maintain independent band lists.
/// Uses a nullable-override pattern: <c>null</c> values inherit from global <see cref="DockSettings"/>.
/// </summary>
public sealed record DockMonitorConfig
{
/// <summary>
/// Gets the monitor device identifier (e.g. <c>\\.\DISPLAY1</c>).
/// </summary>
public required string MonitorDeviceId { get; init; }
/// <summary>
/// Gets a value indicating whether the dock is enabled on this monitor. Defaults to <c>true</c>.
/// </summary>
public bool Enabled { get; init; } = true;
/// <summary>
/// Gets the dock side override for this monitor. When <c>null</c>, inherits the global
/// <see cref="DockSettings.Side"/> value.
/// </summary>
public DockSide? Side { get; init; }
/// <summary>
/// Gets a value indicating whether this monitor is the primary display.
/// Used as a stable key for reconciliation when device IDs change across reboots.
/// </summary>
public bool IsPrimary { get; init; }
/// <summary>
/// Gets a value indicating whether this monitor has its own independent band lists.
/// When <c>false</c>, the monitor inherits bands from the global <see cref="DockSettings"/>.
/// </summary>
public bool IsCustomized { get; init; }
/// <summary>
/// Gets the per-monitor start bands. Only used when <see cref="IsCustomized"/> is <c>true</c>.
/// </summary>
public ImmutableList<DockBandSettings>? StartBands { get; init; }
/// <summary>
/// Gets the per-monitor center bands. Only used when <see cref="IsCustomized"/> is <c>true</c>.
/// </summary>
public ImmutableList<DockBandSettings>? CenterBands { get; init; }
/// <summary>
/// Gets the per-monitor end bands. Only used when <see cref="IsCustomized"/> is <c>true</c>.
/// </summary>
public ImmutableList<DockBandSettings>? EndBands { get; init; }
/// <summary>
/// Gets the UTC timestamp when this monitor was last seen connected. Used for
/// staleness pruning: configs not seen for 6+ months are automatically removed
/// during reconciliation.
/// </summary>
public DateTime? LastSeen { get; init; }
/// <summary>
/// Resolves the effective dock side for this monitor.
/// </summary>
public DockSide ResolveSide(DockSide defaultSide) => Side ?? defaultSide;
/// <summary>
/// Resolves the effective start bands for this monitor.
/// Returns per-monitor bands when customized; otherwise falls back to the global bands.
/// </summary>
public ImmutableList<DockBandSettings> ResolveStartBands(ImmutableList<DockBandSettings> globalBands) =>
IsCustomized && StartBands is not null ? StartBands : globalBands;
/// <summary>
/// Resolves the effective center bands for this monitor.
/// Returns per-monitor bands when customized; otherwise falls back to the global bands.
/// </summary>
public ImmutableList<DockBandSettings> ResolveCenterBands(ImmutableList<DockBandSettings> globalBands) =>
IsCustomized && CenterBands is not null ? CenterBands : globalBands;
/// <summary>
/// Resolves the effective end bands for this monitor.
/// Returns per-monitor bands when customized; otherwise falls back to the global bands.
/// </summary>
public ImmutableList<DockBandSettings> ResolveEndBands(ImmutableList<DockBandSettings> globalBands) =>
IsCustomized && EndBands is not null ? EndBands : globalBands;
/// <summary>
/// Creates a new <see cref="DockMonitorConfig"/> that is a customized fork of the
/// given global dock settings. Copies global bands into per-monitor band lists so
/// they can be independently modified.
/// </summary>
public DockMonitorConfig ForkFromGlobal(DockSettings globalSettings) => this with
{
IsCustomized = true,
// Create independent copies by rebuilding the immutable lists
StartBands = ImmutableList.CreateRange(globalSettings.StartBands ?? ImmutableList<DockBandSettings>.Empty),
CenterBands = ImmutableList.CreateRange(globalSettings.CenterBands ?? ImmutableList<DockBandSettings>.Empty),
EndBands = ImmutableList.CreateRange(globalSettings.EndBands ?? ImmutableList<DockBandSettings>.Empty),
};
}
/// <summary>

View File

@@ -0,0 +1,203 @@
// 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.Collections.Generic;
using System.Collections.Immutable;
using Microsoft.CmdPal.UI.ViewModels.Models;
namespace Microsoft.CmdPal.UI.ViewModels.Settings;
/// <summary>
/// Reconciles persisted <see cref="DockMonitorConfig"/> entries against the
/// set of currently connected monitors. Handles stale device IDs that may change
/// across reboots by using the <see cref="DockMonitorConfig.IsPrimary"/> flag as
/// a secondary matching key.
/// </summary>
/// <remarks>
/// All operations are pure — they return new immutable lists rather than
/// mutating input collections.
/// </remarks>
public static class MonitorConfigReconciler
{
/// <summary>
/// Configs whose <see cref="DockMonitorConfig.LastSeen"/> is older than this
/// duration are pruned during reconciliation.
/// </summary>
internal static readonly TimeSpan StaleThreshold = TimeSpan.FromDays(180);
/// <summary>
/// Reconciles persisted monitor configs against the current set of connected monitors.
/// <para>
/// <b>Phase 1</b>: Exact DeviceId matching — keep IsPrimary up-to-date.<br/>
/// <b>Phase 2</b>: Fuzzy matching — reassociate unmatched configs by IsPrimary flag.<br/>
/// <b>Phase 3</b>: Create default configs for monitors that have no matching config.<br/>
/// <b>Phase 4</b>: Retain disconnected monitor configs for future reconnection; prune entries not seen for 6+ months.
/// </para>
/// </summary>
public static ImmutableList<DockMonitorConfig> Reconcile(
ImmutableList<DockMonitorConfig>? existingConfigs,
IReadOnlyList<MonitorInfo> currentMonitors)
{
// Use Date (day granularity) so the value stabilizes across multiple reconciliations
// within the same day. This prevents infinite loops: SettingsChanged → SyncDocks →
// Reconcile → SettingsChanged when LastSeen changes by milliseconds each call.
return Reconcile(existingConfigs, currentMonitors, DateTime.UtcNow.Date);
}
/// <summary>
/// Overload accepting an explicit <paramref name="utcNow"/> for testability.
/// </summary>
internal static ImmutableList<DockMonitorConfig> Reconcile(
ImmutableList<DockMonitorConfig>? existingConfigs,
IReadOnlyList<MonitorInfo> currentMonitors,
DateTime utcNow)
{
existingConfigs ??= ImmutableList<DockMonitorConfig>.Empty;
if (currentMonitors.Count == 0)
{
return existingConfigs;
}
// Build a DeviceId → index lookup for O(1) matching in Phase 1
var configIndexByDeviceId = new Dictionary<string, int>(existingConfigs.Count, StringComparer.OrdinalIgnoreCase);
for (var i = 0; i < existingConfigs.Count; i++)
{
configIndexByDeviceId.TryAdd(existingConfigs[i].MonitorDeviceId, i);
}
var matchedMonitorDeviceIds = new HashSet<string>(StringComparer.OrdinalIgnoreCase);
var matchedConfigIndices = new HashSet<int>();
var result = new List<DockMonitorConfig>(currentMonitors.Count);
// Phase 1: Exact DeviceId match (O(N) with dictionary lookup)
for (var mi = 0; mi < currentMonitors.Count; mi++)
{
var monitor = currentMonitors[mi];
if (configIndexByDeviceId.TryGetValue(monitor.DeviceId, out var ci) && !matchedConfigIndices.Contains(ci))
{
// Update IsPrimary and LastSeen to current state
result.Add(existingConfigs[ci] with { IsPrimary = monitor.IsPrimary, LastSeen = utcNow });
matchedMonitorDeviceIds.Add(monitor.DeviceId);
matchedConfigIndices.Add(ci);
}
}
// Phase 2: Fuzzy match — recover primary monitor config when its DeviceId changed.
// Windows can reassign DeviceId strings across reboots, driver updates, or cable
// swaps. When the primary monitor's DeviceId no longer matches any saved config,
// we look for an unmatched config that was previously marked as primary and
// reassociate it. Secondary monitors are not interchangeable, so we skip them.
for (var mi = 0; mi < currentMonitors.Count; mi++)
{
var monitor = currentMonitors[mi];
if (!monitor.IsPrimary || matchedMonitorDeviceIds.Contains(monitor.DeviceId))
{
continue;
}
for (var ci = 0; ci < existingConfigs.Count; ci++)
{
if (matchedConfigIndices.Contains(ci))
{
continue;
}
if (existingConfigs[ci].IsPrimary)
{
// Reassociate: update DeviceId, IsPrimary, and LastSeen
result.Add(existingConfigs[ci] with
{
MonitorDeviceId = monitor.DeviceId,
IsPrimary = monitor.IsPrimary,
LastSeen = utcNow,
});
matchedMonitorDeviceIds.Add(monitor.DeviceId);
matchedConfigIndices.Add(ci);
break;
}
}
}
// Phase 3: Create defaults for new monitors with no matching config.
// Primary monitors inherit global bands (IsCustomized = false) for a seamless
// upgrade path. Secondary monitors start with empty band lists so users don't
// have to manually unpin bands from every new display.
for (var mi = 0; mi < currentMonitors.Count; mi++)
{
var monitor = currentMonitors[mi];
if (matchedMonitorDeviceIds.Contains(monitor.DeviceId))
{
continue;
}
if (monitor.IsPrimary)
{
// Primary: inherit global bands (IsCustomized = false)
result.Add(new DockMonitorConfig
{
MonitorDeviceId = monitor.DeviceId,
Enabled = true,
IsPrimary = true,
LastSeen = utcNow,
});
}
else
{
// Secondary: start with empty bands so users choose what to pin per-monitor
result.Add(new DockMonitorConfig
{
MonitorDeviceId = monitor.DeviceId,
Enabled = true,
IsPrimary = false,
IsCustomized = true,
StartBands = ImmutableList<DockBandSettings>.Empty,
CenterBands = ImmutableList<DockBandSettings>.Empty,
EndBands = ImmutableList<DockBandSettings>.Empty,
LastSeen = utcNow,
});
}
}
// Phase 4: Retain disconnected monitor configs so settings survive reconnection.
// Prune entries not seen for longer than StaleThreshold (6 months).
for (var ci = 0; ci < existingConfigs.Count; ci++)
{
if (matchedConfigIndices.Contains(ci))
{
continue;
}
var config = existingConfigs[ci];
var lastSeen = config.LastSeen ?? utcNow; // Treat legacy entries (no LastSeen) as fresh
if ((utcNow - lastSeen) < StaleThreshold)
{
result.Add(config);
}
}
// Return the original reference when nothing actually changed so callers
// can use reference equality to skip no-op settings writes.
if (result.Count == existingConfigs.Count)
{
var changed = false;
for (var i = 0; i < result.Count; i++)
{
if (!result[i].Equals(existingConfigs[i]))
{
changed = true;
break;
}
}
if (!changed)
{
return existingConfigs;
}
}
return ImmutableList.CreateRange(result);
}
}

View File

@@ -121,6 +121,19 @@ public record SettingsModel
// </Theme settings>
// Extension Gallery settings
/// <summary>
/// Gets the URL of the custom gallery feed <strong>for development only</strong>. This feed is currently only enabled in
/// development builds and is used for testing purposes. <strong>It is not used in CI builds.</strong>
/// </summary>
/// <remarks>
/// We do not want to allow this to be changed in retail builds to prevent the injection of a malicious feed.
/// </remarks>
public string? GalleryFeedUrl { get; init; }
// </Gallery settings>
// END SETTINGS
///////////////////////////////////////////////////////////////////////////
@@ -196,6 +209,8 @@ public record SettingsModel
[JsonSerializable(typeof(ImmutableDictionary<string, FallbackSettings>), TypeInfoPropertyName = "ImmutableFallbackDictionary")]
[JsonSerializable(typeof(ImmutableList<string>), TypeInfoPropertyName = "ImmutableStringList")]
[JsonSerializable(typeof(ImmutableList<DockBandSettings>), TypeInfoPropertyName = "ImmutableDockBandSettingsList")]
[JsonSerializable(typeof(DockMonitorConfig))]
[JsonSerializable(typeof(ImmutableList<DockMonitorConfig>), TypeInfoPropertyName = "ImmutableDockMonitorConfigList")]
[JsonSerializable(typeof(ImmutableDictionary<string, ProviderSettings>), TypeInfoPropertyName = "ImmutableProviderSettingsDictionary")]
[JsonSerializable(typeof(ImmutableDictionary<string, CommandAlias>), TypeInfoPropertyName = "ImmutableAliasDictionary")]
[JsonSerializable(typeof(ImmutableList<TopLevelHotkey>), TypeInfoPropertyName = "ImmutableTopLevelHotkeyList")]

View File

@@ -5,7 +5,9 @@
using System.Collections.ObjectModel;
using System.ComponentModel;
using CommunityToolkit.Mvvm.Messaging;
using Microsoft.CmdPal.UI.ViewModels.Dock;
using Microsoft.CmdPal.UI.ViewModels.Messages;
using Microsoft.CmdPal.UI.ViewModels.Models;
using Microsoft.CmdPal.UI.ViewModels.Services;
using Microsoft.CmdPal.UI.ViewModels.Settings;
using Microsoft.CommandPalette.Extensions.Toolkit;
@@ -29,6 +31,7 @@ public partial class SettingsViewModel : INotifyPropertyChanged
private readonly ISettingsService _settingsService;
private readonly TopLevelCommandManager _topLevelCommandManager;
private readonly IMonitorService? _monitorService;
public event PropertyChangedEventHandler? PropertyChanged;
@@ -250,20 +253,26 @@ public partial class SettingsViewModel : INotifyPropertyChanged
public ObservableCollection<FallbackSettingsViewModel> FallbackRankings { get; set; } = new();
public ObservableCollection<DockMonitorConfigViewModel> MonitorConfigs { get; } = new();
public SettingsExtensionsViewModel Extensions { get; }
public SettingsViewModel(
TopLevelCommandManager topLevelCommandManager,
TaskScheduler scheduler,
IThemeService themeService,
ISettingsService settingsService)
ISettingsService settingsService,
IMonitorService? monitorService = null)
{
_settingsService = settingsService;
_topLevelCommandManager = topLevelCommandManager;
_monitorService = monitorService;
Appearance = new AppearanceSettingsViewModel(themeService, settingsService);
DockAppearance = new DockAppearanceSettingsViewModel(themeService, settingsService);
PopulateMonitorConfigs();
var activeProviders = GetCommandProviders();
var allProviderSettings = _settingsService.Settings.ProviderSettings;
@@ -332,4 +341,44 @@ public partial class SettingsViewModel : INotifyPropertyChanged
_settingsService.UpdateSettings(s => s with { FallbackRanks = FallbackRankings.Select(s2 => s2.Id).ToArray() });
PropertyChanged?.Invoke(this, new PropertyChangedEventArgs(nameof(FallbackRankings)));
}
/// <summary>
/// Builds or refreshes the <see cref="MonitorConfigs"/> collection by reconciling
/// connected monitors with persisted per-monitor settings.
/// </summary>
public void PopulateMonitorConfigs()
{
if (_monitorService is null)
{
return;
}
var monitors = _monitorService.GetMonitors();
var currentSettings = _settingsService.Settings.DockSettings;
var reconciled = MonitorConfigReconciler.Reconcile(currentSettings.MonitorConfigs, monitors);
var currentMonitorConfigs = currentSettings.MonitorConfigs ?? System.Collections.Immutable.ImmutableList<DockMonitorConfig>.Empty;
if (!reconciled.SequenceEqual(currentMonitorConfigs))
{
_settingsService.UpdateSettings(s => s with
{
DockSettings = s.DockSettings with { MonitorConfigs = reconciled },
});
}
MonitorConfigs.Clear();
foreach (var monitor in monitors)
{
var config = reconciled.FirstOrDefault(c =>
string.Equals(c.MonitorDeviceId, monitor.DeviceId, StringComparison.OrdinalIgnoreCase));
if (config is not null)
{
MonitorConfigs.Add(new DockMonitorConfigViewModel(config, monitor, _settingsService));
}
}
PropertyChanged?.Invoke(this, new PropertyChangedEventArgs(nameof(MonitorConfigs)));
}
}

View File

@@ -707,7 +707,7 @@ public sealed partial class TopLevelCommandManager : ObservableObject,
{
if (message.Pin)
{
wrapper?.PinDockBand(message.CommandId, _serviceProvider, message.Side, message.ShowTitles, message.ShowSubtitles);
wrapper?.PinDockBand(message.CommandId, _serviceProvider, message.Side, message.ShowTitles, message.ShowSubtitles, message.MonitorDeviceId);
}
else
{

Some files were not shown because too many files have changed in this diff Show More