mirror of
https://github.com/microsoft/PowerToys.git
synced 2026-07-01 07:59:36 +02:00
Compare commits
8 Commits
user/muyua
...
dev/migrie
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
e83d6cb8f6 | ||
|
|
47c66cea90 | ||
|
|
758f23ed7e | ||
|
|
dc3ce6a081 | ||
|
|
1b63c3e554 | ||
|
|
b76671adab | ||
|
|
a2768b066f | ||
|
|
23aa09ee2f |
14
.github/actions/spell-check/allow/code.txt
vendored
14
.github/actions/spell-check/allow/code.txt
vendored
@@ -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
|
||||
|
||||
1
.github/actions/spell-check/allow/names.txt
vendored
1
.github/actions/spell-check/allow/names.txt
vendored
@@ -209,6 +209,7 @@ Bilibili
|
||||
BVID
|
||||
capturevideosample
|
||||
cmdow
|
||||
contoso
|
||||
Contoso
|
||||
Controlz
|
||||
cortana
|
||||
|
||||
4
.github/actions/spell-check/excludes.txt
vendored
4
.github/actions/spell-check/excludes.txt
vendored
@@ -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$
|
||||
|
||||
1
.github/actions/spell-check/expect.txt
vendored
1
.github/actions/spell-check/expect.txt
vendored
@@ -1533,6 +1533,7 @@ resmimetype
|
||||
RESOURCEID
|
||||
RESTORETOMAXIMIZED
|
||||
RETURNONLYFSDIRS
|
||||
Revalidates
|
||||
RGBQUAD
|
||||
rgbs
|
||||
rgelt
|
||||
|
||||
@@ -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.
|
||||
|
||||
@@ -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" />
|
||||
|
||||
@@ -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; };
|
||||
}
|
||||
|
||||
167
doc/devdocs/modules/cmdpal/extension-gallery.md
Normal file
167
doc/devdocs/modules/cmdpal/extension-gallery.md
Normal 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.
|
||||
|
||||
@@ -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.)
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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; }
|
||||
}
|
||||
@@ -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; }
|
||||
}
|
||||
@@ -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; } = [];
|
||||
}
|
||||
@@ -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; }
|
||||
}
|
||||
@@ -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; } = [];
|
||||
}
|
||||
@@ -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
|
||||
{
|
||||
}
|
||||
@@ -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();
|
||||
}
|
||||
}
|
||||
@@ -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);
|
||||
}
|
||||
@@ -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();
|
||||
@@ -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; }
|
||||
}
|
||||
@@ -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; }
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
@@ -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>
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
@@ -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);
|
||||
}
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
}
|
||||
@@ -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
|
||||
{
|
||||
}
|
||||
@@ -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>
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
|
||||
@@ -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(),
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
@@ -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"),
|
||||
@@ -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
|
||||
{
|
||||
@@ -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.
|
||||
@@ -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
|
||||
{
|
||||
@@ -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);
|
||||
@@ -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);
|
||||
@@ -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);
|
||||
@@ -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;
|
||||
}
|
||||
@@ -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,
|
||||
}
|
||||
@@ -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);
|
||||
@@ -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,
|
||||
}
|
||||
@@ -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);
|
||||
@@ -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);
|
||||
}
|
||||
@@ -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);
|
||||
@@ -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);
|
||||
}
|
||||
@@ -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);
|
||||
}
|
||||
@@ -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);
|
||||
}
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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: []);
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
@@ -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();
|
||||
}
|
||||
}
|
||||
@@ -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";
|
||||
}
|
||||
@@ -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,
|
||||
};
|
||||
}
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
|
||||
|
||||
@@ -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 },
|
||||
];
|
||||
|
||||
|
||||
@@ -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");
|
||||
}
|
||||
}
|
||||
@@ -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();
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
}
|
||||
@@ -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));
|
||||
|
||||
@@ -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));
|
||||
}
|
||||
}
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
}
|
||||
@@ -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,
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
}
|
||||
@@ -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));
|
||||
}
|
||||
}
|
||||
@@ -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;
|
||||
|
||||
@@ -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);
|
||||
@@ -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);
|
||||
@@ -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);
|
||||
|
||||
@@ -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);
|
||||
|
||||
@@ -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);
|
||||
|
||||
@@ -192,7 +192,7 @@ public class ExtensionWrapper : IExtensionWrapper
|
||||
}
|
||||
else if (supportedProviders is T singleProviderSupported)
|
||||
{
|
||||
return [singleProviderSupported];
|
||||
return (T[])[singleProviderSupported];
|
||||
}
|
||||
|
||||
return Enumerable.Empty<T>();
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
@@ -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));
|
||||
|
||||
@@ -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
|
||||
File diff suppressed because it is too large
Load Diff
@@ -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>
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
@@ -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")]
|
||||
|
||||
@@ -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)));
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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
Reference in New Issue
Block a user