Skip to content

Commit c4ff073

Browse files
CmdPal: Extension Gallery (#46636)
## Summary of the Pull Request Adds the **Extension Gallery** to Command Palette — a built-in page where users can discover, browse, and install community extensions without leaving the app. https://github.com/user-attachments/assets/e4565333-b970-4085-9e40-5cfd207e533b ## How it works ### 1. The extension author's side Extensions are listed in the external repo **[`microsoft/CmdPal-Extensions`](https://github.com/microsoft/CmdPal-Extensions)**. To get an extension into the in-app gallery, an author opens a PR there that adds a single entry to `extensions.json`. Nothing in PowerToys itself needs to change. A typical entry looks like: ```json { "id": "contoso.sample", "title": "Sample Extension", "description": "Short blurb shown in the list and detail view.", "author": { "name": "Contoso", "url": "https://github.com/contoso" }, "homepage": "https://github.com/contoso/sample", "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_8wekyb..." } } ``` - `id`, `title`, `description`, `author.name`, and at least one `installSources` entry are required; everything else is optional. - `installSources` can mix and match `winget` / `msstore` / `url`. The gallery shows an install button for the first source it can handle (WinGet preferred) and exposes any remaining sources as links. - `detection.packageFamilyName` lets CmdPal recognise an already-installed packaged extension before any WinGet lookup resolves, so the "Installed" badge appears instantly. Once the PR is merged into `CmdPal-Extensions`, every running copy of CmdPal picks the new entry up the next time its feed cache expires (within 4 hours) or when the user clicks **Refresh**. ### 2. What CmdPal does with it `ExtensionGalleryService` (in `Microsoft.CmdPal.Common`) owns the whole pipeline: 1. **Resolve the feed URL.** Default is `https://raw.githubusercontent.com/microsoft/CmdPal-Extensions/refs/heads/main/extensions.json`. A hidden setting (`GalleryFeedUrl`) lets developers point at a custom URL or a `file://` path for local testing. 2. **Fetch** the feed through `ExtensionGalleryHttpClient`, which wraps `HttpCachingClient` — a conditional-GET + on-disk cache layer built on `HttpClient` (ETag / `If-None-Match`, 30 s timeout, UA `PowerToys-CmdPal/1.0`). 3. **Parse** with the source-generated `GallerySerializationContext` into a strongly-typed `GalleryRemoteIndex` (`{ "extensions": [ ... ] }`). Entries without an `id` are dropped. 4. **Normalize** relative `iconUrl` / `screenshotUrls` against the feed URL (useful for local `file://` feeds). 5. **Localize icons.** Each HTTP icon URL is pulled through the same cache and rewritten to a local `file://` URI before the view model binds to it, so the list renders instantly on subsequent loads and works offline. 6. **Prune** cached resources that are no longer referenced, but only after a successful forced refresh. The gallery page itself is built on top of `ExtensionGalleryViewModel`, with `ExtensionGalleryItemViewModel` handling per-entry concerns — install/update/uninstall (via the shared WinGet service), installed-state detection, and joining in-flight install progress so the global `WinGetOperationsButton` in the top bar stays in sync. ### 3. Caching + offline behaviour The cache lives under `ApplicationData.Current.LocalCacheFolder\GalleryCache\` when CmdPal runs packaged, or `%LOCALAPPDATA%\Microsoft\PowerToys\Microsoft.CmdPal\Cache\GalleryCache\` when unpackaged. | Resource | TTL | |-----------------|----------| | `extensions.json` feed | 4 hours | | Icons (per URL) | 24 hours | Each fetch returns a `GalleryFetchResult` whose flags drive the UI: - `FromCache` — cache was still fresh, no network call was made. - `UsedFallbackCache` — network failed; the last-known-good cached copy was served instead. The page shows a "showing cached data" info bar. - `RateLimited` — origin returned `429` and no fallback was available. The page shows a rate-limit error. `RefreshAsync` (wired up to the gallery's refresh button) forces a fresh conditional GET, then prunes any cached files that the new feed no longer references. ### 4. WinGet install flow - `installSources[type=winget].id` is handed to the shared WinGet service for install/update/uninstall. - In-flight operations are surfaced by `WinGetOperationsButton` in the top bar with per-operation progress. - `detection.packageFamilyName` is consulted first so that the gallery can show "Installed" / "Update available" without waiting on WinGet metadata. ### Top-level command cleanup - Removed the separate "Find extensions from WinGet" and "Find extensions from the Store" top-level commands — the gallery replaces both. - Renamed the gallery command to **"Find and install Command Palette extensions"** and gave it the extensions puzzle-piece icon. ## PR Checklist - [ ] **Communication:** I've discussed this with core contributors already. If the work hasn't been agreed, this work might be rejected - [ ] **Tests:** Added/updated and all pass - [ ] **Localization:** All end-user-facing strings can be localized - [ ] **Dev docs:** Added/updated - [ ] **New binaries:** Added on the required places - [ ] [JSON for signing](https://github.com/microsoft/PowerToys/blob/main/.pipelines/ESRPSigning_core.json) for new binaries - [ ] [WXS for installer](https://github.com/microsoft/PowerToys/blob/main/installer/PowerToysSetup/Product.wxs) for new binaries and localization folder - [ ] [YML for CI pipeline](https://github.com/microsoft/PowerToys/blob/main/.pipelines/ci/templates/build-powertoys-steps.yml) for new test projects - [ ] [YML for signed pipeline](https://github.com/microsoft/PowerToys/blob/main/.pipelines/release.yml) - [ ] **Documentation updated:** If checked, please file a pull request on [our docs repo](https://github.com/MicrosoftDocs/windows-uwp/tree/docs/hub/powertoys) and link it here: #xxx ## New projects / areas | Area | What | |------|------| | Microsoft.CmdPal.Common | Gallery models, `ExtensionGalleryService` (fetch + cache), HTTP caching layer (`HttpCachingClient`, `FileSystemHttpResourceCacheStore`), WinGet service abstractions and implementations | | Microsoft.CmdPal.UI.ViewModels | `ExtensionGalleryViewModel`, `ExtensionGalleryItemViewModel`, WinGet operation view models, gallery sort options | | Microsoft.CmdPal.UI | `ExtensionGalleryPage.xaml`, `ExtensionGalleryItemPage.xaml`, `IconCarouselControl`, `WinGetOperationsButton`, service registrations | | Microsoft.CmdPal.Ext.WinGet | Streamlined — removed the two redundant "find extensions" top-level commands, kept the general WinGet search page | | Tests | Unit tests for gallery service, gallery view models, WinGet services | | Docs | [`doc/devdocs/modules/cmdpal/extension-gallery/extension-gallery.md`](https://github.com/microsoft/PowerToys/blob/dev/jpolasek/f/46628-cmdpal-extension-gallery/doc/devdocs/modules/cmdpal/extension-gallery/extension-gallery.md) — dev reference for the runtime, caching, and feed shape | ## Validation Steps Performed - Gallery loads and displays extensions from the remote index - Search, sort, and filtering work as expected - WinGet install/update/uninstall flow works end-to-end with progress tracking - Loading state correctly hides all content until data is fetched - Offline / cache-fallback path surfaces the info bar as expected - Spell-check CI workflow passes --------- Co-authored-by: Niels Laute <niels.laute@live.nl> Co-authored-by: niels9001 <9866362+niels9001@users.noreply.github.com>
1 parent 59eefd9 commit c4ff073

109 files changed

Lines changed: 12032 additions & 682 deletions

File tree

Some content is hidden

Large Commits have some content hidden by default. Use the searchbox below for content that may be hidden.

.github/actions/spell-check/allow/names.txt

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -209,6 +209,7 @@ Bilibili
209209
BVID
210210
capturevideosample
211211
cmdow
212+
contoso
212213
Contoso
213214
Controlz
214215
cortana

.github/actions/spell-check/excludes.txt

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -105,13 +105,13 @@
105105
^src/common/ManagedCommon/ColorFormatHelper\.cs$
106106
^src/common/notifications/BackgroundActivatorDLL/cpp\.hint$
107107
^src/common/sysinternals/Eula/
108-
^src/modules/cmdpal/Tests/Microsoft\.CommandPalette\.Extensions\.Toolkit\.UnitTests/FuzzyMatcherComparisonTests.cs$
109-
^src/modules/cmdpal/Tests/Microsoft\.CommandPalette\.Extensions\.Toolkit\.UnitTests/FuzzyMatcherDiacriticsTests.cs$
110108
^doc/devdocs/modules/cmdpal/initial-sdk-spec/list-elements-mock-002\.pdn$
111109
^src/modules/cmdpal/ext/SamplePagesExtension/Pages/SampleMarkdownImagesPage\.cs$
112110
^src/modules/cmdpal/Microsoft\.CmdPal\.UI/Settings/InternalPage\.SampleData\.cs$
113111
^src/modules/cmdpal/Tests/Microsoft\.CmdPal\.Common\.UnitTests/.*\.TestData\.cs$
114112
^src/modules/cmdpal/Tests/Microsoft\.CmdPal\.Common\.UnitTests/Text/.*\.cs$
113+
^src/modules/cmdpal/Tests/Microsoft\.CommandPalette\.Extensions\.Toolkit\.UnitTests/FuzzyMatcherComparisonTests.cs$
114+
^src/modules/cmdpal/Tests/Microsoft\.CommandPalette\.Extensions\.Toolkit\.UnitTests/FuzzyMatcherDiacriticsTests.cs$
115115
^src/modules/colorPicker/ColorPickerUI/Shaders/GridShader\.cso$
116116
^src/modules/launcher/Plugins/Microsoft\.PowerToys\.Run\.Plugin\.TimeDate/Properties/
117117
^src/modules/MouseUtils/MouseJumpUI/MainForm\.resx$

.github/actions/spell-check/expect.txt

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1534,6 +1534,7 @@ resmimetype
15341534
RESOURCEID
15351535
RESTORETOMAXIMIZED
15361536
RETURNONLYFSDIRS
1537+
Revalidates
15371538
RGBQUAD
15381539
rgbs
15391540
rgelt
Lines changed: 167 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,167 @@
1+
# Command Palette Extension Gallery
2+
3+
This document describes how Command Palette (CmdPal) discovers extensions for
4+
the in-app **Extension gallery** page.
5+
6+
## At a glance
7+
8+
- The gallery loads a single JSON feed called `extensions.json` from a remote
9+
HTTPS URL, parses it, and renders the entries.
10+
- The default feed lives in the external repo
11+
**`microsoft/CmdPal-Extensions`** at
12+
`https://aka.ms/CmdPal-ExtensionsJson`.
13+
- Feed content + icon images are cached on disk so the page works offline and
14+
survives short network hiccups.
15+
- There is no WinGet discovery, no per-extension `manifest.json` fetch, and no
16+
other network call for rendering the list.
17+
18+
## Implementation pointers
19+
20+
| Concern | File |
21+
| --- | --- |
22+
| Fetching, parsing, caching, pruning | `Microsoft.CmdPal.Common/ExtensionGallery/Services/ExtensionGalleryService.cs` |
23+
| Resolving which URL to fetch | `Microsoft.CmdPal.Common/ExtensionGallery/Services/GalleryFeedUrlProvider.cs` + `Microsoft.CmdPal.UI/Helpers/GalleryServiceRegistration.cs` |
24+
| HTTP + on-disk cache | `Microsoft.CmdPal.Common/ExtensionGallery/Services/ExtensionGalleryHttpClient.cs` (wraps `Microsoft.CmdPal.Common/Services/HttpCaching/HttpCachingClient`) |
25+
| Feed + entry models | `Microsoft.CmdPal.Common/ExtensionGallery/Models/` |
26+
27+
## Feed URL resolution
28+
29+
`ExtensionGalleryService.GetFeedUrl()` returns, in order:
30+
31+
1. The user-configured URL from CmdPal settings (`SettingsModel.GalleryFeedUrl`,
32+
exposed via the hidden `InternalPage` settings page). Any non-empty value
33+
wins. Mostly used for local testing against a custom feed.
34+
2. Otherwise, the built-in default
35+
`https://aka.ms/CmdPal-ExtensionsJson`.
36+
37+
Local `file://` URIs are allowed too — `FetchFeedDocumentAsync` reads the file
38+
directly and bypasses the HTTP cache.
39+
40+
## Feed format
41+
42+
The feed is a single wrapped JSON document with inline entries:
43+
44+
```json
45+
{
46+
"$schema": "https://raw.githubusercontent.com/microsoft/CmdPal-Extensions/main/.github/schemas/gallery.schema.json",
47+
"extensions": [
48+
{
49+
"id": "sample-extension",
50+
"title": "Sample Extension",
51+
"description": "A sample extension demonstrating the gallery feed format.",
52+
"author": { "name": "Microsoft", "url": "https://github.com/microsoft" },
53+
"homepage": "https://github.com/microsoft/CmdPal-Extensions",
54+
"iconUrl": "https://.../icon.png",
55+
"screenshotUrls": ["https://.../screenshot-1.png"],
56+
"tags": ["sample"],
57+
"installSources": [
58+
{ "type": "winget", "id": "Contoso.SampleExtension" },
59+
{ "type": "msstore", "id": "9P…" },
60+
{ "type": "url", "uri": "https://github.com/contoso/sample/releases/latest" }
61+
],
62+
"detection": { "packageFamilyName": "Contoso.SampleExtension_1234567890abc" }
63+
}
64+
]
65+
}
66+
```
67+
68+
Only the `extensions` array is read at runtime. The authoritative JSON
69+
schema for an entry lives in the upstream feed repo
70+
([`microsoft/CmdPal-Extensions`](https://github.com/microsoft/CmdPal-Extensions));
71+
don't duplicate it here — it drifts.
72+
73+
### Required + optional entry fields
74+
75+
| Field | Required | Notes |
76+
| --- | --- | --- |
77+
| `id` | yes | Lowercase stable identifier; entries with empty id are dropped. |
78+
| `title` | yes | Display name. |
79+
| `description` | yes | Shown in list and detail views. |
80+
| `author.name` | yes | `author.url` optional. |
81+
| `installSources` | yes | At least one entry; see [Install sources](#install-sources). |
82+
| `homepage`, `iconUrl`, `screenshotUrls`, `tags`, `detection.packageFamilyName` | no | All optional. |
83+
84+
Relative `iconUrl` / `screenshotUrls` are resolved against the feed URL's
85+
directory (useful only for local / `file://` feeds during development).
86+
87+
## Install sources
88+
89+
Each entry's `installSources` is consumed by
90+
`ExtensionGalleryItemViewModel` to decide which install affordances to show.
91+
92+
| `type` | Required field | Behaviour |
93+
| --- | --- | --- |
94+
| `winget` | `id` | Enables the "Install via WinGet" button (uses the shared WinGet service), and joins in-flight install progress + installed/update status. |
95+
| `msstore` | `id` | Opens `ms-windows-store://pdp/?ProductId={id}`. |
96+
| `url` | `uri` | Shown as a "GitHub" or "Website" link depending on host. |
97+
98+
An entry can declare any combination. Sources the runtime does not recognise
99+
are surfaced as an "unknown source" indicator.
100+
101+
## Fetching and caching
102+
103+
`ExtensionGalleryService` uses `ExtensionGalleryHttpClient`, which wraps
104+
`HttpCachingClient` over a file-system cache. Both the feed JSON and any
105+
cacheable icon URLs are cached.
106+
107+
| Setting | Value | Defined in |
108+
| --- | --- | --- |
109+
| Cache root | `{AppCache}\GalleryCache\` | `ExtensionGalleryHttpClient.CacheDirectoryName` |
110+
| Feed TTL | 4 hours | `ExtensionGalleryHttpClient.DefaultTimeToLive` |
111+
| Icon TTL | 24 hours | `ExtensionGalleryService.IconCacheTtl` |
112+
| HTTP timeout | 15 s | `ExtensionGalleryHttpClient` |
113+
| `User-Agent` | `PowerToys-CmdPal/1.0` | `ExtensionGalleryHttpClient` |
114+
115+
`{AppCache}` resolves to `ApplicationData.Current.LocalCacheFolder` when
116+
CmdPal runs packaged, and to
117+
`%LOCALAPPDATA%\Microsoft\PowerToys\Microsoft.CmdPal\Cache\` when unpackaged
118+
(see `ApplicationInfoService.DetermineCacheDirectory`).
119+
120+
### Fetch flow
121+
122+
`GetExtensionsAsync` (normal load) and `RefreshAsync` (user-initiated
123+
refresh, `forceRefresh: true`) both go through `FetchWrappedFeedAsync`:
124+
125+
1. Resolve the feed URL (see above).
126+
2. If the URL is local, read it from disk. Otherwise, hand it to
127+
`HttpCachingClient.GetResourceAsync` which:
128+
- Serves a fresh cached copy if one exists and TTL has not elapsed.
129+
- Otherwise issues a conditional GET (ETag / `If-None-Match`). On `304
130+
Not Modified` it refreshes the cache metadata and returns the cached
131+
body.
132+
- On network failure it returns the last-known cached body with
133+
`UsedFallbackCache = true`, so the UI can show a "stale data" banner.
134+
3. Parse the JSON with the source-generated `GallerySerializationContext`
135+
(strongly-typed `GalleryRemoteIndex` — no reflection, AOT-friendly).
136+
4. Drop entries with missing `id`, normalize relative `iconUrl` and
137+
`screenshotUrls`, and resolve remote icon URIs through the same HTTP
138+
cache so the UI binds to local `file://` URIs.
139+
5. On a successful forced refresh, `PruneCachedResources` deletes cache
140+
entries that are no longer referenced by the current feed (old feed URL
141+
and icon URLs that dropped out of the feed).
142+
143+
### Fetch result flags
144+
145+
`GetExtensionsAsync` returns a `GalleryFetchResult` that the view model uses
146+
for UI hints:
147+
148+
| Flag | Meaning |
149+
| --- | --- |
150+
| `FromCache` | The feed came from cache without hitting the network (TTL still valid). |
151+
| `UsedFallbackCache` | A network request was attempted and failed, and the cached copy was served as fallback. The UI shows a stale-data info bar. |
152+
| `RateLimited` | The origin returned `429 Too Many Requests` and no fallback was available. The UI shows a rate-limit error. |
153+
154+
## Authoring
155+
156+
- Entries for the production gallery are added to the feed repo
157+
`microsoft/CmdPal-Extensions`.
158+
- For editor validation of an entry, reference the schema published in the
159+
upstream repo via the entry's `$schema` field.
160+
- Keep `id` stable once an extension is published — users may have it
161+
installed and the gallery keys install status by id.
162+
- Prefer providing a `winget` source when the extension ships through App
163+
Installer; the gallery uses it both for status ("Installed" / "Update
164+
available") and for the in-app install button.
165+
- `detection.packageFamilyName` lets the gallery recognise an
166+
already-installed packaged extension before WinGet metadata resolves.
167+
Lines changed: 12 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,12 @@
1+
// Copyright (c) Microsoft Corporation
2+
// The Microsoft Corporation licenses this file to you under the MIT license.
3+
// See the LICENSE file in the project root for more information.
4+
5+
namespace Microsoft.CmdPal.Common.ExtensionGallery.Models;
6+
7+
public sealed class GalleryAuthor
8+
{
9+
public string Name { get; set; } = string.Empty;
10+
11+
public string? Url { get; set; }
12+
}
Lines changed: 10 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,10 @@
1+
// Copyright (c) Microsoft Corporation
2+
// The Microsoft Corporation licenses this file to you under the MIT license.
3+
// See the LICENSE file in the project root for more information.
4+
5+
namespace Microsoft.CmdPal.Common.ExtensionGallery.Models;
6+
7+
public sealed class GalleryDetection
8+
{
9+
public string? PackageFamilyName { get; set; }
10+
}
Lines changed: 32 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,32 @@
1+
// Copyright (c) Microsoft Corporation
2+
// The Microsoft Corporation licenses this file to you under the MIT license.
3+
// See the LICENSE file in the project root for more information.
4+
5+
namespace Microsoft.CmdPal.Common.ExtensionGallery.Models;
6+
7+
public sealed class GalleryExtensionEntry
8+
{
9+
public string Id { get; set; } = string.Empty;
10+
11+
public string Title { get; set; } = string.Empty;
12+
13+
public string Description { get; set; } = string.Empty;
14+
15+
public string? ShortDescription { get; set; }
16+
17+
public GalleryAuthor Author { get; set; } = new();
18+
19+
public string? Homepage { get; set; }
20+
21+
public string? Readme { get; set; }
22+
23+
public string? IconUrl { get; set; }
24+
25+
public List<string> ScreenshotUrls { get; set; } = [];
26+
27+
public List<GalleryInstallSource> InstallSources { get; set; } = [];
28+
29+
public GalleryDetection? Detection { get; set; }
30+
31+
public List<string> Tags { get; set; } = [];
32+
}
Lines changed: 14 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,14 @@
1+
// Copyright (c) Microsoft Corporation
2+
// The Microsoft Corporation licenses this file to you under the MIT license.
3+
// See the LICENSE file in the project root for more information.
4+
5+
namespace Microsoft.CmdPal.Common.ExtensionGallery.Models;
6+
7+
public sealed class GalleryInstallSource
8+
{
9+
public string Type { get; set; } = string.Empty;
10+
11+
public string? Id { get; set; }
12+
13+
public string? Uri { get; set; }
14+
}
Lines changed: 13 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,13 @@
1+
// Copyright (c) Microsoft Corporation
2+
// The Microsoft Corporation licenses this file to you under the MIT license.
3+
// See the LICENSE file in the project root for more information.
4+
5+
namespace Microsoft.CmdPal.Common.ExtensionGallery.Models;
6+
7+
/// <summary>
8+
/// Represents the wrapped gallery index format where extension data is inline.
9+
/// </summary>
10+
public sealed class GalleryRemoteIndex
11+
{
12+
public List<GalleryExtensionEntry> Extensions { get; set; } = [];
13+
}
Lines changed: 14 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,14 @@
1+
// Copyright (c) Microsoft Corporation
2+
// The Microsoft Corporation licenses this file to you under the MIT license.
3+
// See the LICENSE file in the project root for more information.
4+
5+
using System.Text.Json.Serialization;
6+
7+
namespace Microsoft.CmdPal.Common.ExtensionGallery.Models;
8+
9+
[JsonSerializable(typeof(GalleryExtensionEntry))]
10+
[JsonSerializable(typeof(GalleryRemoteIndex))]
11+
[JsonSourceGenerationOptions(PropertyNameCaseInsensitive = true)]
12+
public sealed partial class GallerySerializationContext : JsonSerializerContext
13+
{
14+
}

0 commit comments

Comments
 (0)