Compare commits

...

32 Commits

Author SHA1 Message Date
Jiří Polášek
06a22ca72a Fix spelling in the parent PR 2026-04-11 12:56:14 +02:00
Jiří Polášek
adb805c273 Improve carousel icons quality 2026-04-11 12:52:08 +02:00
Jiří Polášek
d58a39a594 Smoothen carousel animation 2026-04-11 12:51:40 +02:00
Niels Laute
955a497799 Adding a carousel 2026-04-11 11:10:36 +02:00
Jiří Polášek
964e5af435 CmdPal: Add screenshots to gallery (#46843)
<!-- Enter a brief description/summary of your PR here. What does it
fix/what does it change/how was it tested (even manually, if necessary)?
-->
## Summary of the Pull Request

<!-- Please review the items on the PR checklist before submitting-->
## PR Checklist

- [ ] Closes: #xxx
<!-- - [ ] Closes: #yyy (add separate lines for additional resolved
issues) -->
- [ ] **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

<!-- Provide a more detailed description of the PR, other things fixed,
or any additional comments/features here -->
## Detailed Description of the Pull Request / Additional comments

<!-- Describe how you validated the behavior. Add automated tests
wherever possible, but list manual validation steps taken as well -->
## Validation Steps Performed

---------

Co-authored-by: Niels Laute <niels.laute@live.nl>
2026-04-10 09:52:51 +02:00
Jiří Polášek
fbc3a63af9 Refactor? You bet! 2026-04-04 17:26:15 +02:00
Jiří Polášek
25aebbfb38 Replace static loggers with DI 2026-04-04 12:08:25 +02:00
Jiří Polášek
46c62cc7bd S.P.E.L.L. 2026-04-04 03:32:56 +02:00
Jiří Polášek
3b5d8575ca Merge main 2026-04-04 03:23:18 +02:00
Jiří Polášek
5067cc21ed Replace StackPanel in the gallery tile template with a grid to allow correct trimming of the description 2026-04-04 02:21:59 +02:00
Jiří Polášek
496ce3f890 Make the http cache more generic and improve pruning 2026-04-04 01:42:24 +02:00
Jiří Polášek
d233b22bfb Switch from caching manifests to caching the feed 2026-04-04 00:33:12 +02:00
Jiří Polášek
0e4da80564 Make local testing feasible again 2026-04-04 00:11:54 +02:00
Jiří Polášek
4fd340152a Remove localization support 2026-04-03 22:46:56 +02:00
Jiří Polášek
6892960ce6 Placate spellchecker 2026-04-03 22:40:14 +02:00
Jiří Polášek
37bd1b4cb5 Fix spellchecking, attempt 2 2026-04-03 22:02:58 +02:00
Jiří Polášek
461e474396 Merge branch 'main' into dev/jpolasek/f/46628-cmdpal-extension-gallery 2026-04-03 21:47:11 +02:00
Jiří Polášek
ddcc462aa1 Exclude extension gallery sample data from spellchecking 2026-04-03 21:46:53 +02:00
Jiří Polášek
cf76163aa0 Revert "Fix spell-check: add flagged words to allow/expect lists"
This reverts commit b4ce152fb7.
2026-04-03 21:41:03 +02:00
Jiří Polášek
237b33a136 Move the cache out of the backuped folders 2026-04-03 21:06:43 +02:00
Jiří Polášek
71d342a2e6 Reformat XAML 2026-04-03 20:42:15 +02:00
Jiří Polášek
d133b6f3e7 Improve gallery data caching, introduce a generialize http resource cache, fix icon placeholder, fix gallery page stale data info bar 2026-04-03 20:40:42 +02:00
Jiří Polášek
570cb85b58 Drop Icon in favor of IconUrl 2026-04-03 20:04:07 +02:00
Jiří Polášek
d81e784b0a Merge branch 'main' into dev/jpolasek/f/46628-cmdpal-extension-gallery 2026-04-03 19:54:45 +02:00
Niels Laute
d3ba693eed More extension gallery changes (#46752)
<!-- Enter a brief description/summary of your PR here. What does it
fix/what does it change/how was it tested (even manually, if necessary)?
-->
## Summary of the Pull Request

<!-- Please review the items on the PR checklist before submitting-->
## PR Checklist

- [ ] Closes: #xxx
<!-- - [ ] Closes: #yyy (add separate lines for additional resolved
issues) -->
- [ ] **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

<!-- Provide a more detailed description of the PR, other things fixed,
or any additional comments/features here -->
## Detailed Description of the Pull Request / Additional comments

<!-- Describe how you validated the behavior. Add automated tests
wherever possible, but list manual validation steps taken as well -->
## Validation Steps Performed

---------

Co-authored-by: Jiří Polášek <me@jiripolasek.com>
2026-04-03 19:50:44 +02:00
Niels Laute
40c54b3d1f Merge branch 'main' into dev/jpolasek/f/46628-cmdpal-extension-gallery 2026-04-03 11:29:19 +02:00
Niels Laute
f042f3a33e Merge branch 'main' into dev/jpolasek/f/46628-cmdpal-extension-gallery 2026-04-01 15:11:06 +02:00
copilot-swe-agent[bot]
b4ce152fb7 Fix spell-check: add flagged words to allow/expect lists
Agent-Logs-Url: https://github.com/microsoft/PowerToys/sessions/e2de7cde-3866-4cfd-a69d-79629aa2b9fb

Co-authored-by: niels9001 <9866362+niels9001@users.noreply.github.com>
2026-04-01 10:03:45 +00:00
Niels Laute
8ad1456521 Merge branch 'main' into dev/jpolasek/f/46628-cmdpal-extension-gallery 2026-04-01 10:30:41 +02:00
Niels Laute
d3448ee133 Merge branch 'main' into dev/jpolasek/f/46628-cmdpal-extension-gallery 2026-03-31 11:45:41 +02:00
Jiří Polášek
0d1cc4c047 Merge main 2026-03-30 21:09:16 +02:00
Jiří Polášek
d01bd697a2 Import from jiripolasek/PowerToys 2026-03-30 20:53:53 +02:00
137 changed files with 13018 additions and 559 deletions

View File

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

View File

@@ -105,13 +105,14 @@
^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$
^src/modules/cmdpal/doc/initial-sdk-spec/list-elements-mock-002\.pdn$
^src/modules/cmdpal/doc/extension-gallery/sample-gallery/
^src/modules/cmdpal/ext/SamplePagesExtension/Pages/SampleMarkdownImagesPage\.cs$
^src/modules/cmdpal/Microsoft\.CmdPal\.UI/Settings/InternalPage\.SampleData\.cs$
^src/modules/cmdpal/Tests/Microsoft\.CmdPal\.Common\.UnitTests/.*\.TestData\.cs$
^src/modules/cmdpal/Tests/Microsoft\.CmdPal\.Common\.UnitTests/Text/.*\.cs$
^src/modules/cmdpal/Tests/Microsoft\.CommandPalette\.Extensions\.Toolkit\.UnitTests/FuzzyMatcherComparisonTests.cs$
^src/modules/cmdpal/Tests/Microsoft\.CommandPalette\.Extensions\.Toolkit\.UnitTests/FuzzyMatcherDiacriticsTests.cs$
^src/modules/colorPicker/ColorPickerUI/Shaders/GridShader\.cso$
^src/modules/launcher/Plugins/Microsoft\.PowerToys\.Run\.Plugin\.TimeDate/Properties/
^src/modules/MouseUtils/MouseJumpUI/MainForm\.resx$

View File

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

View File

@@ -0,0 +1,12 @@
// Copyright (c) Microsoft Corporation
// The Microsoft Corporation licenses this file to you under the MIT license.
// See the LICENSE file in the project root for more information.
namespace Microsoft.CmdPal.Common.ExtensionGallery.Models;
public sealed class GalleryAuthor
{
public string Name { get; set; } = string.Empty;
public string? Url { get; set; }
}

View File

@@ -0,0 +1,10 @@
// Copyright (c) Microsoft Corporation
// The Microsoft Corporation licenses this file to you under the MIT license.
// See the LICENSE file in the project root for more information.
namespace Microsoft.CmdPal.Common.ExtensionGallery.Models;
public sealed class GalleryDetection
{
public string? PackageFamilyName { get; set; }
}

View File

@@ -0,0 +1,32 @@
// Copyright (c) Microsoft Corporation
// The Microsoft Corporation licenses this file to you under the MIT license.
// See the LICENSE file in the project root for more information.
namespace Microsoft.CmdPal.Common.ExtensionGallery.Models;
public sealed class GalleryExtensionEntry
{
public string Id { get; set; } = string.Empty;
public string Title { get; set; } = string.Empty;
public string Description { get; set; } = string.Empty;
public string? ShortDescription { get; set; }
public GalleryAuthor Author { get; set; } = new();
public string? Homepage { get; set; }
public string? Readme { get; set; }
public string? IconUrl { get; set; }
public List<string> ScreenshotUrls { get; set; } = [];
public List<GalleryInstallSource> InstallSources { get; set; } = [];
public GalleryDetection? Detection { get; set; }
public List<string> Tags { get; set; } = [];
}

View File

@@ -0,0 +1,14 @@
// Copyright (c) Microsoft Corporation
// The Microsoft Corporation licenses this file to you under the MIT license.
// See the LICENSE file in the project root for more information.
namespace Microsoft.CmdPal.Common.ExtensionGallery.Models;
public sealed class GalleryInstallSource
{
public string Type { get; set; } = string.Empty;
public string? Id { get; set; }
public string? Uri { get; set; }
}

View File

@@ -0,0 +1,13 @@
// Copyright (c) Microsoft Corporation
// The Microsoft Corporation licenses this file to you under the MIT license.
// See the LICENSE file in the project root for more information.
namespace Microsoft.CmdPal.Common.ExtensionGallery.Models;
/// <summary>
/// Represents the wrapped gallery index format where extension data is inline.
/// </summary>
public sealed class GalleryRemoteIndex
{
public List<GalleryExtensionEntry> Extensions { get; set; } = [];
}

View File

@@ -0,0 +1,14 @@
// Copyright (c) Microsoft Corporation
// The Microsoft Corporation licenses this file to you under the MIT license.
// See the LICENSE file in the project root for more information.
using System.Text.Json.Serialization;
namespace Microsoft.CmdPal.Common.ExtensionGallery.Models;
[JsonSerializable(typeof(GalleryExtensionEntry))]
[JsonSerializable(typeof(GalleryRemoteIndex))]
[JsonSourceGenerationOptions(PropertyNameCaseInsensitive = true)]
public sealed partial class GallerySerializationContext : JsonSerializerContext
{
}

View File

@@ -0,0 +1,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.ExtensionGallery.Models;
public sealed class GallerySourceDetailItem
{
public string Label { get; set; } = string.Empty;
public string Value { get; set; } = string.Empty;
public Uri? LinkUri { get; set; }
public bool HasLink => LinkUri is not null;
public bool HasNoLink => !HasLink;
}

View File

@@ -0,0 +1,86 @@
// 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 GallerySourceDetails
{
private const string SummaryLabel = "Summary";
private const string DescriptionLabel = "Description";
private const string VersionLabel = "Version";
private const string TagsLabel = "Tags";
public string? Summary { get; set; }
public string? Description { get; set; }
public string? Version { get; set; }
public List<GallerySourceDetailItem> Items { get; set; } = [];
public List<string> Tags { get; set; } = [];
public bool HasSummary => !string.IsNullOrWhiteSpace(Summary);
public bool HasDescription => !string.IsNullOrWhiteSpace(Description);
public bool HasVersion => !string.IsNullOrWhiteSpace(Version);
public bool HasItems => Items.Count > 0;
public bool HasTags => Tags.Count > 0;
public bool HasContent => HasSummary || HasDescription || HasVersion || HasItems || HasTags;
public string TagsText => string.Join(", ", Tags.Where(tag => !string.IsNullOrWhiteSpace(tag)));
public List<GallerySourceDetailItem> FlattenedItems
{
get
{
List<GallerySourceDetailItem> flattened = [];
AddFlattenedItem(flattened, SummaryLabel, Summary, null);
AddFlattenedItem(flattened, DescriptionLabel, Description, null);
AddFlattenedItem(flattened, VersionLabel, Version, null);
for (var i = 0; i < Items.Count; i++)
{
var item = Items[i];
AddFlattenedItem(flattened, item.Label, item.Value, item.LinkUri);
}
AddFlattenedItem(flattened, TagsLabel, TagsText, null);
return flattened;
}
}
private static void AddFlattenedItem(ICollection<GallerySourceDetailItem> target, string? label, string? value, Uri? linkUri)
{
var normalizedLabel = NormalizeToNullIfWhiteSpace(label);
var normalizedValue = NormalizeToNullIfWhiteSpace(value);
if (normalizedLabel is null || (normalizedValue is null && linkUri is null))
{
return;
}
target.Add(new GallerySourceDetailItem
{
Label = normalizedLabel,
Value = normalizedValue ?? linkUri!.AbsoluteUri,
LinkUri = linkUri,
});
}
private static string? NormalizeToNullIfWhiteSpace(string? value)
{
if (string.IsNullOrWhiteSpace(value))
{
return null;
}
return value.Trim();
}
}

View File

@@ -0,0 +1,22 @@
// Copyright (c) Microsoft Corporation
// The Microsoft Corporation licenses this file to you under the MIT license.
// See the LICENSE file in the project root for more information.
namespace Microsoft.CmdPal.Common.ExtensionGallery.Models;
public sealed class GallerySourceInfo
{
public string Kind { get; set; } = string.Empty;
public string DisplayName { get; set; } = string.Empty;
public string? Id { get; set; }
public string? Uri { get; set; }
public bool IsKnown { get; set; }
public GallerySourceDetails? Details { get; set; }
public bool HasDetails => Details?.HasContent == true;
}

View File

@@ -0,0 +1,57 @@
// Copyright (c) Microsoft Corporation
// The Microsoft Corporation licenses this file to you under the MIT license.
// See the LICENSE file in the project root for more information.
using Microsoft.CmdPal.Common.Services;
using Microsoft.CmdPal.Common.Services.HttpCaching;
using Microsoft.Extensions.Logging;
namespace Microsoft.CmdPal.Common.ExtensionGallery.Services;
/// <summary>
/// Identifies the HTTP client instance used by the extension gallery.
/// </summary>
public sealed partial class ExtensionGalleryHttpClient : IDisposable
{
internal const string CacheDirectoryName = "GalleryCache";
private const int TimeoutSeconds = 15;
private const string UserAgent = "PowerToys-CmdPal/1.0";
private readonly HttpCachingClient _cache;
internal static readonly TimeSpan DefaultTimeToLive = TimeSpan.FromHours(4);
public ExtensionGalleryHttpClient(IApplicationInfoService applicationInfoService, ILogger<ExtensionGalleryHttpClient> logger)
: this(applicationInfoService, innerHandler: null, logger)
{
}
internal ExtensionGalleryHttpClient(IApplicationInfoService applicationInfoService, HttpMessageHandler? innerHandler, ILogger<ExtensionGalleryHttpClient> logger)
: this(
Path.Combine(applicationInfoService.CacheDirectory, CacheDirectoryName),
innerHandler,
logger)
{
ArgumentNullException.ThrowIfNull(applicationInfoService);
}
internal ExtensionGalleryHttpClient(string cacheDirectory, HttpMessageHandler? innerHandler, ILogger<ExtensionGalleryHttpClient> logger)
{
ArgumentException.ThrowIfNullOrWhiteSpace(cacheDirectory);
ArgumentNullException.ThrowIfNull(logger);
_cache = new HttpCachingClient(
cacheDirectory,
DefaultTimeToLive,
TimeSpan.FromSeconds(TimeoutSeconds),
UserAgent,
innerHandler,
logger);
}
internal HttpCachingClient Cache => _cache;
public void Dispose()
{
_cache.Dispose();
}
}

View File

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

View File

@@ -0,0 +1,7 @@
// Copyright (c) Microsoft Corporation
// The Microsoft Corporation licenses this file to you under the MIT license.
// See the LICENSE file in the project root for more information.
namespace Microsoft.CmdPal.Common.ExtensionGallery.Services;
public delegate string? GalleryFeedUrlProvider();

View File

@@ -0,0 +1,42 @@
// Copyright (c) Microsoft Corporation
// The Microsoft Corporation licenses this file to you under the MIT license.
// See the LICENSE file in the project root for more information.
using Microsoft.CmdPal.Common.ExtensionGallery.Models;
namespace Microsoft.CmdPal.Common.ExtensionGallery.Services;
public sealed record GalleryFetchResult
{
/// <summary>
/// Gets or sets the gallery entries returned by the fetch operation.
/// </summary>
public List<GalleryExtensionEntry> Extensions { get; init; } = [];
/// <summary>
/// Gets or sets a value indicating whether the result was loaded from cache.
/// </summary>
public bool FromCache { get; init; }
/// <summary>
/// Gets or sets a value indicating whether the service had to fall back to cached data
/// because a remote refresh could not be completed successfully.
/// </summary>
public bool UsedFallbackCache { get; init; }
/// <summary>
/// Gets or sets a value indicating whether the fetch failed because the gallery responded
/// with HTTP 429 Too Many Requests and no cached fallback data was available.
/// </summary>
public bool IsRateLimited { get; init; }
/// <summary>
/// Gets or sets a value indicating whether the fetch operation completed with an error.
/// </summary>
public bool HasError { get; init; }
/// <summary>
/// Gets or sets the error message associated with the fetch operation, when available.
/// </summary>
public string? ErrorMessage { get; init; }
}

View File

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

View File

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

View File

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

View File

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

View File

@@ -0,0 +1,12 @@
// Copyright (c) Microsoft Corporation
// The Microsoft Corporation licenses this file to you under the MIT license.
// See the LICENSE file in the project root for more information.
namespace Microsoft.CmdPal.Common.Services.HttpCaching.Abstraction;
internal sealed class CachedHttpFetchResult(CachedHttpResource resource, bool usedFallbackCache)
{
public CachedHttpResource Resource { get; } = resource;
public bool UsedFallbackCache { get; } = usedFallbackCache;
}

View File

@@ -0,0 +1,18 @@
// Copyright (c) Microsoft Corporation
// The Microsoft Corporation licenses this file to you under the MIT license.
// See the LICENSE file in the project root for more information.
namespace Microsoft.CmdPal.Common.Services.HttpCaching.Abstraction;
internal sealed class CachedHttpResource(string contentPath, string? contentType, bool fromCache, bool wasRevalidated)
{
public string ContentPath { get; } = Path.GetFullPath(contentPath);
public Uri ContentUri => new(ContentPath);
public string? ContentType { get; } = contentType;
public bool FromCache { get; } = fromCache;
public bool WasRevalidated { get; } = wasRevalidated;
}

View File

@@ -0,0 +1,26 @@
// Copyright (c) Microsoft Corporation
// The Microsoft Corporation licenses this file to you under the MIT license.
// See the LICENSE file in the project root for more information.
namespace Microsoft.CmdPal.Common.Services.HttpCaching.Abstraction;
internal sealed class CachedHttpResourceEntry(
Uri resourceUri,
string entryDirectory,
string metadataPath,
string payloadPath,
string payloadFileName,
HttpResourceCacheMetadata? metadata)
{
public Uri ResourceUri { get; } = resourceUri;
public string EntryDirectory { get; } = Path.GetFullPath(entryDirectory);
public string MetadataPath { get; } = Path.GetFullPath(metadataPath);
public string PayloadPath { get; } = Path.GetFullPath(payloadPath);
public string PayloadFileName { get; } = payloadFileName;
public HttpResourceCacheMetadata? Metadata { get; } = metadata;
}

View File

@@ -0,0 +1,22 @@
// Copyright (c) Microsoft Corporation
// The Microsoft Corporation licenses this file to you under the MIT license.
// See the LICENSE file in the project root for more information.
namespace Microsoft.CmdPal.Common.Services.HttpCaching.Abstraction;
internal sealed class HttpResourceCacheMetadata
{
public string? ContentType { get; set; }
public string? ETag { get; set; }
public DateTimeOffset? ExpiresUtc { get; set; }
public string FileName { get; set; } = "payload.bin";
public DateTimeOffset? LastModifiedUtc { get; set; }
public DateTimeOffset LastValidatedUtc { get; set; }
public string SourceUri { get; set; } = string.Empty;
}

View File

@@ -0,0 +1,20 @@
// Copyright (c) Microsoft Corporation
// The Microsoft Corporation licenses this file to you under the MIT license.
// See the LICENSE file in the project root for more information.
namespace Microsoft.CmdPal.Common.Services.HttpCaching.Abstraction;
internal interface IHttpResourceCacheStore
{
CachedHttpResourceEntry GetEntry(Uri resourceUri, string? fileNameHint = null);
CachedHttpResource? TryGetFresh(CachedHttpResourceEntry entry, TimeSpan? timeToLiveOverride);
CachedHttpResource? TryGetCached(CachedHttpResourceEntry entry, bool fromCache, bool wasRevalidated);
CachedHttpResource? UpdateAfterNotModified(CachedHttpResourceEntry entry, HttpResponseMessage response);
Task<CachedHttpResource> SaveResponseAsync(CachedHttpResourceEntry entry, HttpResponseMessage response, CancellationToken cancellationToken);
void Prune(IEnumerable<Uri> retainedResourceUris);
}

View File

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

View File

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

View File

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

View File

@@ -0,0 +1,13 @@
// Copyright (c) Microsoft Corporation
// The Microsoft Corporation licenses this file to you under the MIT license.
// See the LICENSE file in the project root for more information.
using System.Text.Json.Serialization;
using Microsoft.CmdPal.Common.Services.HttpCaching.Abstraction;
namespace Microsoft.CmdPal.Common.Services.HttpCaching;
[JsonSerializable(typeof(HttpResourceCacheMetadata))]
internal sealed partial class HttpResourceCacheJsonContext : JsonSerializerContext
{
}

View File

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

View File

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

View File

@@ -0,0 +1,46 @@
// 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;
namespace Microsoft.CmdPal.Common.WinGet.Interop;
#nullable disable
internal sealed class ClassModel
{
/// <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>
public IReadOnlyDictionary<ClsidContext, Guid> Clsids { get; init; }
/// <summary>
/// Get CLSID based on the provided context
/// </summary>
/// <param name="context">Context</param>
/// <returns>CLSID for the provided context.</returns>
/// <exception cref="InvalidOperationException">Throw an exception if the clsid context is not available for the current instance.</exception>
public Guid GetClsid(ClsidContext context)
{
return !Clsids.TryGetValue(context, out var clsid)
? throw new InvalidOperationException($"{ProjectedClassType.FullName} is not implemented in context {context}")
: clsid;
}
/// <summary>
/// Get IID corresponding to the COM object
/// </summary>
/// <returns>IID.</returns>
public Guid GetIid() => InterfaceId;
}

View File

@@ -0,0 +1,119 @@
// 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.Management.Deployment;
namespace Microsoft.CmdPal.Common.WinGet.Interop;
internal static class ClassesDefinition
{
private static Dictionary<Type, ClassModel> Classes { get; } = new()
{
[typeof(PackageManager)] = new()
{
ProjectedClassType = typeof(PackageManager),
InterfaceId = new Guid("B375E3B9-F2E0-5C93-87A7-B67497F7E593"),
Clsids = new Dictionary<ClsidContext, Guid>()
{
[ClsidContext.Prod] = new Guid("C53A4F16-787E-42A4-B304-29EFFB4BF597"),
[ClsidContext.Dev] = new Guid("74CB3139-B7C5-4B9E-9388-E6616DEA288C"),
},
},
[typeof(FindPackagesOptions)] = new()
{
ProjectedClassType = typeof(FindPackagesOptions),
InterfaceId = new Guid("A5270EDD-7DA7-57A3-BACE-F2593553561F"),
Clsids = new Dictionary<ClsidContext, Guid>()
{
[ClsidContext.Prod] = new Guid("572DED96-9C60-4526-8F92-EE7D91D38C1A"),
[ClsidContext.Dev] = new Guid("1BD8FF3A-EC50-4F69-AEEE-DF4C9D3BAA96"),
},
},
[typeof(CreateCompositePackageCatalogOptions)] = new()
{
ProjectedClassType = typeof(CreateCompositePackageCatalogOptions),
InterfaceId = new Guid("21ABAA76-089D-51C5-A745-C85EEFE70116"),
Clsids = new Dictionary<ClsidContext, Guid>()
{
[ClsidContext.Prod] = new Guid("526534B8-7E46-47C8-8416-B1685C327D37"),
[ClsidContext.Dev] = new Guid("EE160901-B317-4EA7-9CC6-5355C6D7D8A7"),
},
},
[typeof(InstallOptions)] = new()
{
ProjectedClassType = typeof(InstallOptions),
InterfaceId = new Guid("6EE9DB69-AB48-5E72-A474-33A924CD23B3"),
Clsids = new Dictionary<ClsidContext, Guid>()
{
[ClsidContext.Prod] = new Guid("1095F097-EB96-453B-B4E6-1613637F3B14"),
[ClsidContext.Dev] = new Guid("44FE0580-62F7-44D4-9E91-AA9614AB3E86"),
},
},
[typeof(UninstallOptions)] = new()
{
ProjectedClassType = typeof(UninstallOptions),
InterfaceId = new Guid("3EBC67F0-8339-594B-8A42-F90B69D02BBE"),
Clsids = new Dictionary<ClsidContext, Guid>()
{
[ClsidContext.Prod] = new Guid("E1D9A11E-9F85-4D87-9C17-2B93143ADB8D"),
[ClsidContext.Dev] = new Guid("AA2A5C04-1AD9-46C4-B74F-6B334AD7EB8C"),
},
},
[typeof(PackageMatchFilter)] = new()
{
ProjectedClassType = typeof(PackageMatchFilter),
InterfaceId = new Guid("D981ECA3-4DE5-5AD7-967A-698C7D60FC3B"),
Clsids = new Dictionary<ClsidContext, Guid>()
{
[ClsidContext.Prod] = new Guid("D02C9DAF-99DC-429C-B503-4E504E4AB000"),
[ClsidContext.Dev] = new Guid("3F85B9F4-487A-4C48-9035-2903F8A6D9E8"),
},
},
};
/// <summary>
/// Get CLSID based on the provided context for the specified type
/// </summary>
/// <typeparam name="T">Projected class type</typeparam>
/// <param name="context">Context</param>
/// <returns>CLSID for the provided context and type, or throw an exception if not found.</returns>
/// <exception cref="InvalidOperationException">Throws an exception if type is not a project class.</exception>
public static Guid GetClsid<T>(ClsidContext context)
{
ValidateType<T>();
return Classes[typeof(T)].GetClsid(context);
}
/// <summary>
/// Get IID corresponding to the COM object
/// </summary>
/// <typeparam name="T">Projected class type</typeparam>
/// <returns>IID or throw an exception if not found.</returns>
/// <exception cref="InvalidOperationException">Throws an exception if type is not a project class.</exception>
public static Guid GetIid<T>()
{
ValidateType<T>();
return Classes[typeof(T)].GetIid();
}
/// <summary>
/// Validate that the provided type is defined.
/// </summary>
/// <param name="type">Projected class type</param>
/// <exception cref="InvalidOperationException">Throws an exception if type is not a project class.</exception>
private static void ValidateType<TType>()
{
if (!Classes.ContainsKey(typeof(TType)))
{
throw new InvalidOperationException($"{typeof(TType).Name} is not a projected class type.");
}
}
}

View File

@@ -0,0 +1,14 @@
// Copyright (c) Microsoft Corporation
// The Microsoft Corporation licenses this file to you under the MIT license.
// See the LICENSE file in the project root for more information.
namespace Microsoft.CmdPal.Common.WinGet.Interop;
public enum ClsidContext
{
// Production CLSID Guids
Prod,
// Development CLSID Guids
Dev,
}

View File

@@ -0,0 +1,59 @@
// 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 Microsoft.CmdPal.Common.WinGet.Interop;
/// <summary>
/// Factory class for creating WinGet COM objects.
/// Details about each method can be found in the source IDL:
/// https://github.com/microsoft/winget-cli/blob/master/src/Microsoft.Management.Deployment/PackageManager.idl
/// </summary>
public abstract class WindowsPackageManagerFactory
{
private readonly ClsidContext _clsidContext;
public WindowsPackageManagerFactory(ClsidContext clsidContext)
{
_clsidContext = clsidContext;
}
/// <summary>
/// Creates an instance of the class <typeparamref name="T"/>.
/// </summary>
/// <remarks>
/// Type <typeparamref name="T"/> must be one of the types defined in the winget COM API.
/// Implementations of this method can assume that <paramref name="clsid"/> and <paramref name="iid"/>
/// are the right GUIDs for the class in the given context.
/// </remarks>
protected abstract T CreateInstance<T>(Guid clsid, Guid iid);
public PackageManager CreatePackageManager() => CreateInstance<PackageManager>();
public FindPackagesOptions CreateFindPackagesOptions() => CreateInstance<FindPackagesOptions>();
public CreateCompositePackageCatalogOptions CreateCreateCompositePackageCatalogOptions() => CreateInstance<CreateCompositePackageCatalogOptions>();
public InstallOptions CreateInstallOptions() => CreateInstance<InstallOptions>();
public UninstallOptions CreateUninstallOptions() => CreateInstance<UninstallOptions>();
public PackageMatchFilter CreatePackageMatchFilter() => CreateInstance<PackageMatchFilter>();
/// <summary>
/// Creates an instance of the class <typeparamref name="T"/>.
/// </summary>
/// <remarks>
/// This is a helper for calling the derived class's <see cref="CreateInstance{T}(Guid, Guid)"/>
/// method with the appropriate GUIDs.
/// </remarks>
private T CreateInstance<T>()
{
var clsid = ClassesDefinition.GetClsid<T>(_clsidContext);
var iid = ClassesDefinition.GetIid<T>();
return CreateInstance<T>(clsid, iid);
}
}

View File

@@ -0,0 +1,45 @@
// 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.Runtime.InteropServices;
using Windows.Win32;
using Windows.Win32.System.Com;
using WinRT;
namespace Microsoft.CmdPal.Common.WinGet.Interop;
public class WindowsPackageManagerStandardFactory : WindowsPackageManagerFactory
{
public WindowsPackageManagerStandardFactory(ClsidContext clsidContext = ClsidContext.Prod)
: base(clsidContext)
{
}
protected override T CreateInstance<T>(Guid clsid, Guid iid)
{
var pUnknown = IntPtr.Zero;
unsafe
{
try
{
var hr = PInvoke.CoCreateInstance(clsid, null, CLSCTX.CLSCTX_ALL, iid, out var result);
Marshal.ThrowExceptionForHR(hr);
pUnknown = new IntPtr(result);
return MarshalGeneric<T>.FromAbi(pUnknown);
}
finally
{
// CoCreateInstance and FromAbi both AddRef on the native object.
// Release once to prevent memory leak.
if (pUnknown != IntPtr.Zero)
{
Marshal.Release(pUnknown);
}
}
}
}
}

View File

@@ -0,0 +1,17 @@
// Copyright (c) Microsoft Corporation
// The Microsoft Corporation licenses this file to you under the MIT license.
// See the LICENSE file in the project root for more information.
namespace Microsoft.CmdPal.Common.WinGet.Models;
public sealed record WinGetExtensionCatalogEntry(
string PackageId,
string PackageName,
string? Summary,
string? Description,
string? Publisher,
string? PublisherUrl,
string? Author,
string? PackageUrl,
string? IconUrl,
IReadOnlyList<string> Tags);

View File

@@ -0,0 +1,9 @@
// Copyright (c) Microsoft Corporation
// The Microsoft Corporation licenses this file to you under the MIT license.
// See the LICENSE file in the project root for more information.
namespace Microsoft.CmdPal.Common.WinGet.Models;
public sealed record WinGetNamedLink(
string Label,
string Url);

View File

@@ -0,0 +1,23 @@
// Copyright (c) Microsoft Corporation
// The Microsoft Corporation licenses this file to you under the MIT license.
// See the LICENSE file in the project root for more information.
namespace Microsoft.CmdPal.Common.WinGet.Models;
public sealed record WinGetPackageDetails(
string? Name,
string? Version,
string? Summary,
string? Description,
string? Publisher,
string? PublisherUrl,
string? PublisherSupportUrl,
string? Author,
string? License,
string? LicenseUrl,
string? PackageUrl,
string? ReleaseNotes,
string? ReleaseNotesUrl,
string? IconUrl,
IReadOnlyList<WinGetNamedLink> DocumentationLinks,
IReadOnlyList<string> Tags);

View File

@@ -0,0 +1,9 @@
// Copyright (c) Microsoft Corporation
// The Microsoft Corporation licenses this file to you under the MIT license.
// See the LICENSE file in the project root for more information.
namespace Microsoft.CmdPal.Common.WinGet.Models;
public sealed record WinGetPackageInfo(
WinGetPackageStatus Status,
WinGetPackageDetails? Details);

View File

@@ -0,0 +1,27 @@
// Copyright (c) Microsoft Corporation
// The Microsoft Corporation licenses this file to you under the MIT license.
// See the LICENSE file in the project root for more information.
namespace Microsoft.CmdPal.Common.WinGet.Models;
public sealed record WinGetPackageOperation(
Guid OperationId,
string PackageId,
string PackageName,
WinGetPackageOperationKind Kind,
WinGetPackageOperationState State,
bool CanCancel,
bool IsIndeterminate,
uint? ProgressPercent,
ulong? BytesDownloaded,
ulong? BytesRequired,
string? ErrorMessage,
DateTimeOffset StartedAt,
DateTimeOffset UpdatedAt,
DateTimeOffset? CompletedAt)
{
public bool IsCompleted =>
State is WinGetPackageOperationState.Succeeded
or WinGetPackageOperationState.Failed
or WinGetPackageOperationState.Canceled;
}

View File

@@ -0,0 +1,11 @@
// Copyright (c) Microsoft Corporation
// The Microsoft Corporation licenses this file to you under the MIT license.
// See the LICENSE file in the project root for more information.
namespace Microsoft.CmdPal.Common.WinGet.Models;
public enum WinGetPackageOperationKind
{
Install = 0,
Uninstall = 1,
}

View File

@@ -0,0 +1,10 @@
// Copyright (c) Microsoft Corporation
// The Microsoft Corporation licenses this file to you under the MIT license.
// See the LICENSE file in the project root for more information.
namespace Microsoft.CmdPal.Common.WinGet.Models;
public sealed record WinGetPackageOperationResult(
bool Succeeded,
bool IsUnavailable,
string? ErrorMessage);

View File

@@ -0,0 +1,17 @@
// Copyright (c) Microsoft Corporation
// The Microsoft Corporation licenses this file to you under the MIT license.
// See the LICENSE file in the project root for more information.
namespace Microsoft.CmdPal.Common.WinGet.Models;
public enum WinGetPackageOperationState
{
Queued = 0,
Downloading = 1,
Installing = 2,
Uninstalling = 3,
PostProcessing = 4,
Succeeded = 5,
Failed = 6,
Canceled = 7,
}

View File

@@ -0,0 +1,11 @@
// Copyright (c) Microsoft Corporation
// The Microsoft Corporation licenses this file to you under the MIT license.
// See the LICENSE file in the project root for more information.
namespace Microsoft.CmdPal.Common.WinGet.Models;
public sealed record WinGetPackageStatus(
bool IsInstalled,
bool IsInstalledStateKnown,
bool IsUpdateAvailable,
bool IsUpdateStateKnown);

View File

@@ -0,0 +1,14 @@
// Copyright (c) Microsoft Corporation
// The Microsoft Corporation licenses this file to you under the MIT license.
// See the LICENSE file in the project root for more information.
namespace Microsoft.CmdPal.Common.WinGet.Models;
[System.Diagnostics.CodeAnalysis.SuppressMessage("StyleCop.CSharp.MaintainabilityRules", "SA1649:File name should match first type name", Justification = "Generic result type.")]
public sealed record WinGetQueryResult<T>(
T? Value,
bool IsUnavailable,
string? ErrorMessage)
{
public bool IsSuccess => string.IsNullOrWhiteSpace(ErrorMessage);
}

View File

@@ -0,0 +1,9 @@
// Copyright (c) Microsoft Corporation
// The Microsoft Corporation licenses this file to you under the MIT license.
// See the LICENSE file in the project root for more information.
namespace Microsoft.CmdPal.Common.WinGet.Models;
public sealed record WinGetServiceState(
bool IsAvailable,
string? Message);

View File

@@ -0,0 +1,44 @@
// Copyright (c) Microsoft Corporation
// The Microsoft Corporation licenses this file to you under the MIT license.
// See the LICENSE file in the project root for more information.
using Microsoft.CmdPal.Common.WinGet.Models;
namespace Microsoft.CmdPal.Common.WinGet.Services;
public interface IWinGetOperationTrackerService
{
/// <summary>
/// Gets the current and recently completed WinGet operations started by Command Palette.
/// </summary>
IReadOnlyList<WinGetPackageOperation> Operations { get; }
/// <summary>
/// Raised when a new tracked WinGet operation starts.
/// </summary>
event EventHandler<WinGetPackageOperationEventArgs>? OperationStarted;
/// <summary>
/// Raised when a tracked WinGet operation reports new progress.
/// </summary>
event EventHandler<WinGetPackageOperationEventArgs>? OperationUpdated;
/// <summary>
/// Raised when a tracked WinGet operation completes.
/// </summary>
event EventHandler<WinGetPackageOperationEventArgs>? OperationCompleted;
/// <summary>
/// Gets the newest tracked operation for a WinGet package id.
/// </summary>
/// <param name="packageId">The WinGet package id.</param>
/// <returns>The newest tracked operation for the package, or null when none is tracked.</returns>
WinGetPackageOperation? GetLatestOperation(string packageId);
/// <summary>
/// Requests cancellation for a tracked WinGet operation when supported.
/// </summary>
/// <param name="operationId">The tracked operation id.</param>
/// <returns>True when a cancellation request was issued; otherwise, false.</returns>
bool TryCancelOperation(Guid operationId);
}

View File

@@ -0,0 +1,87 @@
// 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>
/// Searches WinGet for Command Palette extensions and returns metadata shaped for gallery-style consumption.
/// </summary>
/// <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 Command Palette extension metadata.</returns>
Task<WinGetQueryResult<IReadOnlyList<WinGetExtensionCatalogEntry>>> SearchCommandPaletteExtensionsAsync(
uint resultLimit = 100,
CancellationToken cancellationToken = default);
/// <summary>
/// Resolves packages by WinGet package id.
/// </summary>
/// <param name="packageIds">The package ids to resolve.</param>
/// <param name="includeStoreCatalog">True to include the Store catalog in the lookup.</param>
/// <param name="cancellationToken">A token that cancels the lookup.</param>
/// <returns>A query result containing the resolved packages keyed by package id.</returns>
Task<WinGetQueryResult<IReadOnlyDictionary<string, CatalogPackage>>> GetPackagesByIdAsync(
IEnumerable<string> packageIds,
bool includeStoreCatalog = false,
CancellationToken cancellationToken = default);
/// <summary>
/// Installs or updates the provided package and refreshes package catalogs when possible.
/// </summary>
/// <param name="package">The package to install or update.</param>
/// <param name="skipDependencies">True to skip dependent packages when supported.</param>
/// <param name="progressHandler">An optional callback that receives install progress updates.</param>
/// <param name="cancellationToken">A token that cancels the install or update.</param>
/// <returns>The final result of the install or update operation.</returns>
Task<WinGetPackageOperationResult> InstallPackageAsync(
CatalogPackage package,
bool skipDependencies = false,
Action<InstallProgress>? progressHandler = null,
CancellationToken cancellationToken = default);
/// <summary>
/// Uninstalls the provided package and refreshes package catalogs when possible.
/// </summary>
/// <param name="package">The package to uninstall.</param>
/// <param name="progressHandler">An optional callback that receives uninstall progress updates.</param>
/// <param name="cancellationToken">A token that cancels the uninstall.</param>
/// <returns>The final result of the uninstall operation.</returns>
Task<WinGetPackageOperationResult> UninstallPackageAsync(
CatalogPackage package,
Action<UninstallProgress>? progressHandler = null,
CancellationToken cancellationToken = default);
/// <summary>
/// Refreshes WinGet package catalogs when supported and clears cached composite catalogs.
/// </summary>
/// <param name="cancellationToken">A token that cancels the refresh.</param>
/// <returns>True when catalog refresh was attempted successfully; otherwise, false.</returns>
Task<bool> RefreshCatalogsAsync(CancellationToken cancellationToken = default);
}

View File

@@ -0,0 +1,32 @@
// Copyright (c) Microsoft Corporation
// The Microsoft Corporation licenses this file to you under the MIT license.
// See the LICENSE file in the project root for more information.
using Microsoft.CmdPal.Common.WinGet.Models;
namespace Microsoft.CmdPal.Common.WinGet.Services;
public interface IWinGetPackageStatusService
{
/// <summary>
/// Tries to resolve WinGet package information for the provided package ids.
/// Returns null when WinGet lookups are unavailable.
/// </summary>
/// <param name="packageIds">The WinGet package ids to resolve.</param>
/// <param name="cancellationToken">A token that cancels the lookup.</param>
/// <returns>A package-info map keyed by package id, or null when status lookups are unavailable.</returns>
Task<IReadOnlyDictionary<string, WinGetPackageInfo>?> TryGetPackageInfosAsync(
IEnumerable<string> packageIds,
CancellationToken cancellationToken = default);
/// <summary>
/// Tries to resolve WinGet install/update status for the provided package ids.
/// Returns null when status detection is unavailable.
/// </summary>
/// <param name="packageIds">The WinGet package ids to inspect.</param>
/// <param name="cancellationToken">A token that cancels the lookup.</param>
/// <returns>A package-status map keyed by package id, or null when status detection is unavailable.</returns>
Task<IReadOnlyDictionary<string, WinGetPackageStatus>?> TryGetPackageStatusesAsync(
IEnumerable<string> packageIds,
CancellationToken cancellationToken = default);
}

View File

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

View File

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

View File

@@ -0,0 +1,294 @@
// 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;
}
public static WinGetExtensionCatalogEntry CreateExtensionCatalogEntry(CatalogPackage package)
{
var details = TryBuildPackageDetails(package);
var packageName = details?.Name ?? GetPackageDisplayName(package);
return new WinGetExtensionCatalogEntry(
PackageId: package.Id,
PackageName: packageName,
Summary: details?.Summary,
Description: details?.Description,
Publisher: details?.Publisher,
PublisherUrl: details?.PublisherUrl,
Author: details?.Author,
PackageUrl: details?.PackageUrl ?? details?.PublisherSupportUrl ?? details?.PublisherUrl,
IconUrl: details?.IconUrl,
Tags: details?.Tags ?? []);
}
private static bool HasDetailsContent(WinGetPackageDetails details)
{
return !string.IsNullOrWhiteSpace(details.Name)
|| !string.IsNullOrWhiteSpace(details.Version)
|| !string.IsNullOrWhiteSpace(details.Summary)
|| !string.IsNullOrWhiteSpace(details.Description)
|| !string.IsNullOrWhiteSpace(details.Publisher)
|| !string.IsNullOrWhiteSpace(details.PublisherUrl)
|| !string.IsNullOrWhiteSpace(details.PublisherSupportUrl)
|| !string.IsNullOrWhiteSpace(details.Author)
|| !string.IsNullOrWhiteSpace(details.License)
|| !string.IsNullOrWhiteSpace(details.LicenseUrl)
|| !string.IsNullOrWhiteSpace(details.PackageUrl)
|| !string.IsNullOrWhiteSpace(details.ReleaseNotes)
|| !string.IsNullOrWhiteSpace(details.ReleaseNotesUrl)
|| !string.IsNullOrWhiteSpace(details.IconUrl)
|| details.DocumentationLinks.Count > 0
|| details.Tags.Count > 0;
}
private static string? TryResolveIconUrl(CatalogPackageMetadata metadata)
{
var icons = TryGetRef(() => metadata.Icons);
if (icons is null)
{
return null;
}
for (var i = 0; i < icons.Count; i++)
{
var icon = icons[i];
var url = ValidateAbsoluteUri(TryGetString(() => icon.Url));
if (url is not null)
{
return url;
}
}
return null;
}
private static T? TryGetRef<T>(Func<T> getter)
where T : class
{
try
{
return getter();
}
catch
{
return null;
}
}
private static string? TryGetString(Func<string> getter)
{
try
{
return getter();
}
catch
{
return null;
}
}
private static string? ValidateAbsoluteUri(string? value)
{
var normalized = ToNullIfWhiteSpace(value);
if (normalized is null || !Uri.TryCreate(normalized, UriKind.Absolute, out _))
{
return null;
}
return normalized;
}
private static string? ToNullIfWhiteSpace(string? value)
{
if (string.IsNullOrWhiteSpace(value))
{
return null;
}
return value.Trim();
}
private static bool ContainsIgnoreCase(IReadOnlyList<string> values, string candidate)
{
for (var i = 0; i < values.Count; i++)
{
if (OrdinalIgnoreCase.Equals(values[i], candidate))
{
return true;
}
}
return false;
}
}

View File

@@ -0,0 +1,12 @@
// Copyright (c) Microsoft Corporation
// The Microsoft Corporation licenses this file to you under the MIT license.
// See the LICENSE file in the project root for more information.
using Microsoft.CmdPal.Common.WinGet.Models;
namespace Microsoft.CmdPal.Common.WinGet.Services;
public sealed class WinGetPackageOperationEventArgs(WinGetPackageOperation operation) : EventArgs
{
public WinGetPackageOperation Operation { get; } = operation;
}

View File

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

View File

@@ -0,0 +1,10 @@
// Copyright (c) Microsoft Corporation
// The Microsoft Corporation licenses this file to you under the MIT license.
// See the LICENSE file in the project root for more information.
namespace Microsoft.CmdPal.Common.WinGet;
public static class WinGetPackageTags
{
public const string CommandPaletteExtension = "windows-commandpalette-extension";
}

View File

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

View File

@@ -0,0 +1,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.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 */
{
}
}

View File

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

View File

@@ -0,0 +1,947 @@
// 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.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, GallerySourceInfo> _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 : "No description available.";
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 : "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<GallerySourceInfo> 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<GallerySourceInfo> SourcesWithDetails
{
get
{
List<GallerySourceInfo> 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
? "This extension has source metadata with an unsupported source type."
: "Source metadata is not available yet.";
public string NoSourceMenuText => "Source metadata not available";
public string NoSourceDetailsText => "This extension does not currently expose install or link metadata in the gallery feed.";
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) ? $"WinGet package: {WinGetId}" : "Available on WinGet";
public string StoreTooltip => !string.IsNullOrWhiteSpace(StoreId) ? $"Microsoft Store product: {StoreId}" : "Available on Microsoft Store";
public string GitHubTooltip => GetSource(SourceTypeGitHub)?.Uri ?? "GitHub source";
public string WebsiteTooltip => GetSource(SourceTypeWebsite)?.Uri ?? Homepage ?? "Website source";
public string WinGetMenuText => !string.IsNullOrWhiteSpace(WinGetId) ? $"WinGet: {WinGetId}" : "WinGet";
public string StoreMenuText => !string.IsNullOrWhiteSpace(StoreId) ? $"Microsoft Store: {StoreId}" : "Microsoft Store";
public string GitHubMenuText => "GitHub source";
public string WebsiteMenuText => "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 ? "Update" : "Install";
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
? "Update available"
: IsInstalled
? "Installed"
: IsInstalledStateKnown
? "Not installed"
: "Install status unavailable";
public string WinGetStatusText =>
!HasWinGetSource
? string.Empty
: IsUpdateAvailable
? "Installed, update available."
: IsInstalled
? "Installed."
: IsInstalledStateKnown
? "Not installed."
: "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 ? "Updating with WinGet..." : "Installing with WinGet...";
try
{
var packagesResult = await _winGetPackageManagerService.GetPackagesByIdAsync([WinGetId], includeStoreCatalog: false);
if (!packagesResult.IsSuccess)
{
WinGetActionMessage = packagesResult.ErrorMessage ?? "WinGet couldn't resolve this package.";
return;
}
if (packagesResult.Value is null || !packagesResult.Value.TryGetValue(WinGetId, out var package))
{
WinGetActionMessage = "The WinGet package couldn't be found.";
return;
}
var installResult = await _winGetPackageManagerService.InstallPackageAsync(package, skipDependencies: true);
if (!installResult.Succeeded)
{
WinGetActionMessage = installResult.ErrorMessage ?? "The WinGet 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
? "Queued for WinGet uninstall..."
: treatAsUpdate
? "Queued for WinGet update..."
: "Queued for WinGet install...";
break;
case WinGetPackageOperationState.Downloading:
IsWinGetActionInProgress = true;
IsWinGetActionIndeterminate = !operation.ProgressPercent.HasValue;
WinGetActionProgressValue = operation.ProgressPercent ?? 0;
WinGetActionMessage = operation.ProgressPercent is uint progressPercent
? $"Downloading with WinGet... {progressPercent}%"
: "Downloading with WinGet...";
break;
case WinGetPackageOperationState.Installing:
IsWinGetActionInProgress = true;
IsWinGetActionIndeterminate = true;
WinGetActionProgressValue = 0;
WinGetActionMessage = treatAsUpdate ? "Updating with WinGet..." : "Installing with WinGet...";
break;
case WinGetPackageOperationState.Uninstalling:
IsWinGetActionInProgress = true;
IsWinGetActionIndeterminate = true;
WinGetActionProgressValue = 0;
WinGetActionMessage = "Uninstalling with WinGet...";
break;
case WinGetPackageOperationState.PostProcessing:
IsWinGetActionInProgress = true;
IsWinGetActionIndeterminate = true;
WinGetActionProgressValue = 0;
WinGetActionMessage = "Finishing WinGet operation...";
break;
case WinGetPackageOperationState.Succeeded:
IsWinGetActionInProgress = false;
IsWinGetActionIndeterminate = false;
WinGetActionProgressValue = 100;
WinGetActionMessage = operation.Kind == WinGetPackageOperationKind.Uninstall
? "Extension uninstalled with WinGet."
: treatAsUpdate
? "Extension updated with WinGet."
: "Extension installed with WinGet.";
ApplyOptimisticTrackedCompletion(operation.Kind);
break;
case WinGetPackageOperationState.Canceled:
IsWinGetActionInProgress = false;
IsWinGetActionIndeterminate = false;
WinGetActionProgressValue = 0;
WinGetActionMessage = "The WinGet operation was canceled.";
break;
case WinGetPackageOperationState.Failed:
IsWinGetActionInProgress = false;
IsWinGetActionIndeterminate = false;
WinGetActionProgressValue = 0;
WinGetActionMessage = operation.ErrorMessage ?? "The WinGet operation 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 GallerySourceInfo? 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<GallerySourceInfo> SourceList, IReadOnlyDictionary<string, GallerySourceInfo> SourceByKind) BuildSourceInfos(
IReadOnlyDictionary<string, GalleryInstallSource> installSourcesByType,
string? homepage)
{
Dictionary<string, GallerySourceInfo> sourcesByKind = new(OrdinalIgnoreCase);
foreach (var installSource in installSourcesByType.Values)
{
var sourceInfo = CreateSourceInfoFromInstallSource(installSource);
if (sourceInfo is null)
{
continue;
}
UpsertSourceInfo(sourcesByKind, sourceInfo);
}
if (TryCreateSourceInfoFromUri(homepage, out var homepageSource))
{
UpsertSourceInfo(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 UpsertSourceInfo(IDictionary<string, GallerySourceInfo> sourcesByKind, GallerySourceInfo sourceInfo)
{
if (sourcesByKind.TryGetValue(sourceInfo.Kind, out var existing))
{
sourcesByKind[sourceInfo.Kind] = MergeSourceInfo(existing, sourceInfo);
return;
}
sourcesByKind[sourceInfo.Kind] = sourceInfo;
}
private static GallerySourceInfo MergeSourceInfo(GallerySourceInfo existing, GallerySourceInfo incoming)
{
return new GallerySourceInfo
{
Kind = existing.Kind,
DisplayName = existing.DisplayName,
Id = !string.IsNullOrWhiteSpace(existing.Id) ? existing.Id : incoming.Id,
Uri = !string.IsNullOrWhiteSpace(existing.Uri) ? existing.Uri : incoming.Uri,
IsKnown = existing.IsKnown || incoming.IsKnown,
};
}
private static GallerySourceInfo? CreateSourceInfoFromInstallSource(GalleryInstallSource installSource)
{
var normalizedType = NormalizeSourceType(installSource.Type);
if (normalizedType is null)
{
return null;
}
return normalizedType switch
{
SourceTypeWinGet => new GallerySourceInfo
{
Kind = SourceTypeWinGet,
DisplayName = "WinGet",
Id = installSource.Id,
IsKnown = true,
},
SourceTypeStore => new GallerySourceInfo
{
Kind = SourceTypeStore,
DisplayName = "Microsoft Store",
Id = installSource.Id,
IsKnown = true,
},
SourceTypeUrl => CreateSourceInfoFromUrl(installSource.Uri),
_ => new GallerySourceInfo
{
Kind = SourceTypeUnknown,
DisplayName = $"Source: {normalizedType}",
Id = installSource.Id,
Uri = installSource.Uri,
IsKnown = false,
},
};
}
private static GallerySourceInfo CreateSourceInfoFromUrl(string? url)
{
if (IsGitHubUri(url))
{
return new GallerySourceInfo
{
Kind = SourceTypeGitHub,
DisplayName = "GitHub",
Uri = url,
IsKnown = true,
};
}
return new GallerySourceInfo
{
Kind = SourceTypeWebsite,
DisplayName = "Website",
Uri = url,
IsKnown = true,
};
}
private static bool TryCreateSourceInfoFromUri(string? uriValue, out GallerySourceInfo sourceInfo)
{
sourceInfo = default!;
if (string.IsNullOrWhiteSpace(uriValue) || !Uri.TryCreate(uriValue, UriKind.Absolute, out _))
{
return false;
}
sourceInfo = CreateSourceInfoFromUrl(uriValue);
return true;
}
private static string? NormalizeSourceType(string? sourceType)
{
if (string.IsNullOrWhiteSpace(sourceType))
{
return null;
}
return sourceType.Trim().ToLowerInvariant();
}
private void ApplySourceDetails(string sourceKind, GallerySourceDetails details)
{
if (!_sourcesByKind.TryGetValue(sourceKind, out var source))
{
return;
}
source.Details = details;
OnPropertyChanged(nameof(SourcesWithDetails));
OnPropertyChanged(nameof(HasSourceMetadataDetails));
}
private static GallerySourceDetails CreateSourceDetails(WinGetPackageDetails details)
{
GallerySourceDetails sourceDetails = new()
{
Summary = details.Summary,
Description = details.Description,
Version = details.Version,
};
AddDetail(sourceDetails.Items, "Package", details.Name, uri: null);
AddDetail(sourceDetails.Items, "Publisher", details.Publisher, details.PublisherUrl);
AddDetail(sourceDetails.Items, "Author", details.Author, uri: null);
AddDetail(sourceDetails.Items, "License", details.License, details.LicenseUrl);
AddDetail(sourceDetails.Items, "Support", null, details.PublisherSupportUrl);
AddDetail(sourceDetails.Items, "Package page", null, details.PackageUrl);
AddDetail(sourceDetails.Items, "Release notes", details.ReleaseNotes, details.ReleaseNotesUrl);
for (var i = 0; i < details.DocumentationLinks.Count; i++)
{
var link = details.DocumentationLinks[i];
AddDetail(sourceDetails.Items, link.Label, null, link.Url);
}
for (var i = 0; i < details.Tags.Count; i++)
{
var tag = details.Tags[i];
if (!string.IsNullOrWhiteSpace(tag))
{
sourceDetails.Tags.Add(tag);
}
}
return sourceDetails;
}
private static void AddDetail(ICollection<GallerySourceDetailItem> 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 GallerySourceDetailItem
{
Label = label,
Value = normalizedValue ?? normalizedUri!.AbsoluteUri,
LinkUri = 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 NormalizeStatusText(string value)
{
return string.IsNullOrWhiteSpace(value) ? string.Empty : value.Trim().TrimEnd('.');
}
public async Task RefreshWinGetPackageInfoAsync(WinGetPackageOperationKind completedOperationKind = WinGetPackageOperationKind.Install)
{
if (_winGetPackageStatusService is not null && !string.IsNullOrWhiteSpace(WinGetId))
{
var infos = await _winGetPackageStatusService.TryGetPackageInfosAsync([WinGetId]);
if (infos is not null && infos.TryGetValue(WinGetId, out var packageInfo))
{
ApplyWinGetPackageInfo(packageInfo);
return;
}
}
IsInstalled = completedOperationKind != WinGetPackageOperationKind.Uninstall;
IsInstalledStateKnown = true;
IsUpdateAvailable = false;
IsUpdateStateKnown = true;
}
private void ApplyOptimisticTrackedCompletion(WinGetPackageOperationKind completedOperationKind)
{
IsInstalled = completedOperationKind != WinGetPackageOperationKind.Uninstall;
IsInstalledStateKnown = true;
IsUpdateAvailable = false;
IsUpdateStateKnown = true;
}
private ImageSource CreateImageSource(Uri iconUri)
{
try
{
return new BitmapImage(iconUri);
}
catch (Exception ex)
{
LogIconLoadFailed(_logger, iconUri.AbsoluteUri, ex);
return new BitmapImage(PlaceholderIconUri);
}
}
private static void LogWinGetInstallFailed(ILogger logger, Exception exception)
{
LogWinGetInstallFailedMessage(logger, exception);
}
private static void LogIconLoadFailed(ILogger logger, string iconUri, Exception exception)
{
LogIconLoadFailedMessage(logger, iconUri, exception);
}
partial void OnIsInstalledChanged(bool value)
{
OnPropertyChanged(nameof(ShowInstalledBadge));
OnPropertyChanged(nameof(ShowInstallButton));
OnPropertyChanged(nameof(InstallStatusText));
OnPropertyChanged(nameof(WinGetStatusText));
OnPropertyChanged(nameof(ShowWinGetStatusDetails));
OnPropertyChanged(nameof(ShowInstallViaWinGetButton));
OnPropertyChanged(nameof(CanInstallViaWinGet));
OnPropertyChanged(nameof(InstallViaWinGetText));
OnPropertyChanged(nameof(ShowWinGetActionControls));
InstallViaWinGetCommand.NotifyCanExecuteChanged();
}
partial void OnIsInstalledStateKnownChanged(bool value)
{
OnPropertyChanged(nameof(InstallStatusText));
OnPropertyChanged(nameof(WinGetStatusText));
OnPropertyChanged(nameof(ShowWinGetStatusDetails));
}
partial void OnIsUpdateAvailableChanged(bool value)
{
OnPropertyChanged(nameof(ShowInstalledBadge));
OnPropertyChanged(nameof(ShowInstallButton));
OnPropertyChanged(nameof(ShowUpdateBadge));
OnPropertyChanged(nameof(InstallStatusText));
OnPropertyChanged(nameof(WinGetStatusText));
OnPropertyChanged(nameof(ShowWinGetStatusDetails));
OnPropertyChanged(nameof(ShowInstallViaWinGetButton));
OnPropertyChanged(nameof(CanInstallViaWinGet));
OnPropertyChanged(nameof(InstallViaWinGetText));
OnPropertyChanged(nameof(ShowWinGetActionControls));
InstallViaWinGetCommand.NotifyCanExecuteChanged();
}
partial void OnIsWinGetActionInProgressChanged(bool value)
{
OnPropertyChanged(nameof(CanInstallViaWinGet));
OnPropertyChanged(nameof(ShowWinGetActionIndicator));
OnPropertyChanged(nameof(ShowWinGetActionStatus));
OnPropertyChanged(nameof(ShowCancelWinGetActionButton));
OnPropertyChanged(nameof(ShowWinGetActionControls));
InstallViaWinGetCommand.NotifyCanExecuteChanged();
CancelWinGetActionCommand.NotifyCanExecuteChanged();
}
partial void OnCanCancelWinGetActionChanged(bool value)
{
OnPropertyChanged(nameof(ShowCancelWinGetActionButton));
CancelWinGetActionCommand.NotifyCanExecuteChanged();
}
partial void OnWinGetActionMessageChanged(string? value)
{
OnPropertyChanged(nameof(HasWinGetActionMessage));
OnPropertyChanged(nameof(ShowWinGetActionStatus));
}
}

View File

@@ -0,0 +1,41 @@
// Copyright (c) Microsoft Corporation
// The Microsoft Corporation licenses this file to you under the MIT license.
// See the LICENSE file in the project root for more information.
using Microsoft.CmdPal.Common.ExtensionGallery.Models;
using Microsoft.CmdPal.Common.WinGet.Services;
using Microsoft.Extensions.Logging;
namespace Microsoft.CmdPal.UI.ViewModels.Gallery;
public sealed class ExtensionGalleryItemViewModelFactory
{
private readonly ILogger<ExtensionGalleryItemViewModel> _logger;
private readonly IWinGetPackageManagerService? _winGetPackageManagerService;
private readonly IWinGetOperationTrackerService? _winGetOperationTrackerService;
private readonly IWinGetPackageStatusService? _winGetPackageStatusService;
public ExtensionGalleryItemViewModelFactory(
ILogger<ExtensionGalleryItemViewModel> logger,
IWinGetPackageManagerService? winGetPackageManagerService = null,
IWinGetPackageStatusService? winGetPackageStatusService = null,
IWinGetOperationTrackerService? winGetOperationTrackerService = null)
{
_logger = logger;
_winGetPackageManagerService = winGetPackageManagerService;
_winGetPackageStatusService = winGetPackageStatusService;
_winGetOperationTrackerService = winGetOperationTrackerService;
}
public ExtensionGalleryItemViewModel Create(GalleryExtensionEntry entry)
{
ArgumentNullException.ThrowIfNull(entry);
return new ExtensionGalleryItemViewModel(
entry,
_logger,
_winGetPackageManagerService,
_winGetPackageStatusService,
_winGetOperationTrackerService);
}
}

View File

@@ -0,0 +1,36 @@
// 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.UI.Xaml.Media;
using Microsoft.UI.Xaml.Media.Imaging;
namespace Microsoft.CmdPal.UI.ViewModels.Gallery;
public sealed class ExtensionGalleryScreenshotViewModel
{
public ExtensionGalleryScreenshotViewModel(Uri uri, int index)
{
ArgumentNullException.ThrowIfNull(uri);
Uri = uri;
Index = index;
DisplayName = $"Screenshot {index + 1}";
}
public Uri Uri { get; }
public int Index { get; }
public string DisplayName { get; }
public ImageSource ImageSource => field ??= CreateImageSource(Uri);
private static ImageSource CreateImageSource(Uri uri)
{
BitmapImage bitmap = new();
bitmap.DecodePixelWidth = 720;
bitmap.UriSource = uri;
return bitmap;
}
}

View File

@@ -0,0 +1,13 @@
// Copyright (c) Microsoft Corporation
// The Microsoft Corporation licenses this file to you under the MIT license.
// See the LICENSE file in the project root for more information.
namespace Microsoft.CmdPal.UI.ViewModels.Gallery;
public enum ExtensionGallerySortOption
{
Featured = 0,
Name = 1,
Author = 2,
InstallationStatus = 3,
}

View File

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

View File

@@ -194,17 +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)
{
var wrappers = await CreateWrappersForExtension(extension);
UpdateExtensionsListsFromWrappers(wrappers);
}
}
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
{
@@ -233,6 +236,58 @@ 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)
{
var wrappers = await CreateWrappersForExtension(extension);
UpdateExtensionsListsFromWrappers(wrappers);
}
}
return includeDisabledExtensions ? _installedExtensions : _enabledExtensions;
}
private static async Task RebuildInstalledExtensionsCacheAsync()
{
var previouslyEnabledExtensionIds = new HashSet<string>(
_enabledExtensions.Select(static extension => extension.ExtensionUniqueId),
StringComparer.Ordinal);
var previouslyInstalledExtensionIds = new HashSet<string>(
_installedExtensions.Select(static extension => extension.ExtensionUniqueId),
StringComparer.Ordinal);
var extensions = await GetInstalledAppExtensionsAsync();
List<ExtensionWrapper> refreshedWrappers = [];
foreach (var extension in extensions)
{
var wrappers = await CreateWrappersForExtension(extension);
refreshedWrappers.AddRange(wrappers);
}
_installedExtensions.Clear();
_enabledExtensions.Clear();
foreach (var extensionWrapper in refreshedWrappers)
{
_installedExtensions.Add(extensionWrapper);
var wasPreviouslyInstalled = previouslyInstalledExtensionIds.Contains(extensionWrapper.ExtensionUniqueId);
var shouldBeEnabled = !wasPreviouslyInstalled || previouslyEnabledExtensionIds.Contains(extensionWrapper.ExtensionUniqueId);
if (shouldBeEnabled)
{
_enabledExtensions.Add(extensionWrapper);
}
}
}
private static async Task<List<ExtensionWrapper>> CreateWrappersForExtension(AppExtension extension)
{
var (cmdPalProvider, classIds) = await GetCmdPalExtensionPropertiesAsync(extension);

View File

@@ -411,6 +411,15 @@ namespace Microsoft.CmdPal.UI.ViewModels.Properties {
}
}
/// <summary>
/// Looks up a localized string similar to Open Command Palette extension gallery.
/// </summary>
public static string builtin_open_gallery_name {
get {
return ResourceManager.GetString("builtin_open_gallery_name", resourceCulture);
}
}
/// <summary>
/// Looks up a localized string similar to Open Command Palette settings.
/// </summary>
@@ -528,6 +537,60 @@ namespace Microsoft.CmdPal.UI.ViewModels.Properties {
}
}
/// <summary>
/// Looks up a localized string similar to We couldn&apos;t load the extension gallery right now. Please try again in a little while..
/// </summary>
public static string gallery_error_generic_message {
get {
return ResourceManager.GetString("gallery_error_generic_message", resourceCulture);
}
}
/// <summary>
/// Looks up a localized string similar to Failed to load extensions.
/// </summary>
public static string gallery_error_generic_title {
get {
return ResourceManager.GetString("gallery_error_generic_title", resourceCulture);
}
}
/// <summary>
/// Looks up a localized string similar to We hit the extension gallery rate limit. Please try again in a little while..
/// </summary>
public static string gallery_error_rate_limited_message {
get {
return ResourceManager.GetString("gallery_error_rate_limited_message", resourceCulture);
}
}
/// <summary>
/// Looks up a localized string similar to The gallery is taking a breather.
/// </summary>
public static string gallery_error_rate_limited_title {
get {
return ResourceManager.GetString("gallery_error_rate_limited_title", resourceCulture);
}
}
/// <summary>
/// Looks up a localized string similar to {0} extensions available.
/// </summary>
public static string gallery_n_extensions_available {
get {
return ResourceManager.GetString("gallery_n_extensions_available", resourceCulture);
}
}
/// <summary>
/// Looks up a localized string similar to {0} extensions found.
/// </summary>
public static string gallery_n_extensions_found {
get {
return ResourceManager.GetString("gallery_n_extensions_found", resourceCulture);
}
}
/// <summary>
/// Looks up a localized string similar to Commands.
/// </summary>
@@ -563,5 +626,194 @@ namespace Microsoft.CmdPal.UI.ViewModels.Properties {
return ResourceManager.GetString("ShowDetailsCommand", resourceCulture);
}
}
/// <summary>
/// Looks up a localized string similar to {0} of {1}.
/// </summary>
public static string winget_operation_detail_progress {
get {
return ResourceManager.GetString("winget_operation_detail_progress", resourceCulture);
}
}
/// <summary>
/// Looks up a localized string similar to Updated {0:t}.
/// </summary>
public static string winget_operation_detail_updated {
get {
return ResourceManager.GetString("winget_operation_detail_updated", resourceCulture);
}
}
/// <summary>
/// Looks up a localized string similar to {0} B.
/// </summary>
public static string winget_operation_size_bytes {
get {
return ResourceManager.GetString("winget_operation_size_bytes", resourceCulture);
}
}
/// <summary>
/// Looks up a localized string similar to {0:F1} GB.
/// </summary>
public static string winget_operation_size_gigabytes {
get {
return ResourceManager.GetString("winget_operation_size_gigabytes", resourceCulture);
}
}
/// <summary>
/// Looks up a localized string similar to {0:F1} KB.
/// </summary>
public static string winget_operation_size_kilobytes {
get {
return ResourceManager.GetString("winget_operation_size_kilobytes", resourceCulture);
}
}
/// <summary>
/// Looks up a localized string similar to {0:F1} MB.
/// </summary>
public static string winget_operation_size_megabytes {
get {
return ResourceManager.GetString("winget_operation_size_megabytes", resourceCulture);
}
}
/// <summary>
/// Looks up a localized string similar to Canceled.
/// </summary>
public static string winget_operation_status_canceled {
get {
return ResourceManager.GetString("winget_operation_status_canceled", resourceCulture);
}
}
/// <summary>
/// Looks up a localized string similar to Downloading.
/// </summary>
public static string winget_operation_status_downloading {
get {
return ResourceManager.GetString("winget_operation_status_downloading", resourceCulture);
}
}
/// <summary>
/// Looks up a localized string similar to Downloading {0}%.
/// </summary>
public static string winget_operation_status_downloading_percent {
get {
return ResourceManager.GetString("winget_operation_status_downloading_percent", resourceCulture);
}
}
/// <summary>
/// Looks up a localized string similar to Failed.
/// </summary>
public static string winget_operation_status_failed {
get {
return ResourceManager.GetString("winget_operation_status_failed", resourceCulture);
}
}
/// <summary>
/// Looks up a localized string similar to Installing.
/// </summary>
public static string winget_operation_status_installing {
get {
return ResourceManager.GetString("winget_operation_status_installing", resourceCulture);
}
}
/// <summary>
/// Looks up a localized string similar to Finalizing.
/// </summary>
public static string winget_operation_status_post_processing {
get {
return ResourceManager.GetString("winget_operation_status_post_processing", resourceCulture);
}
}
/// <summary>
/// Looks up a localized string similar to Queued to install.
/// </summary>
public static string winget_operation_status_queued_install {
get {
return ResourceManager.GetString("winget_operation_status_queued_install", resourceCulture);
}
}
/// <summary>
/// Looks up a localized string similar to Queued to uninstall.
/// </summary>
public static string winget_operation_status_queued_uninstall {
get {
return ResourceManager.GetString("winget_operation_status_queued_uninstall", resourceCulture);
}
}
/// <summary>
/// Looks up a localized string similar to Installed.
/// </summary>
public static string winget_operation_status_succeeded_install {
get {
return ResourceManager.GetString("winget_operation_status_succeeded_install", resourceCulture);
}
}
/// <summary>
/// Looks up a localized string similar to Uninstalled.
/// </summary>
public static string winget_operation_status_succeeded_uninstall {
get {
return ResourceManager.GetString("winget_operation_status_succeeded_uninstall", resourceCulture);
}
}
/// <summary>
/// Looks up a localized string similar to Uninstalling.
/// </summary>
public static string winget_operation_status_uninstalling {
get {
return ResourceManager.GetString("winget_operation_status_uninstalling", resourceCulture);
}
}
/// <summary>
/// Looks up a localized string similar to Downloads and installs.
/// </summary>
public static string winget_operations_flyout_active_header {
get {
return ResourceManager.GetString("winget_operations_flyout_active_header", resourceCulture);
}
}
/// <summary>
/// Looks up a localized string similar to {0} WinGet operations in progress.
/// </summary>
public static string winget_operations_in_progress_plural {
get {
return ResourceManager.GetString("winget_operations_in_progress_plural", resourceCulture);
}
}
/// <summary>
/// Looks up a localized string similar to 1 WinGet operation in progress.
/// </summary>
public static string winget_operations_in_progress_single {
get {
return ResourceManager.GetString("winget_operations_in_progress_single", resourceCulture);
}
}
/// <summary>
/// Looks up a localized string similar to Recent WinGet activity.
/// </summary>
public static string winget_operations_recent_activity {
get {
return ResourceManager.GetString("winget_operations_recent_activity", resourceCulture);
}
}
}
}

View File

@@ -174,6 +174,9 @@
<data name="builtin_open_settings_name" xml:space="preserve">
<value>Open Command Palette settings</value>
</data>
<data name="builtin_open_gallery_name" xml:space="preserve">
<value>Open Command Palette extension gallery</value>
</data>
<data name="builtin_create_extension_success" xml:space="preserve">
<value>Successfully created your new extension!</value>
</data>
@@ -299,4 +302,95 @@
<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="winget_operation_detail_progress" xml:space="preserve">
<value>{0} of {1}</value>
<comment>{0}=downloaded size, {1}=total size</comment>
</data>
<data name="winget_operation_detail_updated" xml:space="preserve">
<value>Updated {0:t}</value>
<comment>{0}=local completion time</comment>
</data>
<data name="winget_operation_size_bytes" xml:space="preserve">
<value>{0} B</value>
<comment>{0}=byte count</comment>
</data>
<data name="winget_operation_size_gigabytes" xml:space="preserve">
<value>{0:F1} GB</value>
<comment>{0}=gigabyte count</comment>
</data>
<data name="winget_operation_size_kilobytes" xml:space="preserve">
<value>{0:F1} KB</value>
<comment>{0}=kilobyte count</comment>
</data>
<data name="winget_operation_size_megabytes" xml:space="preserve">
<value>{0:F1} MB</value>
<comment>{0}=megabyte count</comment>
</data>
<data name="winget_operation_status_canceled" xml:space="preserve">
<value>Canceled</value>
</data>
<data name="winget_operation_status_downloading" xml:space="preserve">
<value>Downloading</value>
</data>
<data name="winget_operation_status_downloading_percent" xml:space="preserve">
<value>Downloading {0}%</value>
<comment>{0}=download percent</comment>
</data>
<data name="winget_operation_status_failed" xml:space="preserve">
<value>Failed</value>
</data>
<data name="winget_operation_status_installing" xml:space="preserve">
<value>Installing</value>
</data>
<data name="winget_operation_status_post_processing" xml:space="preserve">
<value>Finalizing</value>
</data>
<data name="winget_operation_status_queued_install" xml:space="preserve">
<value>Queued to install</value>
</data>
<data name="winget_operation_status_queued_uninstall" xml:space="preserve">
<value>Queued to uninstall</value>
</data>
<data name="winget_operation_status_succeeded_install" xml:space="preserve">
<value>Installed</value>
</data>
<data name="winget_operation_status_succeeded_uninstall" xml:space="preserve">
<value>Uninstalled</value>
</data>
<data name="winget_operation_status_uninstalling" xml:space="preserve">
<value>Uninstalling</value>
</data>
<data name="winget_operations_flyout_active_header" xml:space="preserve">
<value>Downloads and installs</value>
</data>
<data name="winget_operations_in_progress_plural" xml:space="preserve">
<value>{0} WinGet operations in progress</value>
<comment>{0}=active operation count</comment>
</data>
<data name="winget_operations_in_progress_single" xml:space="preserve">
<value>1 WinGet operation in progress</value>
</data>
<data name="winget_operations_recent_activity" xml:space="preserve">
<value>Recent WinGet activity</value>
</data>
</root>

View File

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

View File

@@ -91,6 +91,11 @@ public record SettingsModel
// </Theme settings>
// Extension Gallery settings
public string? GalleryFeedUrl { get; init; }
// </Gallery settings>
// END SETTINGS
///////////////////////////////////////////////////////////////////////////

View File

@@ -0,0 +1,144 @@
// 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.Globalization;
using System.Text;
using CommunityToolkit.Mvvm.ComponentModel;
using CommunityToolkit.Mvvm.Input;
using Microsoft.CmdPal.Common.WinGet.Models;
using Microsoft.CmdPal.Common.WinGet.Services;
namespace Microsoft.CmdPal.UI.ViewModels.WinGet;
public sealed partial class WinGetOperationViewModel : ObservableObject
{
private static readonly CompositeFormat DownloadingPercentFormat = CompositeFormat.Parse(Properties.Resources.winget_operation_status_downloading_percent);
private static readonly CompositeFormat DownloadProgressFormat = CompositeFormat.Parse(Properties.Resources.winget_operation_detail_progress);
private static readonly CompositeFormat UpdatedFormat = CompositeFormat.Parse(Properties.Resources.winget_operation_detail_updated);
private static readonly CompositeFormat GigabytesFormat = CompositeFormat.Parse(Properties.Resources.winget_operation_size_gigabytes);
private static readonly CompositeFormat MegabytesFormat = CompositeFormat.Parse(Properties.Resources.winget_operation_size_megabytes);
private static readonly CompositeFormat KilobytesFormat = CompositeFormat.Parse(Properties.Resources.winget_operation_size_kilobytes);
private static readonly CompositeFormat BytesFormat = CompositeFormat.Parse(Properties.Resources.winget_operation_size_bytes);
private readonly IWinGetOperationTrackerService _trackerService;
private WinGetPackageOperation _operation;
public WinGetOperationViewModel(WinGetPackageOperation operation, IWinGetOperationTrackerService trackerService)
{
_operation = operation;
_trackerService = trackerService;
}
public Guid OperationId => _operation.OperationId;
public string PackageId => _operation.PackageId;
public string PackageName => !string.IsNullOrWhiteSpace(_operation.PackageName) ? _operation.PackageName : _operation.PackageId;
public bool IsCompleted => _operation.IsCompleted;
public bool IsActive => !IsCompleted;
public bool CanCancel => _operation.CanCancel;
public bool ShowProgressBar => IsActive;
public bool IsIndeterminate => _operation.IsIndeterminate || !_operation.ProgressPercent.HasValue;
public double ProgressValue => _operation.ProgressPercent ?? 0;
public string StatusText => _operation.State switch
{
WinGetPackageOperationState.Queued => _operation.Kind == WinGetPackageOperationKind.Uninstall
? Properties.Resources.winget_operation_status_queued_uninstall
: Properties.Resources.winget_operation_status_queued_install,
WinGetPackageOperationState.Downloading => _operation.ProgressPercent is uint percent
? string.Format(CultureInfo.CurrentCulture, DownloadingPercentFormat, percent)
: Properties.Resources.winget_operation_status_downloading,
WinGetPackageOperationState.Installing => Properties.Resources.winget_operation_status_installing,
WinGetPackageOperationState.Uninstalling => Properties.Resources.winget_operation_status_uninstalling,
WinGetPackageOperationState.PostProcessing => Properties.Resources.winget_operation_status_post_processing,
WinGetPackageOperationState.Succeeded => _operation.Kind == WinGetPackageOperationKind.Uninstall
? Properties.Resources.winget_operation_status_succeeded_uninstall
: Properties.Resources.winget_operation_status_succeeded_install,
WinGetPackageOperationState.Canceled => Properties.Resources.winget_operation_status_canceled,
WinGetPackageOperationState.Failed => Properties.Resources.winget_operation_status_failed,
_ => _operation.State.ToString(),
};
public string DetailText
{
get
{
if (_operation.State == WinGetPackageOperationState.Downloading
&& _operation.BytesDownloaded is ulong bytesDownloaded
&& _operation.BytesRequired is ulong bytesRequired
&& bytesRequired > 0)
{
return string.Format(
CultureInfo.CurrentCulture,
DownloadProgressFormat,
FormatBytes(bytesDownloaded),
FormatBytes(bytesRequired));
}
if (_operation.State == WinGetPackageOperationState.Failed && !string.IsNullOrWhiteSpace(_operation.ErrorMessage))
{
return _operation.ErrorMessage;
}
if (_operation.IsCompleted && _operation.CompletedAt is DateTimeOffset completedAt)
{
return string.Format(CultureInfo.CurrentCulture, UpdatedFormat, completedAt.ToLocalTime());
}
return string.Empty;
}
}
public bool HasDetailText => !string.IsNullOrWhiteSpace(DetailText);
public void ApplyOperation(WinGetPackageOperation operation)
{
_operation = operation;
NotifyStateChanged();
CancelCommand.NotifyCanExecuteChanged();
}
[RelayCommand(CanExecute = nameof(CanCancel))]
private void Cancel()
{
_trackerService.TryCancelOperation(OperationId);
}
private void NotifyStateChanged()
{
OnPropertyChanged(nameof(OperationId));
OnPropertyChanged(nameof(PackageId));
OnPropertyChanged(nameof(PackageName));
OnPropertyChanged(nameof(IsCompleted));
OnPropertyChanged(nameof(IsActive));
OnPropertyChanged(nameof(CanCancel));
OnPropertyChanged(nameof(ShowProgressBar));
OnPropertyChanged(nameof(IsIndeterminate));
OnPropertyChanged(nameof(ProgressValue));
OnPropertyChanged(nameof(StatusText));
OnPropertyChanged(nameof(DetailText));
OnPropertyChanged(nameof(HasDetailText));
}
private static string FormatBytes(ulong bytes)
{
const double KB = 1024;
const double MB = KB * 1024;
const double GB = MB * 1024;
return bytes switch
{
>= (ulong)GB => string.Format(CultureInfo.CurrentCulture, GigabytesFormat, bytes / GB),
>= (ulong)MB => string.Format(CultureInfo.CurrentCulture, MegabytesFormat, bytes / MB),
>= (ulong)KB => string.Format(CultureInfo.CurrentCulture, KilobytesFormat, bytes / KB),
_ => string.Format(CultureInfo.CurrentCulture, BytesFormat, bytes),
};
}
}

View File

@@ -0,0 +1,131 @@
// 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 Microsoft.CmdPal.Common.WinGet.Models;
using Microsoft.CmdPal.Common.WinGet.Services;
using Microsoft.CommandPalette.Extensions.Toolkit;
namespace Microsoft.CmdPal.UI.ViewModels.WinGet;
public sealed partial class WinGetOperationsViewModel : ObservableObject, IDisposable
{
private static readonly CompositeFormat ActiveOperationsFormat = CompositeFormat.Parse(Properties.Resources.winget_operations_in_progress_plural);
private readonly IWinGetOperationTrackerService _trackerService;
private readonly TaskScheduler _uiScheduler;
private readonly Dictionary<Guid, WinGetOperationViewModel> _operationViewModels = [];
private bool _disposed;
public WinGetOperationsViewModel(IWinGetOperationTrackerService trackerService, TaskScheduler? uiScheduler = null)
{
_trackerService = trackerService;
_uiScheduler = uiScheduler ?? TaskScheduler.Current;
_trackerService.OperationStarted += OnTrackedOperationChanged;
_trackerService.OperationUpdated += OnTrackedOperationChanged;
_trackerService.OperationCompleted += OnTrackedOperationChanged;
RefreshOperations(_trackerService.Operations);
}
public ObservableCollection<WinGetOperationViewModel> Operations { get; } = [];
public bool HasVisibleOperations => Operations.Count > 0;
public bool HasActiveOperations => Operations.Any(static operation => operation.IsActive);
public string SummaryText
{
get
{
var activeCount = Operations.Count(static operation => operation.IsActive);
if (activeCount == 0)
{
return Properties.Resources.winget_operations_recent_activity;
}
return activeCount == 1
? Properties.Resources.winget_operations_in_progress_single
: string.Format(CultureInfo.CurrentCulture, ActiveOperationsFormat, activeCount);
}
}
public string FlyoutHeaderText => HasActiveOperations
? Properties.Resources.winget_operations_flyout_active_header
: Properties.Resources.winget_operations_recent_activity;
public void Dispose()
{
if (_disposed)
{
return;
}
_disposed = true;
_trackerService.OperationStarted -= OnTrackedOperationChanged;
_trackerService.OperationUpdated -= OnTrackedOperationChanged;
_trackerService.OperationCompleted -= OnTrackedOperationChanged;
}
private void OnTrackedOperationChanged(object? sender, WinGetPackageOperationEventArgs e)
{
if (_disposed)
{
return;
}
_ = Task.Factory.StartNew(
() => RefreshOperations(_trackerService.Operations),
CancellationToken.None,
TaskCreationOptions.DenyChildAttach,
_uiScheduler);
}
private void RefreshOperations(IReadOnlyList<WinGetPackageOperation> operations)
{
HashSet<Guid> activeIds = [];
List<WinGetOperationViewModel> ordered = new(operations.Count);
for (var i = 0; i < operations.Count; i++)
{
var operation = operations[i];
activeIds.Add(operation.OperationId);
if (!_operationViewModels.TryGetValue(operation.OperationId, out var operationViewModel))
{
operationViewModel = new WinGetOperationViewModel(operation, _trackerService);
_operationViewModels[operation.OperationId] = operationViewModel;
}
else
{
operationViewModel.ApplyOperation(operation);
}
ordered.Add(operationViewModel);
}
var staleIds = _operationViewModels.Keys
.Where(id => !activeIds.Contains(id))
.ToArray();
for (var i = 0; i < staleIds.Length; i++)
{
_operationViewModels.Remove(staleIds[i]);
}
ListHelpers.InPlaceUpdateList(Operations, ordered);
NotifyStateChanged();
}
private void NotifyStateChanged()
{
OnPropertyChanged(nameof(HasVisibleOperations));
OnPropertyChanged(nameof(HasActiveOperations));
OnPropertyChanged(nameof(SummaryText));
OnPropertyChanged(nameof(FlyoutHeaderText));
}
}

View File

@@ -17,6 +17,7 @@
<ResourceDictionary Source="ms-appx:///Styles/TextBlock.xaml" />
<ResourceDictionary Source="ms-appx:///Styles/TextBox.xaml" />
<ResourceDictionary Source="ms-appx:///Styles/Settings.xaml" />
<ResourceDictionary Source="ms-appx:///Styles/Button.xaml" />
<ResourceDictionary Source="ms-appx:///Controls/Tag.xaml" />
<ResourceDictionary Source="ms-appx:///PowerToys.Common.UI.Controls/Controls/KeyVisual/KeyVisual.xaml" />
<ResourceDictionary Source="ms-appx:///PowerToys.Common.UI.Controls/Controls/KeyVisual/KeyCharPresenter.xaml" />

View File

@@ -8,6 +8,7 @@ using Microsoft.CmdPal.Common.Helpers;
using Microsoft.CmdPal.Common.Logging;
using Microsoft.CmdPal.Common.Services;
using Microsoft.CmdPal.Common.Text;
using Microsoft.CmdPal.Common.WinGet.Services;
using Microsoft.CmdPal.Ext.Apps;
using Microsoft.CmdPal.Ext.Bookmarks;
using Microsoft.CmdPal.Ext.Calc;
@@ -121,14 +122,19 @@ public partial class App : Application, IDisposable
{
// TODO: It's in the Labs feed, but we can use Sergio's AOT-friendly source generator for this: https://github.com/CommunityToolkit/Labs-Windows/discussions/463
ServiceCollection services = new();
var uiScheduler = TaskScheduler.FromCurrentSynchronizationContext();
// Root services
services.AddSingleton(TaskScheduler.FromCurrentSynchronizationContext());
services.AddSingleton(uiScheduler);
var dispatcherQueue = DispatcherQueue.GetForCurrentThread();
services.AddCmdPalLogging();
AddBuiltInCommands(services, appInfoService.ConfigDirectory);
var winGet = services.AddWinGetServices();
services.AddGalleryServices();
AddBuiltInCommands(services, appInfoService.ConfigDirectory, winGet?.PackageManager, winGet?.OperationTracker, uiScheduler);
AddCoreServices(services, appInfoService);
@@ -137,7 +143,12 @@ public partial class App : Application, IDisposable
return services.BuildServiceProvider();
}
private static void AddBuiltInCommands(ServiceCollection services, string configDirectory)
private static void AddBuiltInCommands(
ServiceCollection services,
string configDirectory,
IWinGetPackageManagerService? winGetPackageManagerService,
IWinGetOperationTrackerService? winGetOperationTrackerService,
TaskScheduler uiScheduler)
{
var providerLoadGuard = new ProviderLoadGuard(configDirectory);
@@ -158,20 +169,20 @@ public partial class App : Application, IDisposable
// GH #38440: Users might not have WinGet installed! Or they might have
// a ridiculously old version. Or might be running as admin.
// We shouldn't explode in the App ctor if we fail to instantiate an
// instance of PackageManager, which will happen in the static ctor
// for WinGetStatics
try
if (winGetPackageManagerService is not null && winGetOperationTrackerService is not null)
{
var winget = new WinGetExtensionCommandsProvider();
winget.SetAllLookup(
query => allApps.LookupAppByPackageFamilyName(query, requireSingleMatch: true),
query => allApps.LookupAppByProductCode(query, requireSingleMatch: true));
services.AddSingleton<ICommandProvider>(winget);
}
catch (Exception ex)
{
Logger.LogError("Couldn't load winget", ex);
try
{
var winget = new WinGetExtensionCommandsProvider(winGetPackageManagerService, winGetOperationTrackerService, uiScheduler);
winget.SetAllLookup(
query => allApps.LookupAppByPackageFamilyName(query, requireSingleMatch: true),
query => allApps.LookupAppByProductCode(query, requireSingleMatch: true));
services.AddSingleton<ICommandProvider>(winget);
}
catch (Exception ex)
{
Logger.LogError("Couldn't load winget", ex);
}
}
services.AddSingleton<ICommandProvider, WindowsTerminalCommandsProvider>();
@@ -237,7 +248,9 @@ public partial class App : Application, IDisposable
services.AddIconServices(dispatcherQueue);
}
private static void AddCoreServices(ServiceCollection services, IApplicationInfoService appInfoService)
private static void AddCoreServices(
ServiceCollection services,
IApplicationInfoService appInfoService)
{
// Core services
services.AddSingleton(appInfoService);
@@ -261,6 +274,7 @@ public partial class App : Application, IDisposable
public void Dispose()
{
(Services as IDisposable)?.Dispose();
_globalErrorHandler.Dispose();
EtwTrace.Dispose();
GC.SuppressFinalize(this);

View File

@@ -0,0 +1,10 @@
<?xml version="1.0" encoding="utf-8" ?>
<UserControl
x:Class="Microsoft.CmdPal.UI.Controls.IconCarouselControl"
xmlns="http://schemas.microsoft.com/winfx/2006/xaml/presentation"
xmlns:x="http://schemas.microsoft.com/winfx/2006/xaml"
xmlns:d="http://schemas.microsoft.com/expression/blend/2008"
xmlns:mc="http://schemas.openxmlformats.org/markup-compatibility/2006"
mc:Ignorable="d">
<Grid x:Name="RootGrid" />
</UserControl>

View File

@@ -0,0 +1,618 @@
// 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.Numerics;
using Microsoft.UI.Composition;
using Microsoft.UI.Xaml;
using Microsoft.UI.Xaml.Controls;
using Microsoft.UI.Xaml.Hosting;
using Microsoft.UI.Xaml.Media;
using Windows.UI;
namespace Microsoft.CmdPal.UI.Controls;
public sealed partial class IconCarouselControl : UserControl
{
private const int SlotCount = 7;
private const int TransitionSlotBeforeFirst = -1;
private const int TransitionSlotAfterLast = SlotCount;
private const int HiddenSlot = int.MinValue;
private const float CardSize = 108f;
private const float CardCornerRadius = 20f;
private const float IconPadding = 14f;
private const float Stride = CardSize * 0.50f;
private const float IconDecodeScaleFactor = 1.5f;
private const float TransitionScaleFactor = 0.92f;
private const float TransitionOpacityFactor = 0.20f;
private const float ArrivalFadeHoldProgress = 0.40f;
private const float ArrivalFadeMidpointProgress = 0.78f;
private const float ArrivalFadeMidpointFactor = 0.72f;
private static readonly TimeSpan RotateInterval = TimeSpan.FromSeconds(3);
private static readonly TimeSpan AnimationDuration = TimeSpan.FromMilliseconds(400);
private static readonly SlotLayout[] Slots =
[
new(-3, 0.55f, 0.7f),
new(-2, 0.65f, 0.8f),
new(-1, 0.80f, 0.9f),
new(0, 1.00f, 1.0f),
new(1, 0.80f, 0.9f),
new(2, 0.65f, 0.8f),
new(3, 0.55f, 0.7f),
];
public static readonly DependencyProperty IconUrisProperty =
DependencyProperty.Register(
nameof(IconUris),
typeof(IReadOnlyList<Uri>),
typeof(IconCarouselControl),
new PropertyMetadata(null, OnIconUrisChanged));
private readonly List<ContainerVisual> _cards = [];
private readonly List<LoadedImageSurface> _surfaces = [];
private readonly List<CompositionColorBrush> _cardFillBrushes = [];
private readonly List<CompositionColorBrush> _cardStrokeBrushes = [];
private Compositor? _compositor;
private ContainerVisual? _container;
private DispatcherTimer? _timer;
private int _rotationIndex;
public IconCarouselControl()
{
this.InitializeComponent();
this.Loaded += OnLoaded;
this.Unloaded += OnUnloaded;
this.SizeChanged += OnSizeChanged;
this.ActualThemeChanged += OnActualThemeChanged;
}
public IReadOnlyList<Uri>? IconUris
{
get => (IReadOnlyList<Uri>?)GetValue(IconUrisProperty);
set => SetValue(IconUrisProperty, value);
}
private static void OnIconUrisChanged(DependencyObject d, DependencyPropertyChangedEventArgs e)
{
if (d is IconCarouselControl control)
{
control.RebuildVisuals();
}
}
private void OnLoaded(object sender, RoutedEventArgs e)
{
var hostVisual = ElementCompositionPreview.GetElementVisual(RootGrid);
_compositor = hostVisual.Compositor;
_container = _compositor.CreateContainerVisual();
_container.Size = new Vector2((float)ActualWidth, (float)ActualHeight);
ElementCompositionPreview.SetElementChildVisual(RootGrid, _container);
RebuildVisuals();
}
private void OnUnloaded(object sender, RoutedEventArgs e)
{
StopTimer();
CleanupVisuals();
}
private void OnSizeChanged(object sender, SizeChangedEventArgs e)
{
if (_container != null)
{
_container.Size = new Vector2((float)e.NewSize.Width, (float)e.NewSize.Height);
}
SnapAllToCurrentSlots();
}
private void RebuildVisuals()
{
if (_compositor == null || _container == null)
{
return;
}
StopTimer();
CleanupVisuals();
var uris = IconUris;
if (uris == null || uris.Count == 0)
{
return;
}
_rotationIndex = 0;
for (var i = 0; i < uris.Count; i++)
{
var card = CreateIconCard(uris[i]);
_cards.Add(card);
}
_container.Size = new Vector2((float)ActualWidth, (float)ActualHeight);
SnapAllToCurrentSlots();
ApplyZOrder();
StartTimer();
}
private ContainerVisual CreateIconCard(Uri iconUri)
{
// Oversized container so shadow blur isn't clipped
var shadowPad = 24f;
var containerSize = CardSize + (shadowPad * 2);
var card = _compositor!.CreateContainerVisual();
card.Size = new Vector2(containerSize, containerSize);
card.AnchorPoint = new Vector2(0.5f, 0.5f);
// Create a rounded-rect shape to use as shadow mask
var maskGeometry = _compositor.CreateRoundedRectangleGeometry();
maskGeometry.Size = new Vector2(CardSize, CardSize);
maskGeometry.CornerRadius = new Vector2(CardCornerRadius, CardCornerRadius);
var maskShape = _compositor.CreateSpriteShape(maskGeometry);
maskShape.FillBrush = _compositor.CreateColorBrush(Color.FromArgb(255, 255, 255, 255));
var maskShapeVisual = _compositor.CreateShapeVisual();
maskShapeVisual.Size = new Vector2(CardSize, CardSize);
maskShapeVisual.Shapes.Add(maskShape);
var maskSurface = _compositor.CreateVisualSurface();
maskSurface.SourceVisual = maskShapeVisual;
maskSurface.SourceSize = new Vector2(CardSize, CardSize);
var maskBrush = _compositor.CreateSurfaceBrush(maskSurface);
// Shadow visual: card-sized, centered in the oversized container
var shadow = _compositor.CreateDropShadow();
shadow.BlurRadius = 8f;
shadow.Offset = new Vector3(0, 3f, 0);
shadow.Color = Color.FromArgb(30, 0, 0, 0);
shadow.Mask = maskBrush;
var shadowVisual = _compositor.CreateSpriteVisual();
shadowVisual.Size = new Vector2(CardSize, CardSize);
shadowVisual.Offset = new Vector3(shadowPad, shadowPad, 0);
shadowVisual.Shadow = shadow;
card.Children.InsertAtBottom(shadowVisual);
// Rounded card background with soft stroke
var bgGeometry = _compositor.CreateRoundedRectangleGeometry();
bgGeometry.Size = new Vector2(CardSize, CardSize);
bgGeometry.Offset = new Vector2(shadowPad, shadowPad);
bgGeometry.CornerRadius = new Vector2(CardCornerRadius, CardCornerRadius);
var fillBrush = _compositor.CreateColorBrush(GetCardBackgroundColor());
var strokeBrush = _compositor.CreateColorBrush(GetCardStrokeColor());
_cardFillBrushes.Add(fillBrush);
_cardStrokeBrushes.Add(strokeBrush);
var bgShape = _compositor.CreateSpriteShape(bgGeometry);
bgShape.FillBrush = fillBrush;
bgShape.StrokeBrush = strokeBrush;
bgShape.StrokeThickness = 1f;
var bgVisual = _compositor.CreateShapeVisual();
bgVisual.Size = new Vector2(containerSize, containerSize);
bgVisual.Shapes.Add(bgShape);
card.Children.InsertAbove(bgVisual, shadowVisual);
var iconSize = CardSize - (IconPadding * 2);
// Request a slightly larger surface so the smaller carousel slots can
// downsample from a cleaner source instead of revealing raster edges.
var surface = LoadedImageSurface.StartLoadFromUri(iconUri, GetDesiredIconSurfaceSize(iconSize));
_surfaces.Add(surface);
var surfaceBrush = _compositor.CreateSurfaceBrush(surface);
surfaceBrush.Stretch = CompositionStretch.Uniform;
surfaceBrush.BitmapInterpolationMode = CompositionBitmapInterpolationMode.MagLinearMinLinearMipLinear;
surfaceBrush.HorizontalAlignmentRatio = 0.5f;
surfaceBrush.VerticalAlignmentRatio = 0.5f;
var iconVisual = _compositor.CreateSpriteVisual();
iconVisual.Size = new Vector2(iconSize, iconSize);
iconVisual.Offset = new Vector3(shadowPad + IconPadding, shadowPad + IconPadding, 0);
iconVisual.Brush = surfaceBrush;
var iconClipGeometry = _compositor.CreateRoundedRectangleGeometry();
iconClipGeometry.Size = new Vector2(iconSize, iconSize);
iconClipGeometry.CornerRadius = new Vector2(CardCornerRadius - 4, CardCornerRadius - 4);
iconVisual.Clip = _compositor.CreateGeometricClip(iconClipGeometry);
card.Children.InsertAtTop(iconVisual);
return card;
}
private Vector3 GetSlotOffset(SlotLayout slot)
{
var centerX = (float)ActualWidth / 2f;
var centerY = (float)ActualHeight / 2f;
return new Vector3(centerX + (slot.PositionFromCenter * Stride), centerY, 0);
}
private void SnapAllToCurrentSlots()
{
if (_cards.Count == 0 || ActualWidth < 1)
{
return;
}
for (var i = 0; i < _cards.Count; i++)
{
var slotIndex = GetSlotForCard(i);
var card = _cards[i];
if (slotIndex < 0 || slotIndex >= SlotCount)
{
card.IsVisible = false;
continue;
}
card.IsVisible = true;
var slot = Slots[slotIndex];
card.Offset = GetSlotOffset(slot);
card.Scale = new Vector3(slot.Scale, slot.Scale, 1f);
card.Opacity = slot.Opacity;
}
}
private void RotateForward()
{
if (_cards.Count == 0 || _compositor == null || ActualWidth < 1)
{
return;
}
var previousRotationIndex = _rotationIndex;
var nextRotationIndex = (previousRotationIndex + 1) % _cards.Count;
var displaySlots = new int[_cards.Count];
// Prep start states before reordering children so edge cards can glide in/out
// instead of popping as they cross the visible bounds.
for (var i = 0; i < _cards.Count; i++)
{
var currentSlot = GetSlotForCard(i, previousRotationIndex);
var nextSlot = GetSlotForCard(i, nextRotationIndex);
var card = _cards[i];
displaySlots[i] = GetDisplaySlot(currentSlot, nextSlot);
if (!IsVisibleSlot(currentSlot) && IsVisibleSlot(nextSlot))
{
PrepareArrival(card, nextSlot, currentSlot < 0);
}
else if (!IsVisibleSlot(currentSlot) && !IsVisibleSlot(nextSlot))
{
card.IsVisible = false;
}
else
{
card.IsVisible = true;
}
}
_rotationIndex = nextRotationIndex;
// Set z-order BEFORE animating. The departing card goes to the back,
// the arriving card also starts at the back. Since they're behind
// everything, the z-order change is invisible.
ApplyZOrder(displaySlots);
var easing = _compositor.CreateCubicBezierEasingFunction(
new Vector2(0.37f, 0f), new Vector2(0.63f, 1f));
for (var i = 0; i < _cards.Count; i++)
{
var currentSlot = GetSlotForCard(i, previousRotationIndex);
var nextSlot = GetSlotForCard(i, nextRotationIndex);
var card = _cards[i];
if (IsVisibleSlot(currentSlot) && !IsVisibleSlot(nextSlot))
{
AnimateDeparture(card, nextSlot < 0, easing);
continue;
}
if (!IsVisibleSlot(currentSlot) && IsVisibleSlot(nextSlot))
{
AnimateArrival(card, nextSlot, easing);
continue;
}
if (!IsVisibleSlot(nextSlot))
{
card.IsVisible = false;
continue;
}
var slot = Slots[nextSlot];
AnimateCardTo(card, GetSlotOffset(slot), slot.Scale, slot.Opacity, easing);
}
}
private void AnimateDeparture(ContainerVisual card, bool exitingLeft, CompositionEasingFunction easing)
{
var edgeSlot = exitingLeft ? Slots[0] : Slots[^1];
var targetScale = edgeSlot.Scale * TransitionScaleFactor;
AnimateCardTo(card, GetTransitionOffset(exitingLeft), targetScale, 0f, easing);
}
private void AnimateArrival(ContainerVisual card, int slotIndex, CompositionEasingFunction easing)
{
if (!IsVisibleSlot(slotIndex))
{
card.IsVisible = false;
return;
}
var slot = Slots[slotIndex];
StartOffsetAnimation(card, GetSlotOffset(slot), easing);
StartScaleAnimation(card, slot.Scale, easing);
StartArrivalOpacityAnimation(card, slot.Opacity, easing);
}
private void AnimateCardTo(ContainerVisual card, Vector3 offset, float scale, float opacity, CompositionEasingFunction easing)
{
StartOffsetAnimation(card, offset, easing);
StartScaleAnimation(card, scale, easing);
StartOpacityAnimation(card, opacity, easing);
}
private void StartOffsetAnimation(ContainerVisual card, Vector3 offset, CompositionEasingFunction easing)
{
var offsetAnim = _compositor!.CreateVector3KeyFrameAnimation();
offsetAnim.InsertKeyFrame(1f, offset, easing);
offsetAnim.Duration = AnimationDuration;
card.StartAnimation(nameof(card.Offset), offsetAnim);
}
private void StartScaleAnimation(ContainerVisual card, float scale, CompositionEasingFunction easing)
{
var scaleAnim = _compositor!.CreateVector3KeyFrameAnimation();
scaleAnim.InsertKeyFrame(1f, new Vector3(scale, scale, 1f), easing);
scaleAnim.Duration = AnimationDuration;
card.StartAnimation(nameof(card.Scale), scaleAnim);
}
private void StartOpacityAnimation(ContainerVisual card, float opacity, CompositionEasingFunction easing)
{
var opacityAnim = _compositor!.CreateScalarKeyFrameAnimation();
opacityAnim.InsertKeyFrame(1f, opacity, easing);
opacityAnim.Duration = AnimationDuration;
card.StartAnimation(nameof(card.Opacity), opacityAnim);
}
private void StartArrivalOpacityAnimation(ContainerVisual card, float targetOpacity, CompositionEasingFunction easing)
{
var startOpacity = targetOpacity * TransitionOpacityFactor;
var midpointOpacity = MathF.Max(startOpacity, targetOpacity * ArrivalFadeMidpointFactor);
var opacityAnim = _compositor!.CreateScalarKeyFrameAnimation();
opacityAnim.InsertKeyFrame(ArrivalFadeHoldProgress, startOpacity);
opacityAnim.InsertKeyFrame(ArrivalFadeMidpointProgress, midpointOpacity, easing);
opacityAnim.InsertKeyFrame(1f, targetOpacity, easing);
opacityAnim.Duration = AnimationDuration;
card.StartAnimation(nameof(card.Opacity), opacityAnim);
}
private void ApplyZOrder()
{
var displaySlots = new int[_cards.Count];
for (var i = 0; i < _cards.Count; i++)
{
var slotIndex = GetSlotForCard(i);
displaySlots[i] = IsVisibleSlot(slotIndex) ? slotIndex : HiddenSlot;
}
ApplyZOrder(displaySlots);
}
private void ApplyZOrder(IReadOnlyList<int> displaySlots)
{
if (_container == null)
{
return;
}
var centerSlot = SlotCount / 2;
_container.Children.RemoveAll();
var ordered = new List<(int DistFromCenter, int SlotIndex, ContainerVisual Card)>();
for (var i = 0; i < _cards.Count; i++)
{
var slotIndex = displaySlots[i];
if (slotIndex != HiddenSlot)
{
ordered.Add((Math.Abs(slotIndex - centerSlot), slotIndex, _cards[i]));
}
}
// Farthest from center first (bottom), closest last (top)
ordered.Sort((a, b) =>
{
var distanceOrder = b.DistFromCenter.CompareTo(a.DistFromCenter);
return distanceOrder != 0 ? distanceOrder : a.SlotIndex.CompareTo(b.SlotIndex);
});
foreach (var item in ordered)
{
_container.Children.InsertAtTop(item.Card);
}
}
private int GetSlotForCard(int cardIndex)
{
return GetSlotForCard(cardIndex, _rotationIndex);
}
private int GetSlotForCard(int cardIndex, int rotationIndex)
{
var totalCards = _cards.Count;
var centerSlot = SlotCount / 2;
var diff = cardIndex - rotationIndex;
if (diff > totalCards / 2)
{
diff -= totalCards;
}
else if (diff < -(totalCards / 2))
{
diff += totalCards;
}
return centerSlot + diff;
}
private static bool IsVisibleSlot(int slotIndex)
{
return slotIndex >= 0 && slotIndex < SlotCount;
}
private static int GetDisplaySlot(int currentSlot, int nextSlot)
{
if (IsVisibleSlot(nextSlot))
{
return IsVisibleSlot(currentSlot) ? nextSlot : (currentSlot < 0 ? TransitionSlotBeforeFirst : TransitionSlotAfterLast);
}
if (IsVisibleSlot(currentSlot))
{
return nextSlot < 0 ? TransitionSlotBeforeFirst : TransitionSlotAfterLast;
}
return HiddenSlot;
}
private void PrepareArrival(ContainerVisual card, int slotIndex, bool enteringFromLeft)
{
var slot = Slots[slotIndex];
var startScale = slot.Scale * TransitionScaleFactor;
card.StopAnimation(nameof(card.Offset));
card.StopAnimation(nameof(card.Scale));
card.StopAnimation(nameof(card.Opacity));
card.Offset = GetTransitionOffset(enteringFromLeft);
card.Scale = new Vector3(startScale, startScale, 1f);
card.Opacity = slot.Opacity * TransitionOpacityFactor;
card.IsVisible = true;
}
private Vector3 GetTransitionOffset(bool toLeft)
{
var edgeSlot = toLeft ? Slots[0] : Slots[^1];
var direction = toLeft ? -1f : 1f;
return GetSlotOffset(edgeSlot) + new Vector3(direction * Stride, 0f, 0f);
}
private static global::Windows.Foundation.Size GetDesiredIconSurfaceSize(float iconSize)
{
var desiredSize = Math.Ceiling(iconSize * IconDecodeScaleFactor);
return new global::Windows.Foundation.Size(desiredSize, desiredSize);
}
private void StartTimer()
{
if (_timer != null || _cards.Count <= SlotCount)
{
return;
}
_timer = new DispatcherTimer
{
Interval = RotateInterval,
};
_timer.Tick += OnRotateTick;
_timer.Start();
}
private void StopTimer()
{
if (_timer != null)
{
_timer.Stop();
_timer.Tick -= OnRotateTick;
_timer = null;
}
}
private void OnRotateTick(object? sender, object e)
{
RotateForward();
}
private Color GetCardBackgroundColor()
{
return ResolveThemeColor("SolidBackgroundFillColorTertiaryBrush", Color.FromArgb(200, 40, 40, 40));
}
private Color GetCardStrokeColor()
{
return ResolveThemeColor("CardStrokeColorDefaultBrush", Color.FromArgb(20, 0, 0, 0));
}
private Color ResolveThemeColor(string resourceKey, Color fallback)
{
if (Resources.TryGetValue(resourceKey, out var res) && res is SolidColorBrush brush)
{
return brush.Color;
}
if (Application.Current.Resources.TryGetValue(resourceKey, out res) && res is SolidColorBrush appBrush)
{
return appBrush.Color;
}
return fallback;
}
private void OnActualThemeChanged(FrameworkElement sender, object args)
{
var fillColor = GetCardBackgroundColor();
var strokeColor = GetCardStrokeColor();
foreach (var brush in _cardFillBrushes)
{
brush.Color = fillColor;
}
foreach (var brush in _cardStrokeBrushes)
{
brush.Color = strokeColor;
}
}
private void CleanupVisuals()
{
foreach (var card in _cards)
{
card.Dispose();
}
_cards.Clear();
_cardFillBrushes.Clear();
_cardStrokeBrushes.Clear();
foreach (var surface in _surfaces)
{
surface.Dispose();
}
_surfaces.Clear();
if (_container != null)
{
_container.Children.RemoveAll();
}
}
private readonly record struct SlotLayout(int PositionFromCenter, float Scale, float Opacity);
}

View File

@@ -7,106 +7,6 @@
xmlns:local="using:Microsoft.CmdPal.UI.Controls"
xmlns:mc="http://schemas.openxmlformats.org/markup-compatibility/2006"
mc:Ignorable="d">
<UserControl.Resources>
<ResourceDictionary>
<Style x:Key="ScrollButtonStyle" TargetType="Button">
<Setter Property="Background" Value="{ThemeResource FlipViewNextPreviousButtonBackground}" />
<Setter Property="BackgroundSizing" Value="InnerBorderEdge" />
<Setter Property="Foreground" Value="{ThemeResource ButtonForeground}" />
<Setter Property="BorderBrush" Value="{ThemeResource FlipViewNextPreviousButtonBorderBrush}" />
<Setter Property="BorderThickness" Value="1" />
<Setter Property="Padding" Value="8,0,8,0" />
<Setter Property="Margin" Value="0" />
<Setter Property="HorizontalAlignment" Value="Left" />
<Setter Property="VerticalAlignment" Value="Center" />
<Setter Property="FontFamily" Value="{ThemeResource ContentControlThemeFontFamily}" />
<Setter Property="FontWeight" Value="Normal" />
<Setter Property="FontSize" Value="{ThemeResource ControlContentThemeFontSize}" />
<Setter Property="UseSystemFocusVisuals" Value="{StaticResource UseSystemFocusVisuals}" />
<Setter Property="FocusVisualMargin" Value="-3" />
<Setter Property="CornerRadius" Value="{ThemeResource ControlCornerRadius}" />
<Setter Property="Template">
<Setter.Value>
<ControlTemplate TargetType="Button">
<ContentPresenter
x:Name="ContentPresenter"
Padding="{TemplateBinding Padding}"
HorizontalContentAlignment="{TemplateBinding HorizontalContentAlignment}"
VerticalContentAlignment="{TemplateBinding VerticalContentAlignment}"
AnimatedIcon.State="Normal"
AutomationProperties.AccessibilityView="Raw"
Background="{TemplateBinding Background}"
BackgroundSizing="{TemplateBinding BackgroundSizing}"
BorderBrush="{TemplateBinding BorderBrush}"
BorderThickness="{TemplateBinding BorderThickness}"
Content="{TemplateBinding Content}"
ContentTemplate="{TemplateBinding ContentTemplate}"
ContentTransitions="{TemplateBinding ContentTransitions}"
CornerRadius="{TemplateBinding CornerRadius}">
<ContentPresenter.BackgroundTransition>
<BrushTransition Duration="0:0:0.083" />
</ContentPresenter.BackgroundTransition>
<VisualStateManager.VisualStateGroups>
<VisualStateGroup x:Name="CommonStates">
<VisualState x:Name="Normal" />
<VisualState x:Name="PointerOver">
<Storyboard>
<ObjectAnimationUsingKeyFrames Storyboard.TargetName="ContentPresenter" Storyboard.TargetProperty="Background">
<DiscreteObjectKeyFrame KeyTime="0" Value="{ThemeResource FlipViewNextPreviousButtonBackgroundPointerOver}" />
</ObjectAnimationUsingKeyFrames>
<ObjectAnimationUsingKeyFrames Storyboard.TargetName="ContentPresenter" Storyboard.TargetProperty="BorderBrush">
<DiscreteObjectKeyFrame KeyTime="0" Value="{ThemeResource FlipViewNextPreviousButtonBorderBrushPointerOver}" />
</ObjectAnimationUsingKeyFrames>
<ObjectAnimationUsingKeyFrames Storyboard.TargetName="ContentPresenter" Storyboard.TargetProperty="Foreground">
<DiscreteObjectKeyFrame KeyTime="0" Value="{ThemeResource FlipViewNextPreviousArrowForegroundPointerOver}" />
</ObjectAnimationUsingKeyFrames>
</Storyboard>
<VisualState.Setters>
<Setter Target="ContentPresenter.(AnimatedIcon.State)" Value="PointerOver" />
</VisualState.Setters>
</VisualState>
<VisualState x:Name="Pressed">
<Storyboard>
<ObjectAnimationUsingKeyFrames Storyboard.TargetName="ContentPresenter" Storyboard.TargetProperty="Background">
<DiscreteObjectKeyFrame KeyTime="0" Value="{ThemeResource FlipViewNextPreviousButtonBackgroundPressed}" />
</ObjectAnimationUsingKeyFrames>
<ObjectAnimationUsingKeyFrames Storyboard.TargetName="ContentPresenter" Storyboard.TargetProperty="BorderBrush">
<DiscreteObjectKeyFrame KeyTime="0" Value="{ThemeResource FlipViewNextPreviousButtonBorderBrushPressed}" />
</ObjectAnimationUsingKeyFrames>
<ObjectAnimationUsingKeyFrames Storyboard.TargetName="ContentPresenter" Storyboard.TargetProperty="Foreground">
<DiscreteObjectKeyFrame KeyTime="0" Value="{ThemeResource FlipViewNextPreviousArrowForegroundPressed}" />
</ObjectAnimationUsingKeyFrames>
</Storyboard>
<VisualState.Setters>
<Setter Target="ContentPresenter.(AnimatedIcon.State)" Value="Pressed" />
</VisualState.Setters>
</VisualState>
<VisualState x:Name="Disabled">
<Storyboard>
<ObjectAnimationUsingKeyFrames Storyboard.TargetName="ContentPresenter" Storyboard.TargetProperty="Background">
<DiscreteObjectKeyFrame KeyTime="0" Value="{ThemeResource ButtonBackgroundDisabled}" />
</ObjectAnimationUsingKeyFrames>
<ObjectAnimationUsingKeyFrames Storyboard.TargetName="ContentPresenter" Storyboard.TargetProperty="BorderBrush">
<DiscreteObjectKeyFrame KeyTime="0" Value="{ThemeResource ButtonBorderBrushDisabled}" />
</ObjectAnimationUsingKeyFrames>
<ObjectAnimationUsingKeyFrames Storyboard.TargetName="ContentPresenter" Storyboard.TargetProperty="Foreground">
<DiscreteObjectKeyFrame KeyTime="0" Value="{ThemeResource ButtonForegroundDisabled}" />
</ObjectAnimationUsingKeyFrames>
</Storyboard>
<VisualState.Setters>
<!-- DisabledVisual Should be handled by the control, not the animated icon. -->
<Setter Target="ContentPresenter.(AnimatedIcon.State)" Value="Normal" />
</VisualState.Setters>
</VisualState>
</VisualStateGroup>
</VisualStateManager.VisualStateGroups>
</ContentPresenter>
</ControlTemplate>
</Setter.Value>
</Setter>
</Style>
</ResourceDictionary>
</UserControl.Resources>
<Grid
x:Name="RootGrid"
Background="{x:Bind Background, Mode=OneWay}"
@@ -160,7 +60,7 @@
VerticalAlignment="Center"
AutomationProperties.Name="Scroll left"
Click="ScrollBackBtn_Click"
Style="{StaticResource ScrollButtonStyle}"
Style="{StaticResource ScrollContainerScrollButtonStyle}"
ToolTipService.ToolTip="Scroll left"
Visibility="Collapsed">
<FontIcon
@@ -176,7 +76,7 @@
VerticalAlignment="Center"
AutomationProperties.Name="Scroll right"
Click="ScrollForwardBtn_Click"
Style="{StaticResource ScrollButtonStyle}"
Style="{StaticResource ScrollContainerScrollButtonStyle}"
ToolTipService.ToolTip="Scroll right">
<FontIcon
x:Name="ScrollForwardIcon"

View File

@@ -0,0 +1,115 @@
<?xml version="1.0" encoding="utf-8" ?>
<UserControl
x:Class="Microsoft.CmdPal.UI.Controls.WinGetOperationsButton"
xmlns="http://schemas.microsoft.com/winfx/2006/xaml/presentation"
xmlns:x="http://schemas.microsoft.com/winfx/2006/xaml"
xmlns:d="http://schemas.microsoft.com/expression/blend/2008"
xmlns:help="using:Microsoft.CmdPal.UI.Helpers"
xmlns:mc="http://schemas.openxmlformats.org/markup-compatibility/2006"
xmlns:winGet="using:Microsoft.CmdPal.UI.ViewModels.WinGet"
mc:Ignorable="d">
<Button
x:Uid="WinGetOperationsButton_Root"
Padding="14,10"
HorizontalAlignment="Right"
VerticalAlignment="Bottom"
Style="{StaticResource AccentButtonStyle}"
Visibility="{x:Bind help:BindTransformers.BoolToVisibility(HasVisibleOperations), Mode=OneWay, FallbackValue=Collapsed}">
<StackPanel Orientation="Horizontal" Spacing="10">
<FontIcon FontSize="14" Glyph="&#xE8B7;" />
<ProgressRing
Width="16"
Height="16"
IsActive="{x:Bind HasActiveOperations, Mode=OneWay}"
Visibility="{x:Bind help:BindTransformers.BoolToVisibility(HasActiveOperations), Mode=OneWay, FallbackValue=Collapsed}" />
<TextBlock Text="{x:Bind SummaryText, Mode=OneWay}" />
</StackPanel>
<Button.Flyout>
<Flyout Placement="TopEdgeAlignedRight">
<Border
Width="400"
MaxHeight="420"
Padding="16">
<StackPanel Spacing="12">
<TextBlock Style="{StaticResource BodyStrongTextBlockStyle}" Text="{x:Bind FlyoutHeaderText, Mode=OneWay}" />
<ScrollViewer MaxHeight="320">
<ItemsControl ItemsSource="{x:Bind ViewModel.Operations, Mode=OneWay}">
<ItemsControl.ItemTemplate>
<DataTemplate x:DataType="winGet:WinGetOperationViewModel">
<Border
Margin="0,0,0,8"
Padding="12"
Background="{ThemeResource CardBackgroundFillColorDefaultBrush}"
BorderBrush="{ThemeResource CardStrokeColorDefaultBrush}"
BorderThickness="1"
CornerRadius="8">
<Grid RowSpacing="8">
<Grid.RowDefinitions>
<RowDefinition Height="Auto" />
<RowDefinition Height="Auto" />
<RowDefinition Height="Auto" />
<RowDefinition Height="Auto" />
</Grid.RowDefinitions>
<Grid.ColumnDefinitions>
<ColumnDefinition Width="*" />
<ColumnDefinition Width="Auto" />
</Grid.ColumnDefinitions>
<TextBlock
Style="{StaticResource BodyStrongTextBlockStyle}"
Text="{x:Bind PackageName, Mode=OneWay}"
TextTrimming="CharacterEllipsis" />
<Button
x:Uid="WinGetOperationsButton_CancelOperation"
Grid.Row="0"
Grid.Column="1"
Width="32"
Height="32"
Padding="0"
Command="{x:Bind CancelCommand}"
Style="{StaticResource SubtleButtonStyle}"
Visibility="{x:Bind help:BindTransformers.BoolToVisibility(CanCancel), Mode=OneWay, FallbackValue=Collapsed}">
<FontIcon FontSize="12" Glyph="&#xE711;" />
</Button>
<TextBlock
Grid.Row="1"
Grid.Column="0"
Grid.ColumnSpan="2"
Foreground="{ThemeResource TextFillColorSecondaryBrush}"
Style="{StaticResource CaptionTextBlockStyle}"
Text="{x:Bind StatusText, Mode=OneWay}" />
<TextBlock
Grid.Row="2"
Grid.Column="0"
Grid.ColumnSpan="2"
Foreground="{ThemeResource TextFillColorSecondaryBrush}"
Style="{StaticResource CaptionTextBlockStyle}"
Text="{x:Bind DetailText, Mode=OneWay}"
TextWrapping="WrapWholeWords"
Visibility="{x:Bind help:BindTransformers.BoolToVisibility(HasDetailText), Mode=OneWay, FallbackValue=Collapsed}" />
<ProgressBar
Grid.Row="3"
Grid.Column="0"
Grid.ColumnSpan="2"
Height="4"
IsIndeterminate="{x:Bind IsIndeterminate, Mode=OneWay}"
Maximum="100"
Visibility="{x:Bind help:BindTransformers.BoolToVisibility(ShowProgressBar), Mode=OneWay, FallbackValue=Collapsed}"
Value="{x:Bind ProgressValue, Mode=OneWay}" />
</Grid>
</Border>
</DataTemplate>
</ItemsControl.ItemTemplate>
</ItemsControl>
</ScrollViewer>
</StackPanel>
</Border>
</Flyout>
</Button.Flyout>
</Button>
</UserControl>

View File

@@ -0,0 +1,53 @@
// 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.CmdPal.Common.WinGet.Services;
using Microsoft.CmdPal.UI.ViewModels.WinGet;
using Microsoft.Extensions.DependencyInjection;
using Microsoft.UI.Xaml.Controls;
namespace Microsoft.CmdPal.UI.Controls;
public sealed partial class WinGetOperationsButton : UserControl, IDisposable
{
private bool _disposed;
public WinGetOperationsViewModel ViewModel { get; }
public bool HasVisibleOperations => ViewModel.HasVisibleOperations;
public bool HasActiveOperations => ViewModel.HasActiveOperations;
public string SummaryText => ViewModel.SummaryText;
public string FlyoutHeaderText => ViewModel.FlyoutHeaderText;
public WinGetOperationsButton()
{
var trackerService = App.Current.Services.GetRequiredService<IWinGetOperationTrackerService>();
var uiScheduler = App.Current.Services.GetService<TaskScheduler>() ?? TaskScheduler.FromCurrentSynchronizationContext();
ViewModel = new WinGetOperationsViewModel(trackerService, uiScheduler);
this.InitializeComponent();
ViewModel.PropertyChanged += OnViewModelPropertyChanged;
}
public void Dispose()
{
if (_disposed)
{
return;
}
_disposed = true;
ViewModel.PropertyChanged -= OnViewModelPropertyChanged;
ViewModel.Dispose();
}
private void OnViewModelPropertyChanged(object? sender, PropertyChangedEventArgs e)
{
Bindings.Update();
}
}

View File

@@ -8,6 +8,10 @@ namespace Microsoft.CmdPal.UI.Helpers;
internal static class BindTransformers
{
public static Visibility BoolToVisibility(bool value) => value ? Visibility.Visible : Visibility.Collapsed;
public static Visibility BoolToInvertedVisibility(bool value) => value ? Visibility.Collapsed : Visibility.Visible;
public static bool Negate(bool value) => !value;
public static Visibility NegateVisibility(Visibility value) => value == Visibility.Visible ? Visibility.Collapsed : Visibility.Visible;

View File

@@ -0,0 +1,38 @@
// Copyright (c) Microsoft Corporation
// The Microsoft Corporation licenses this file to you under the MIT license.
// See the LICENSE file in the project root for more information.
using Microsoft.CmdPal.Common.ExtensionGallery.Services;
using Microsoft.CmdPal.UI.ViewModels.Gallery;
using Microsoft.CmdPal.UI.ViewModels.Services;
using Microsoft.Extensions.DependencyInjection;
namespace Microsoft.CmdPal.UI.Helpers;
internal static class GalleryServiceRegistration
{
/// <summary>
/// Registers the extension gallery service, wired to the current gallery feed URL from settings.
/// Custom feed URLs are only honored in non-CI (local dev) builds.
/// </summary>
public static IServiceCollection AddGalleryServices(this IServiceCollection services)
{
services.AddSingleton<ExtensionGalleryHttpClient>();
services.AddSingleton<GalleryFeedUrlProvider>(sp =>
{
if (BuildInfo.IsCiBuild)
{
return () => null;
}
var settingsService = sp.GetRequiredService<ISettingsService>();
return () => settingsService.Settings.GalleryFeedUrl;
});
services.AddSingleton<IExtensionGalleryService, ExtensionGalleryService>();
services.AddTransient<ExtensionGalleryItemViewModelFactory>();
services.AddTransient<ExtensionGalleryViewModel>();
return services;
}
}

View File

@@ -0,0 +1,38 @@
// Copyright (c) Microsoft Corporation
// The Microsoft Corporation licenses this file to you under the MIT license.
// See the LICENSE file in the project root for more information.
using ManagedCommon;
using Microsoft.CmdPal.Common.WinGet.Services;
using Microsoft.Extensions.DependencyInjection;
namespace Microsoft.CmdPal.UI.Helpers;
internal static class WinGetServiceRegistration
{
/// <summary>
/// Creates and registers WinGet services. Returns the created instances so
/// they can be used before the container is built (e.g. for built-in command setup).
/// Returns <c>null</c> when WinGet is unavailable (not installed, too old, running as admin, etc.).
/// </summary>
public static (IWinGetPackageManagerService PackageManager, IWinGetOperationTrackerService OperationTracker)?
AddWinGetServices(this IServiceCollection services)
{
try
{
var operationTracker = new WinGetOperationTrackerService();
var packageManager = new WinGetPackageManagerService(operationTracker);
services.AddSingleton<IWinGetOperationTrackerService>(operationTracker);
services.AddSingleton<IWinGetPackageManagerService>(packageManager);
services.AddSingleton<IWinGetPackageStatusService, WinGetPackageStatusService>();
return (packageManager, operationTracker);
}
catch (Exception ex)
{
Logger.LogError("Failed to initialize WinGet services", ex);
return null;
}
}
}

View File

@@ -0,0 +1,14 @@
// Copyright (c) Microsoft Corporation
// The Microsoft Corporation licenses this file to you under the MIT license.
// See the LICENSE file in the project root for more information.
using Microsoft.CmdPal.UI.ViewModels.Gallery;
namespace Microsoft.CmdPal.UI.Messages;
public record OpenExtensionGalleryScreenshotViewerMessage(
IReadOnlyList<ExtensionGalleryScreenshotViewModel> Screenshots,
ExtensionGalleryScreenshotViewModel Screenshot)
{
public const string ConnectedAnimationKey = "ExtensionGalleryScreenshotOpenAnimation";
}

View File

@@ -21,7 +21,7 @@
<!-- For MVVM Toolkit Partial Properties/AOT support -->
<LangVersion>preview</LangVersion>
<!-- OutputPath is set in CmdPal.Branding.props -->
<AppendTargetFrameworkToOutputPath>false</AppendTargetFrameworkToOutputPath>
@@ -39,7 +39,7 @@
</PropertyGroup>
<!-- For debugging purposes, uncomment this block to enable AOT builds -->
<!-- <PropertyGroup>
<!-- <PropertyGroup>
<EnableCmdPalAOT>true</EnableCmdPalAOT>
<GeneratePackageLocally>true</GeneratePackageLocally>
</PropertyGroup> -->
@@ -88,6 +88,7 @@
<None Remove="Controls\ScreenPreview.xaml" />
<None Remove="Controls\ScrollContainer.xaml" />
<None Remove="Controls\SearchBar.xaml" />
<None Remove="Controls\WinGetOperationsButton.xaml" />
<None Remove="ExtViews\Controls\ImageContentViewer.xaml" />
<None Remove="ExtViews\Controls\PlainTextContentViewer.xaml" />
<None Remove="ListDetailPage.xaml" />
@@ -115,6 +116,7 @@
<PackageReference Include="CommunityToolkit.WinUI.Animations" />
<PackageReference Include="CommunityToolkit.WinUI.Extensions" />
<PackageReference Include="CommunityToolkit.Labs.WinUI.Controls.MarkdownTextBlock" />
<PackageReference Include="CommunityToolkit.Labs.WinUI.Controls.OpacityMaskView" />
<PackageReference Include="Microsoft.Extensions.DependencyInjection" />
<PackageReference Include="Microsoft.Graphics.Win2D" />
<PackageReference Include="Microsoft.Windows.SDK.BuildTools" />
@@ -200,6 +202,9 @@
<Page Update="Controls\SearchBar.xaml">
<Generator>MSBuild:Compile</Generator>
</Page>
<Page Update="Controls\WinGetOperationsButton.xaml">
<Generator>MSBuild:Compile</Generator>
</Page>
<Page Update="Controls\DevRibbon.xaml">
<Generator>MSBuild:Compile</Generator>
</Page>

View File

@@ -0,0 +1,454 @@
<?xml version="1.0" encoding="utf-8" ?>
<Page
x:Class="Microsoft.CmdPal.UI.Settings.ExtensionGalleryItemPage"
xmlns="http://schemas.microsoft.com/winfx/2006/xaml/presentation"
xmlns:x="http://schemas.microsoft.com/winfx/2006/xaml"
xmlns:controls="using:CommunityToolkit.WinUI.Controls"
xmlns:d="http://schemas.microsoft.com/expression/blend/2008"
xmlns:galleryModels="using:Microsoft.CmdPal.Common.ExtensionGallery.Models"
xmlns:help="using:Microsoft.CmdPal.UI.Helpers"
xmlns:mc="http://schemas.openxmlformats.org/markup-compatibility/2006"
xmlns:viewModels="using:Microsoft.CmdPal.UI.ViewModels.Gallery"
mc:Ignorable="d">
<Page.Resources>
<ResourceDictionary>
<ResourceDictionary.ThemeDictionaries>
<ResourceDictionary x:Key="Default">
<ImageSource x:Key="StoreLogoSource">ms-appx:///Assets/StoreLogo.dark.svg</ImageSource>
</ResourceDictionary>
<ResourceDictionary x:Key="Light">
<ImageSource x:Key="StoreLogoSource">ms-appx:///Assets/StoreLogo.light.svg</ImageSource>
</ResourceDictionary>
<ResourceDictionary x:Key="HighContrast">
<ImageSource x:Key="StoreLogoSource">ms-appx:///Assets/StoreLogo.dark.svg</ImageSource>
</ResourceDictionary>
</ResourceDictionary.ThemeDictionaries>
</ResourceDictionary>
</Page.Resources>
<Grid>
<ContentDialog
x:Name="WinGetDialog"
Title="Install via WinGet"
CloseButtonText="Close">
<StackPanel MinWidth="360" Spacing="8">
<Grid ColumnSpacing="8" Visibility="{x:Bind help:BindTransformers.BoolToVisibility(ViewModel.ShowWinGetActionControls), Mode=OneWay, FallbackValue=Collapsed}">
<Grid.ColumnDefinitions>
<ColumnDefinition Width="*" />
<ColumnDefinition Width="Auto" />
<ColumnDefinition Width="Auto" />
</Grid.ColumnDefinitions>
<Button Margin="0,12,0,0"
HorizontalAlignment="Stretch"
Command="{x:Bind ViewModel.InstallViaWinGetCommand, Mode=OneWay}"
Content="{x:Bind ViewModel.InstallViaWinGetText, Mode=OneWay}"
IsEnabled="{x:Bind ViewModel.CanInstallViaWinGet, Mode=OneWay}"
Style="{StaticResource AccentButtonStyle}"
Visibility="{x:Bind help:BindTransformers.BoolToVisibility(ViewModel.ShowInstallViaWinGetButton), Mode=OneWay, FallbackValue=Collapsed}" />
<Button
Grid.Column="2"
Width="32"
Height="32"
Padding="0"
Command="{x:Bind ViewModel.CancelWinGetActionCommand, Mode=OneWay}"
Style="{StaticResource SubtleButtonStyle}"
ToolTipService.ToolTip="Cancel WinGet operation"
Visibility="{x:Bind help:BindTransformers.BoolToVisibility(ViewModel.ShowCancelWinGetActionButton), Mode=OneWay, FallbackValue=Collapsed}">
<FontIcon FontSize="12" Glyph="&#xE711;" />
</Button>
</Grid>
<TextBlock
Margin="0,8,0,8"
HorizontalAlignment="Center"
Text="or"
TextAlignment="Center" />
<Grid
Padding="8,4,0,4"
Background="{ThemeResource CardBackgroundFillColorSecondaryBrush}"
ColumnSpacing="8"
CornerRadius="{StaticResource ControlCornerRadius}">
<Grid.ColumnDefinitions>
<ColumnDefinition Width="*" />
<ColumnDefinition Width="Auto" />
</Grid.ColumnDefinitions>
<TextBlock
VerticalAlignment="Center"
Foreground="{ThemeResource TextFillColorSecondaryBrush}"
IsTextSelectionEnabled="True"
Style="{StaticResource CaptionTextBlockStyle}"
Text="{x:Bind ViewModel.WinGetInstallCommand, Mode=OneWay}"
TextWrapping="WrapWholeWords" />
<Button
Grid.Column="1"
Width="28"
Height="28"
Padding="0"
VerticalAlignment="Center"
Command="{x:Bind ViewModel.CopyWinGetInstallCommand, Mode=OneWay}"
IsEnabled="{x:Bind ViewModel.CanCopyWinGetInstallCommand, Mode=OneWay}"
Style="{StaticResource SubtleButtonStyle}"
ToolTipService.ToolTip="Copy install command">
<FontIcon FontSize="12" Glyph="&#xE8C8;" />
</Button>
</Grid>
<TextBlock
Foreground="{ThemeResource TextFillColorSecondaryBrush}"
Style="{StaticResource CaptionTextBlockStyle}"
Text="{x:Bind ViewModel.WinGetUnavailableMessage, Mode=OneWay}"
TextWrapping="WrapWholeWords"
Visibility="{x:Bind help:BindTransformers.BoolToVisibility(ViewModel.ShowWinGetUnavailableMessage), Mode=OneWay, FallbackValue=Collapsed}" />
<TextBlock
Foreground="{ThemeResource TextFillColorSecondaryBrush}"
Style="{StaticResource CaptionTextBlockStyle}"
Text="{x:Bind ViewModel.WinGetActionMessage, Mode=OneWay}"
TextWrapping="WrapWholeWords"
Visibility="{x:Bind help:BindTransformers.BoolToVisibility(ViewModel.HasWinGetActionMessage), Mode=OneWay, FallbackValue=Collapsed}" />
<ProgressBar
Height="4"
IsIndeterminate="{x:Bind ViewModel.IsWinGetActionIndeterminate, Mode=OneWay}"
Maximum="100"
Visibility="{x:Bind help:BindTransformers.BoolToVisibility(ViewModel.IsWinGetActionInProgress), Mode=OneWay, FallbackValue=Collapsed}"
Value="{x:Bind ViewModel.WinGetActionProgressValue, Mode=OneWay}" />
<StackPanel Orientation="Vertical">
<Rectangle
Height="1"
Margin="-16,24,-16,0"
HorizontalAlignment="Stretch"
Fill="{ThemeResource DividerStrokeColorDefaultBrush}" />
<ItemsControl Margin="0,16,0,0" ItemsSource="{x:Bind ViewModel.SourcesWithDetails, Mode=OneWay}">
<ItemsControl.ItemTemplate>
<DataTemplate x:DataType="galleryModels:GallerySourceInfo">
<StackPanel Spacing="8">
<TextBlock
FontSize="10"
FontWeight="SemiBold"
Foreground="{ThemeResource TextFillColorSecondaryBrush}"
Text="{x:Bind DisplayName}" />
<ItemsControl ItemsSource="{x:Bind Details.FlattenedItems}">
<ItemsControl.ItemTemplate>
<DataTemplate x:DataType="galleryModels:GallerySourceDetailItem">
<Grid Margin="0,2" ColumnSpacing="12">
<Grid.ColumnDefinitions>
<ColumnDefinition Width="80" />
<ColumnDefinition Width="*" />
</Grid.ColumnDefinitions>
<TextBlock
FontSize="10"
Foreground="{ThemeResource TextFillColorTertiaryBrush}"
Text="{x:Bind Label}"
TextTrimming="CharacterEllipsis" />
<TextBlock
Grid.Column="1"
FontSize="10"
Foreground="{ThemeResource TextFillColorSecondaryBrush}"
Text="{x:Bind Value}"
TextTrimming="CharacterEllipsis"
TextWrapping="NoWrap"
Visibility="{x:Bind help:BindTransformers.BoolToVisibility(HasNoLink), FallbackValue=Collapsed}" />
<HyperlinkButton
Grid.Column="1"
MinWidth="0"
MinHeight="0"
Margin="0,-4,0,0"
Padding="0"
NavigateUri="{x:Bind LinkUri}"
Visibility="{x:Bind help:BindTransformers.BoolToVisibility(HasLink), FallbackValue=Collapsed}">
<TextBlock
FontSize="10"
Text="{x:Bind Value}"
TextTrimming="CharacterEllipsis" />
</HyperlinkButton>
</Grid>
</DataTemplate>
</ItemsControl.ItemTemplate>
</ItemsControl>
</StackPanel>
</DataTemplate>
</ItemsControl.ItemTemplate>
</ItemsControl>
</StackPanel>
</StackPanel>
</ContentDialog>
<Grid Padding="16,4,16,16">
<Grid.Transitions>
<TransitionCollection>
<EntranceThemeTransition FromVerticalOffset="50" />
</TransitionCollection>
</Grid.Transitions>
<Grid MaxWidth="1000" HorizontalAlignment="Stretch">
<Grid.RowDefinitions>
<RowDefinition Height="Auto" />
<RowDefinition Height="Auto" />
<RowDefinition Height="Auto" />
<RowDefinition Height="Auto" />
<RowDefinition Height="*" />
</Grid.RowDefinitions>
<!-- Header -->
<Grid Margin="0,0,0,24" ColumnSpacing="24">
<Grid.ColumnDefinitions>
<ColumnDefinition Width="Auto" />
<ColumnDefinition Width="*" />
</Grid.ColumnDefinitions>
<Border
Width="72"
Height="72"
VerticalAlignment="Top">
<Image
Width="72"
Height="72"
Source="{x:Bind ViewModel.IconSource, Mode=OneWay}"
Stretch="Uniform" />
</Border>
<StackPanel
Grid.Column="1"
VerticalAlignment="Center"
Orientation="Vertical"
Spacing="8">
<TextBlock
Margin="0,-8,0,0"
Style="{StaticResource TitleTextBlockStyle}"
Text="{x:Bind ViewModel.DisplayTitle, Mode=OneWay}" />
<StackPanel Orientation="Horizontal" Spacing="8">
<!-- Repo URL -->
<HyperlinkButton
Padding="0"
Command="{x:Bind ViewModel.OpenAuthorPageCommand, Mode=OneWay}"
ToolTipService.ToolTip="Author"
Visibility="{x:Bind help:BindTransformers.BoolToVisibility(ViewModel.HasAuthorUrl), Mode=OneWay, FallbackValue=Collapsed}">
<StackPanel Orientation="Horizontal" Spacing="4">
<FontIcon FontSize="10" Glyph="&#xE77B;" />
<TextBlock Text="{x:Bind ViewModel.DisplayAuthorName, Mode=OneWay}" />
</StackPanel>
</HyperlinkButton>
<TextBlock Text="•" />
<HyperlinkButton
Grid.Row="3"
Padding="0"
NavigateUri="{x:Bind ViewModel.Homepage, Mode=OneWay}"
ToolTipService.ToolTip="{x:Bind ViewModel.Homepage, Mode=OneWay}"
Visibility="{x:Bind help:BindTransformers.BoolToVisibility(ViewModel.HasHomepage), Mode=OneWay, FallbackValue=Collapsed}">
<StackPanel Orientation="Horizontal" Spacing="4">
<FontIcon FontSize="10" Glyph="&#xE774;" />
<TextBlock Text="View repository" />
</StackPanel>
</HyperlinkButton>
</StackPanel>
</StackPanel>
</Grid>
<!-- Short description -->
<TextBlock
Grid.Row="1"
Margin="0,0,0,24"
Foreground="{ThemeResource TextFillColorSecondaryBrush}"
Text="{x:Bind ViewModel.DisplayShortDescription, Mode=OneWay}"
TextWrapping="WrapWholeWords" />
<!-- Install area -->
<StackPanel
Grid.Row="2"
Margin="0,0,0,24"
Orientation="Horizontal"
Spacing="12">
<DropDownButton
x:Name="InstallBtn"
MinWidth="172"
Padding="12"
HorizontalContentAlignment="Left"
Content="Install"
FontWeight="SemiBold"
IsEnabled="{x:Bind ViewModel.ShowInstallButton, Mode=OneWay}"
Style="{StaticResource AccentDropDownButtonStyle}">
<DropDownButton.Flyout>
<MenuFlyout Placement="Bottom">
<MenuFlyoutItem
Click="StoreMenuItem_Click"
Text="Microsoft Store"
Visibility="{x:Bind help:BindTransformers.BoolToVisibility(ViewModel.HasStoreSource), Mode=OneWay, FallbackValue=Collapsed}">
<MenuFlyoutItem.Icon>
<ImageIcon Source="{ThemeResource StoreLogoSource}" />
</MenuFlyoutItem.Icon>
</MenuFlyoutItem>
<MenuFlyoutItem
Click="WinGetMenuItem_Click"
Text="WinGet"
Visibility="{x:Bind help:BindTransformers.BoolToVisibility(ViewModel.HasWinGetSource), Mode=OneWay, FallbackValue=Collapsed}">
<MenuFlyoutItem.Icon>
<ImageIcon Source="ms-appx:///Assets/WinGetLogo.svg" />
</MenuFlyoutItem.Icon>
</MenuFlyoutItem>
<MenuFlyoutItem
Click="WebMenuItem_Click"
Text="Website"
Visibility="{x:Bind help:BindTransformers.BoolToVisibility(ViewModel.HasUrlSource), Mode=OneWay, FallbackValue=Collapsed}">
<MenuFlyoutItem.Icon>
<FontIcon Glyph="&#xE774;" />
</MenuFlyoutItem.Icon>
</MenuFlyoutItem>
</MenuFlyout>
</DropDownButton.Flyout>
</DropDownButton>
<Border
Padding="8,4"
VerticalAlignment="Center"
Background="{ThemeResource SystemFillColorSuccessBackgroundBrush}"
CornerRadius="{StaticResource OverlayCornerRadius}"
Visibility="{x:Bind help:BindTransformers.BoolToVisibility(ViewModel.ShowInstalledBadge), Mode=OneWay, FallbackValue=Collapsed}">
<TextBlock
x:Uid="Settings_GalleryItemPage_Installed_Badge"
Foreground="{ThemeResource SystemFillColorSuccessBrush}"
Style="{StaticResource CaptionTextBlockStyle}" />
</Border>
<HyperlinkButton
x:Uid="Settings_GalleryItemPage_Uninstall_Link"
Padding="0"
VerticalAlignment="Center"
Command="{x:Bind ViewModel.OpenInstalledAppsCommand}"
FontSize="12"
Visibility="{x:Bind help:BindTransformers.BoolToVisibility(ViewModel.ShowInstalledBadge), Mode=OneWay, FallbackValue=Collapsed}" />
</StackPanel>
<ScrollViewer Grid.Row="4">
<StackPanel Spacing="16">
<Grid
Background="{ThemeResource CardBackgroundFillColorDefaultBrush}"
BorderBrush="{ThemeResource CardStrokeColorDefaultBrush}"
BorderThickness="1"
CornerRadius="{StaticResource OverlayCornerRadius}"
Visibility="{x:Bind help:BindTransformers.BoolToVisibility(ViewModel.HasScreenshots), Mode=OneWay, FallbackValue=Collapsed}">
<Grid.RowDefinitions>
<RowDefinition Height="Auto" />
<RowDefinition Height="Auto" />
<RowDefinition Height="Auto" />
</Grid.RowDefinitions>
<TextBlock
Margin="16,12,16,12"
FontWeight="SemiBold"
Text="Screenshots" />
<Rectangle
Grid.Row="1"
Height="1"
HorizontalAlignment="Stretch"
Fill="{ThemeResource DividerStrokeColorDefaultBrush}" />
<ItemsView
x:Name="ScreenshotsItemsView"
Grid.Row="2"
Padding="16"
IsItemInvokedEnabled="True"
ItemInvoked="ScreenshotsItemsView_ItemInvoked"
ItemsSource="{x:Bind ViewModel.Screenshots, Mode=OneWay}"
SelectionMode="None">
<ItemsView.Layout>
<StackLayout Orientation="Horizontal" Spacing="12" />
</ItemsView.Layout>
<ItemsView.ItemTemplate>
<DataTemplate x:DataType="viewModels:ExtensionGalleryScreenshotViewModel">
<ItemContainer AutomationProperties.Name="{x:Bind DisplayName}" ToolTipService.ToolTip="{x:Bind DisplayName}">
<ItemContainer.Resources>
<SolidColorBrush x:Key="ItemContainerBackground" Color="Transparent" />
<SolidColorBrush x:Key="ItemContainerBackgroundPointerOver" Color="Transparent" />
<SolidColorBrush x:Key="ItemContainerBackgroundPressed" Color="Transparent" />
</ItemContainer.Resources>
<Border
Height="200"
Background="{ThemeResource CardBackgroundFillColorDefaultBrush}"
BorderBrush="{ThemeResource CardStrokeColorDefaultBrush}"
BorderThickness="1"
CornerRadius="{StaticResource OverlayCornerRadius}">
<Image
AutomationProperties.Name="{x:Bind DisplayName}"
Source="{x:Bind ImageSource}"
Stretch="Uniform" />
</Border>
</ItemContainer>
</DataTemplate>
</ItemsView.ItemTemplate>
</ItemsView>
</Grid>
<Grid>
<Grid.ColumnDefinitions>
<ColumnDefinition Width="*" />
<ColumnDefinition Width="Auto" />
<!-- TO DO: Related extensions column -->
</Grid.ColumnDefinitions>
<Grid
Background="{ThemeResource CardBackgroundFillColorDefaultBrush}"
BorderBrush="{ThemeResource CardStrokeColorDefaultBrush}"
BorderThickness="1"
CornerRadius="{StaticResource OverlayCornerRadius}">
<Grid.RowDefinitions>
<RowDefinition Height="Auto" />
<RowDefinition Height="Auto" />
<RowDefinition Height="*" />
<RowDefinition Height="Auto" />
</Grid.RowDefinitions>
<TextBlock
Margin="16,12,16,12"
FontWeight="SemiBold"
Text="Description" />
<Rectangle
Grid.Row="1"
Height="1"
HorizontalAlignment="Stretch"
Fill="{ThemeResource DividerStrokeColorDefaultBrush}" />
<TextBlock
Grid.Row="2"
Margin="16"
IsTextSelectionEnabled="True"
Text="{x:Bind ViewModel.Description}"
TextWrapping="WrapWholeWords" />
<!-- TO DO: Visualize these as tags? -->
<TextBlock
Grid.Row="3"
Margin="16"
Foreground="{ThemeResource TextFillColorSecondaryBrush}"
Style="{StaticResource CaptionTextBlockStyle}"
Text="{x:Bind ViewModel.TagsText, Mode=OneWay}"
TextWrapping="WrapWholeWords"
Visibility="{x:Bind help:BindTransformers.BoolToVisibility(ViewModel.HasTags), Mode=OneWay, FallbackValue=Collapsed}" />
</Grid>
</Grid>
<Grid ColumnSpacing="8">
<Grid.ColumnDefinitions>
<ColumnDefinition Width="Auto" />
<ColumnDefinition Width="*" />
</Grid.ColumnDefinitions>
<FontIcon
VerticalAlignment="Top"
FontSize="10"
Glyph="&#xE946;" />
<TextBlock
Grid.Column="1"
Margin="0,-4,0,0"
VerticalAlignment="Top"
FontSize="10"
Foreground="{ThemeResource TextFillColorSecondaryBrush}"
Style="{StaticResource CaptionTextBlockStyle}"
Text="Extensions listed here are created and maintained by third parties under their own terms. Review each extension's source and terms before installing or using it. Microsoft does not control third-party extension behavior and is not responsible for their content or actions."
TextWrapping="WrapWholeWords" />
</Grid>
</StackPanel>
</ScrollViewer>
</Grid>
</Grid>
</Grid>
</Page>

View File

@@ -0,0 +1,94 @@
// 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 CommunityToolkit.Mvvm.Messaging;
using Microsoft.CmdPal.UI.Messages;
using Microsoft.CmdPal.UI.ViewModels.Gallery;
using Microsoft.UI.Xaml;
using Microsoft.UI.Xaml.Controls;
using Microsoft.UI.Xaml.Media;
using Microsoft.UI.Xaml.Media.Animation;
using Microsoft.UI.Xaml.Navigation;
namespace Microsoft.CmdPal.UI.Settings;
public sealed partial class ExtensionGalleryItemPage : Page
{
public ExtensionGalleryItemViewModel? ViewModel { get; private set; }
public ExtensionGalleryItemPage()
{
this.InitializeComponent();
}
protected override void OnNavigatedTo(NavigationEventArgs e)
{
base.OnNavigatedTo(e);
if (e.Parameter is ExtensionGalleryItemViewModel vm)
{
ViewModel = vm;
Bindings.Update();
}
}
private void StoreMenuItem_Click(object sender, RoutedEventArgs e)
{
ViewModel?.InstallViaStoreCommand.Execute(null);
}
private async void WinGetMenuItem_Click(object sender, RoutedEventArgs e)
{
WinGetDialog.XamlRoot = XamlRoot;
await WinGetDialog.ShowAsync();
}
private void WebMenuItem_Click(object sender, RoutedEventArgs e)
{
ViewModel?.OpenInstallUrlCommand.Execute(null);
}
private void ScreenshotsItemsView_ItemInvoked(ItemsView sender, ItemsViewItemInvokedEventArgs args)
{
if (args.InvokedItem is ExtensionGalleryScreenshotViewModel screenshot)
{
PrepareScreenshotOpenAnimation(sender, screenshot);
WeakReferenceMessenger.Default.Send(
new OpenExtensionGalleryScreenshotViewerMessage(
ViewModel?.Screenshots ?? [],
screenshot));
}
}
private static void PrepareScreenshotOpenAnimation(ItemsView itemsView, ExtensionGalleryScreenshotViewModel screenshot)
{
var repeater = FindDescendant<ItemsRepeater>(itemsView);
var element = repeater?.TryGetElement(screenshot.Index);
if (element is UIElement sourceElement)
{
ConnectedAnimationService.GetForCurrentView().PrepareToAnimate(OpenExtensionGalleryScreenshotViewerMessage.ConnectedAnimationKey, sourceElement);
}
}
private static T? FindDescendant<T>(DependencyObject parent)
where T : DependencyObject
{
var count = VisualTreeHelper.GetChildrenCount(parent);
for (var i = 0; i < count; i++)
{
var child = VisualTreeHelper.GetChild(parent, i);
if (child is T found)
{
return found;
}
var result = FindDescendant<T>(child);
if (result is not null)
{
return result;
}
}
return null;
}
}

View File

@@ -0,0 +1,425 @@
<?xml version="1.0" encoding="utf-8" ?>
<Page
x:Class="Microsoft.CmdPal.UI.Settings.ExtensionGalleryPage"
xmlns="http://schemas.microsoft.com/winfx/2006/xaml/presentation"
xmlns:x="http://schemas.microsoft.com/winfx/2006/xaml"
xmlns:controls="using:CommunityToolkit.WinUI.Controls"
xmlns:d="http://schemas.microsoft.com/expression/blend/2008"
xmlns:help="using:Microsoft.CmdPal.UI.Helpers"
xmlns:local="using:Microsoft.CmdPal.UI.Settings"
xmlns:mc="http://schemas.openxmlformats.org/markup-compatibility/2006"
xmlns:ptControls="using:Microsoft.CmdPal.UI.Controls"
xmlns:ui="using:CommunityToolkit.WinUI"
xmlns:viewModels="using:Microsoft.CmdPal.UI.ViewModels.Gallery"
mc:Ignorable="d">
<Page.Resources>
<ResourceDictionary>
<ResourceDictionary.ThemeDictionaries>
<ResourceDictionary x:Key="Default">
<LinearGradientBrush x:Key="GalleryBannerBrush" StartPoint="0,0" EndPoint="0.5, 1">
<GradientStop Offset="0" Color="#38C8AEC4" />
<GradientStop Offset="1" Color="#383286EE" />
</LinearGradientBrush>
<LinearGradientBrush x:Key="GalleryTitleBrush" StartPoint="0,0" EndPoint="1,0">
<GradientStop Offset="0.0" Color="#FFB9EBFF" />
<GradientStop Offset="0.5" Color="#FF86CBFF" />
<GradientStop Offset="1.0" Color="#FFA1E7FF" />
</LinearGradientBrush>
</ResourceDictionary>
<ResourceDictionary x:Key="Light">
<LinearGradientBrush x:Key="GalleryBannerBrush" StartPoint="0,0" EndPoint="1, 1">
<GradientStop Offset="0.0" Color="#FFF6F9FF" />
<GradientStop Offset="0.4" Color="#FFEFF5FF" />
<GradientStop Offset="0.7" Color="#FFF7FAFD" />
<GradientStop Offset="1.0" Color="#FFF5F8FA" />
</LinearGradientBrush>
<LinearGradientBrush x:Key="GalleryTitleBrush" StartPoint="0,0" EndPoint="1,0">
<GradientStop Offset="0.0" Color="#FF1462B8" />
<GradientStop Offset="0.5" Color="#FF3B7FD9" />
<GradientStop Offset="1.0" Color="#FF1462B8" />
</LinearGradientBrush>
</ResourceDictionary>
<ResourceDictionary x:Key="HighContrast">
<SolidColorBrush x:Key="GalleryBannerBrush" Color="Transparent" />
<SolidColorBrush x:Key="GalleryTitleBrush" Color="{ThemeResource SystemColorWindowTextColor}" />
</ResourceDictionary>
</ResourceDictionary.ThemeDictionaries>
</ResourceDictionary>
</Page.Resources>
<Grid>
<Grid.RowDefinitions>
<RowDefinition Height="Auto" />
<RowDefinition Height="*" />
</Grid.RowDefinitions>
<ScrollViewer x:Name="RootScrollViewer" Grid.Row="1">
<StackPanel>
<!-- Custom feed InfoBar -->
<InfoBar
x:Name="CustomFeedInfoBar"
x:Uid="Settings_GalleryPage_CustomFeed_InfoBar"
Margin="16,0"
x:Load="{x:Bind ViewModel.IsCustomFeed}"
IsClosable="False"
IsOpen="True"
Message="{x:Bind ViewModel.CustomFeedUrl}"
Severity="Informational" />
<!-- Hero section (full bleed, no MaxWidth) -->
<Grid>
<!-- Gradient background with opacity mask fade-out -->
<controls:OpacityMaskView HorizontalAlignment="Stretch" HorizontalContentAlignment="Stretch">
<controls:OpacityMaskView.OpacityMask>
<Rectangle RadiusX="8" RadiusY="8">
<Rectangle.Fill>
<LinearGradientBrush StartPoint="0.5,0" EndPoint="0.5,1">
<GradientStop Offset="0.50" Color="Black" />
<GradientStop Offset="0.80" Color="#80000000" />
<GradientStop Offset="1.0" Color="Transparent" />
</LinearGradientBrush>
</Rectangle.Fill>
</Rectangle>
</controls:OpacityMaskView.OpacityMask>
<Grid Height="320" Background="{ThemeResource GalleryBannerBrush}" />
</controls:OpacityMaskView>
<!-- Content on top of the gradient (not masked) -->
<StackPanel
Margin="0,24,0,0"
HorizontalAlignment="Center"
Spacing="8">
<TextBlock
x:Uid="Settings_GalleryPage_Banner_Header"
HorizontalAlignment="Center"
FontSize="32"
FontWeight="Bold"
Foreground="{ThemeResource GalleryTitleBrush}" />
<TextBlock
x:Uid="Settings_GalleryPage_Hero_Subtitle"
HorizontalAlignment="Center"
Foreground="{ThemeResource TextFillColorSecondaryBrush}"
Style="{StaticResource BodyTextBlockStyle}"
TextAlignment="Center" />
<HyperlinkButton
x:Uid="Settings_GalleryPage_Hero_CreateLink"
Padding="0"
HorizontalAlignment="Center"
NavigateUri="https://aka.ms/building-cmdpal-extensions" />
<ptControls:IconCarouselControl
Height="160"
Margin="0,12,0,0"
HorizontalAlignment="Stretch"
IconUris="{x:Bind ViewModel.CarouselIconUris, Mode=OneWay}" />
</StackPanel>
</Grid>
<!-- Content area (MaxWidth-constrained) -->
<StackPanel
MaxWidth="1200"
Padding="16,0,16,16"
HorizontalAlignment="Stretch"
Spacing="{StaticResource SettingsCardSpacing}">
<!-- Search + Counter + Refresh -->
<Grid Padding="0,24,0,8" ColumnSpacing="8">
<Grid.ColumnDefinitions>
<ColumnDefinition Width="*" MaxWidth="320" />
<ColumnDefinition Width="*" />
</Grid.ColumnDefinitions>
<AutoSuggestBox
x:Name="SearchBox"
x:Uid="Settings_GalleryPage_SearchBox"
HorizontalAlignment="Stretch"
Text="{x:Bind ViewModel.SearchText, Mode=TwoWay, UpdateSourceTrigger=PropertyChanged}">
<AutoSuggestBox.QueryIcon>
<SymbolIcon Symbol="Find" />
</AutoSuggestBox.QueryIcon>
<AutoSuggestBox.KeyboardAccelerators>
<KeyboardAccelerator
Key="F"
Invoked="OnFindInvoked"
Modifiers="Control" />
</AutoSuggestBox.KeyboardAccelerators>
</AutoSuggestBox>
<StackPanel
Grid.Row="0"
Grid.Column="1"
HorizontalAlignment="Right"
VerticalAlignment="Center"
Orientation="Horizontal"
Spacing="8">
<Button
x:Uid="Settings_GalleryPage_Refresh_Button"
Command="{x:Bind ViewModel.RefreshCommand}"
Style="{StaticResource SubtleButtonStyle}">
<FontIcon
AutomationProperties.AccessibilityView="Raw"
FontSize="16"
Glyph="&#xE72C;" />
</Button>
<Button x:Uid="Settings_GalleryPage_Sort_Button" Style="{StaticResource SubtleButtonStyle}">
<Button.Flyout>
<MenuFlyout Placement="BottomEdgeAlignedRight">
<RadioMenuFlyoutItem
x:Uid="Settings_GalleryPage_Sort_Default"
Command="{x:Bind ViewModel.SortByFeaturedCommand}"
GroupName="GallerySort"
IsChecked="{x:Bind ViewModel.IsSortByFeaturedSelected, Mode=OneWay}" />
<RadioMenuFlyoutItem
x:Uid="Settings_GalleryPage_Sort_Name"
Command="{x:Bind ViewModel.SortByNameCommand}"
GroupName="GallerySort"
IsChecked="{x:Bind ViewModel.IsSortByNameSelected, Mode=OneWay}" />
<RadioMenuFlyoutItem
x:Uid="Settings_GalleryPage_Sort_Author"
Command="{x:Bind ViewModel.SortByAuthorCommand}"
GroupName="GallerySort"
IsChecked="{x:Bind ViewModel.IsSortByAuthorSelected, Mode=OneWay}" />
<RadioMenuFlyoutItem
x:Uid="Settings_GalleryPage_Sort_InstallationStatus"
Command="{x:Bind ViewModel.SortByInstallationStatusCommand}"
GroupName="GallerySort"
IsChecked="{x:Bind ViewModel.IsSortByInstallationStatusSelected, Mode=OneWay}" />
</MenuFlyout>
</Button.Flyout>
<FontIcon
AutomationProperties.AccessibilityView="Raw"
FontSize="16"
Glyph="&#xE8CB;" />
</Button>
</StackPanel>
</Grid>
<!-- Loading state -->
<Grid
x:Name="LoadingPanel"
Padding="48"
x:Load="{x:Bind ViewModel.IsLoading, Mode=OneWay}">
<StackPanel
HorizontalAlignment="Center"
VerticalAlignment="Center"
Spacing="12">
<ProgressRing
Width="32"
Height="32"
IsActive="True" />
<TextBlock
x:Uid="Settings_GalleryPage_Loading_Text"
HorizontalAlignment="Center"
Style="{StaticResource BodyTextBlockStyle}" />
</StackPanel>
</Grid>
<!-- Error state -->
<InfoBar
x:Name="ErrorStateInfoBar"
Title="{x:Bind ViewModel.ErrorDisplayTitle, Mode=OneWay}"
x:Load="{x:Bind ViewModel.ShowErrorInfoBar, Mode=OneWay}"
IsClosable="False"
IsOpen="True"
Message="{x:Bind ViewModel.ErrorDisplayMessage, Mode=OneWay}"
Severity="Error" />
<Grid
x:Name="ErrorSurface"
Padding="48"
x:Load="{x:Bind ViewModel.ShowErrorSurface, Mode=OneWay}"
CornerRadius="4">
<StackPanel
HorizontalAlignment="Center"
VerticalAlignment="Center"
Spacing="8">
<TextBlock
HorizontalAlignment="Center"
FontFamily="Segoe UI Emoji"
FontSize="72"
Text="{x:Bind ViewModel.ErrorDisplayIconGlyph, Mode=OneWay}" />
<TextBlock
Style="{StaticResource BodyStrongTextBlockStyle}"
Text="{x:Bind ViewModel.ErrorDisplayTitle, Mode=OneWay}"
TextAlignment="Center" />
<TextBlock
Foreground="{ThemeResource TextFillColorSecondaryBrush}"
Text="{x:Bind ViewModel.ErrorDisplayMessage, Mode=OneWay}"
TextAlignment="Center"
TextWrapping="WrapWholeWords" />
</StackPanel>
</Grid>
<!-- From cache indicator -->
<InfoBar
x:Name="CacheInfoBar"
x:Uid="Settings_GalleryPage_Cache_InfoBar"
x:Load="{x:Bind ViewModel.UsedFallbackCache, Mode=OneWay}"
IsClosable="False"
IsOpen="True"
Severity="Warning" />
<!-- No results panel -->
<Grid
x:Name="NoResultsPanel"
Padding="48"
x:Load="{x:Bind ViewModel.ShowNoResultsPanel, Mode=OneWay}"
CornerRadius="4">
<StackPanel
HorizontalAlignment="Center"
VerticalAlignment="Center"
Spacing="8">
<TextBlock
x:Uid="Settings_GalleryPage_NoResults_Primary"
Style="{StaticResource BodyStrongTextBlockStyle}"
TextAlignment="Center" />
<TextBlock
x:Uid="Settings_GalleryPage_NoResults_Secondary"
Foreground="{ThemeResource TextFillColorSecondaryBrush}"
TextAlignment="Center" />
</StackPanel>
</Grid>
<!-- Extensions list -->
<ItemsView
x:Name="GalleryItemsView"
x:Load="{x:Bind ViewModel.HasResults, Mode=OneWay}"
IsItemInvokedEnabled="True"
ItemInvoked="GalleryItemsView_ItemInvoked"
ItemsSource="{x:Bind ViewModel.FilteredEntries, Mode=OneWay}"
SelectionMode="None">
<ItemsView.Layout>
<controls:WrapLayout HorizontalSpacing="12" VerticalSpacing="12" />
</ItemsView.Layout>
<ItemsView.ItemTemplate>
<DataTemplate x:DataType="viewModels:ExtensionGalleryItemViewModel">
<ItemContainer
VerticalAlignment="Stretch"
VerticalContentAlignment="Stretch"
AutomationProperties.Name="{x:Bind DisplayTitle}">
<Grid
Width="280"
Height="150"
Padding="12"
Background="{ThemeResource CardBackgroundFillColorDefaultBrush}"
BorderBrush="{ThemeResource CardStrokeColorDefaultBrush}"
BorderThickness="1"
CornerRadius="{StaticResource OverlayCornerRadius}"
RowSpacing="8">
<Grid.RowDefinitions>
<RowDefinition Height="Auto" />
<RowDefinition Height="Auto" />
<RowDefinition Height="*" />
</Grid.RowDefinitions>
<Border
Width="64"
Height="64"
HorizontalAlignment="Left"
CornerRadius="{StaticResource OverlayCornerRadius}">
<Grid>
<Image
Width="48"
Height="48"
Source="{x:Bind IconSource, Mode=OneWay}"
Stretch="UniformToFill" />
<ProgressRing
Width="16"
Height="16"
HorizontalAlignment="Right"
VerticalAlignment="Top"
IsActive="{x:Bind ShowWinGetActionIndicator, Mode=OneWay}"
Visibility="{x:Bind help:BindTransformers.BoolToVisibility(ShowWinGetActionIndicator), Mode=OneWay, FallbackValue=Collapsed}" />
</Grid>
</Border>
<StackPanel
HorizontalAlignment="Right"
VerticalAlignment="Top"
Orientation="Horizontal"
Spacing="8">
<Border
Padding="8,4"
Background="{ThemeResource SystemFillColorCautionBackgroundBrush}"
CornerRadius="{StaticResource OverlayCornerRadius}"
Visibility="{x:Bind help:BindTransformers.BoolToVisibility(ShowUpdateBadge), Mode=OneWay, FallbackValue=Collapsed}">
<TextBlock
x:Uid="Settings_GalleryPage_Update_Badge"
Foreground="{ThemeResource SystemFillColorCautionBrush}"
Style="{StaticResource CaptionTextBlockStyle}" />
</Border>
<Border
Padding="8,4"
Background="{ThemeResource SystemFillColorSuccessBackgroundBrush}"
CornerRadius="{StaticResource OverlayCornerRadius}"
Visibility="{x:Bind help:BindTransformers.BoolToVisibility(ShowInstalledBadge), Mode=OneWay, FallbackValue=Collapsed}">
<TextBlock
x:Uid="Settings_GalleryPage_Installed_Badge"
Foreground="{ThemeResource SystemFillColorSuccessBrush}"
Style="{StaticResource CaptionTextBlockStyle}" />
</Border>
</StackPanel>
<TextBlock
Grid.Row="1"
Grid.Column="0"
Style="{StaticResource BodyStrongTextBlockStyle}"
Text="{x:Bind DisplayTitle}"
TextTrimming="CharacterEllipsis"
TextWrapping="WrapWholeWords" />
<Grid Grid.Row="2" Grid.Column="0">
<Grid.RowDefinitions>
<RowDefinition Height="*" />
<RowDefinition Height="Auto" />
</Grid.RowDefinitions>
<TextBlock
Grid.Row="0"
Foreground="{ThemeResource TextFillColorSecondaryBrush}"
MaxLines="2"
Style="{StaticResource CaptionTextBlockStyle}"
Text="{x:Bind DisplayShortDescription, Mode=OneWay}"
TextTrimming="CharacterEllipsis"
TextWrapping="WrapWholeWords" />
<StackPanel
Grid.Row="1"
Margin="0,8,0,0"
Spacing="6"
Visibility="{x:Bind help:BindTransformers.BoolToVisibility(ShowWinGetActionStatus), Mode=OneWay, FallbackValue=Collapsed}">
<TextBlock
Foreground="{ThemeResource TextFillColorSecondaryBrush}"
Style="{StaticResource CaptionTextBlockStyle}"
Text="{x:Bind WinGetActionMessage, Mode=OneWay}"
TextWrapping="WrapWholeWords" />
<ProgressBar
Height="4"
IsIndeterminate="{x:Bind IsWinGetActionIndeterminate, Mode=OneWay}"
Maximum="100"
Value="{x:Bind WinGetActionProgressValue, Mode=OneWay}" />
</StackPanel>
</Grid>
</Grid>
</ItemContainer>
</DataTemplate>
</ItemsView.ItemTemplate>
</ItemsView>
</StackPanel>
</StackPanel>
</ScrollViewer>
<VisualStateManager.VisualStateGroups>
<VisualStateGroup x:Name="LayoutVisualStates">
<VisualState x:Name="WideLayout">
<VisualState.StateTriggers>
<AdaptiveTrigger MinWindowWidth="720" />
</VisualState.StateTriggers>
</VisualState>
<VisualState x:Name="NarrowLayout">
<VisualState.StateTriggers>
<AdaptiveTrigger MinWindowWidth="0" />
</VisualState.StateTriggers>
<VisualState.Setters />
</VisualState>
</VisualStateGroup>
</VisualStateManager.VisualStateGroups>
</Grid>
</Page>

View File

@@ -0,0 +1,68 @@
// 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.UI.ViewModels.Gallery;
using Microsoft.Extensions.DependencyInjection;
using Microsoft.UI.Xaml;
using Microsoft.UI.Xaml.Controls;
using Microsoft.UI.Xaml.Input;
namespace Microsoft.CmdPal.UI.Settings;
public sealed partial class ExtensionGalleryPage : Page, IDisposable
{
public ExtensionGalleryViewModel ViewModel { get; }
public ExtensionGalleryPage()
{
ViewModel = App.Current.Services.GetRequiredService<ExtensionGalleryViewModel>();
this.InitializeComponent();
Loaded += ExtensionGalleryPage_Loaded;
Unloaded += ExtensionGalleryPage_Unloaded;
}
private void ExtensionGalleryPage_Unloaded(object sender, RoutedEventArgs e)
{
ViewModel.Dispose();
}
private async void ExtensionGalleryPage_Loaded(object sender, RoutedEventArgs e)
{
await ViewModel.LoadAsync();
}
private void GalleryItemsView_ItemInvoked(ItemsView sender, ItemsViewItemInvokedEventArgs args)
{
if (args.InvokedItem is ExtensionGalleryItemViewModel vm)
{
NavigateToDetails(vm);
}
}
private void OnFindInvoked(KeyboardAccelerator sender, KeyboardAcceleratorInvokedEventArgs args)
{
SearchBox?.Focus(FocusState.Keyboard);
args.Handled = true;
}
private void ViewDetailsMenuItem_Click(object sender, RoutedEventArgs e)
{
if (sender is MenuFlyoutItem item && item.Tag is ExtensionGalleryItemViewModel vm)
{
NavigateToDetails(vm);
}
}
private void NavigateToDetails(ExtensionGalleryItemViewModel vm)
{
Frame?.Navigate(typeof(ExtensionGalleryItemPage), vm);
}
public void Dispose()
{
ViewModel.Dispose();
}
}

View File

@@ -65,6 +65,20 @@
<Button Click="ToggleDevRibbonClicked" Content="Toggle dev ribbon" />
</controls:SettingsCard>
<!-- Gallery Section -->
<TextBlock Style="{StaticResource SettingsSectionHeaderTextBlockStyle}" Text="Extension Gallery" />
<controls:SettingsCard
Description="Override the default extension gallery feed URL. Leave empty to use the default feed."
Header="Custom gallery feed URL"
HeaderIcon="{ui:FontIcon Glyph=&#xE71B;}">
<TextBox
x:Name="GalleryFeedUrlTextBox"
MinWidth="400"
LostFocus="GalleryFeedUrlTextBox_LostFocus"
PlaceholderText="https://..."
Text="{x:Bind GalleryFeedUrl, Mode=OneTime}" />
</controls:SettingsCard>
<!-- Data Section -->
<TextBlock Style="{StaticResource SettingsSectionHeaderTextBlockStyle}" Text="Data and Files" />
<controls:SettingsCard

View File

@@ -6,9 +6,11 @@ using CommunityToolkit.Mvvm.Messaging;
using ManagedCommon;
using Microsoft.CmdPal.Common.Services;
using Microsoft.CmdPal.UI.Messages;
using Microsoft.CmdPal.UI.ViewModels.Services;
using Microsoft.CommandPalette.Extensions.Toolkit;
using Microsoft.Extensions.DependencyInjection;
using Microsoft.UI.Xaml;
using Microsoft.UI.Xaml.Controls;
using Windows.System;
using Page = Microsoft.UI.Xaml.Controls.Page;
@@ -20,12 +22,28 @@ namespace Microsoft.CmdPal.UI.Settings;
public sealed partial class InternalPage : Page
{
private readonly IApplicationInfoService _appInfoService;
private readonly ISettingsService _settingsService;
public string GalleryFeedUrl => _settingsService.Settings.GalleryFeedUrl ?? string.Empty;
public InternalPage()
{
InitializeComponent();
_appInfoService = App.Current.Services.GetRequiredService<IApplicationInfoService>();
_settingsService = App.Current.Services.GetRequiredService<ISettingsService>();
}
private void GalleryFeedUrlTextBox_LostFocus(object sender, RoutedEventArgs e)
{
if (sender is TextBox textBox)
{
var newUrl = string.IsNullOrWhiteSpace(textBox.Text) ? null : textBox.Text.Trim();
if (newUrl != _settingsService.Settings.GalleryFeedUrl)
{
_settingsService.UpdateSettings(s => s with { GalleryFeedUrl = newUrl });
}
}
}
private void ThrowPlainMainThreadException_Click(object sender, RoutedEventArgs e)

View File

@@ -3,11 +3,13 @@
x:Class="Microsoft.CmdPal.UI.Settings.SettingsWindow"
xmlns="http://schemas.microsoft.com/winfx/2006/xaml/presentation"
xmlns:x="http://schemas.microsoft.com/winfx/2006/xaml"
xmlns:controls="using:Microsoft.CmdPal.UI.Controls"
xmlns:d="http://schemas.microsoft.com/expression/blend/2008"
xmlns:local="using:Microsoft.CmdPal.UI.Settings"
xmlns:mc="http://schemas.openxmlformats.org/markup-compatibility/2006"
xmlns:text="using:Windows.UI.Text"
xmlns:ui="using:CommunityToolkit.WinUI"
xmlns:viewModels="using:Microsoft.CmdPal.UI.ViewModels.Gallery"
xmlns:winuiex="using:WinUIEx"
Title="SettingsWindow"
Width="1280"
@@ -42,6 +44,7 @@
<NavigationView
x:Name="NavView"
Grid.Row="1"
Grid.Column="0"
CompactModeThresholdWidth="1007"
DisplayModeChanged="NavView_DisplayModeChanged"
ExpandedModeThresholdWidth="1007"
@@ -66,12 +69,6 @@
x:Uid="Settings_GeneralPage_NavigationViewItem_Appearance"
Icon="{ui:FontIcon Glyph=&#xE790;}"
Tag="Appearance" />
<NavigationViewItem
x:Name="ExtensionPageNavItem"
x:Uid="Settings_GeneralPage_NavigationViewItem_Extensions"
Icon="{ui:FontIcon Glyph=&#xEA86;}"
Tag="Extensions" />
<!-- xF596 is HolePunchLandscapeTop -->
<NavigationViewItem
x:Name="DockSettingsPageNavItem"
x:Uid="Settings_GeneralPage_NavigationViewItem_Dock"
@@ -81,6 +78,20 @@
<InfoBadge Style="{StaticResource NewInfoBadge}" />
</NavigationViewItem.InfoBadge>
</NavigationViewItem>
<NavigationViewItemHeader Content="Extensions" />
<!-- x:Uid="Settings_GeneralPage_NavigationViewItem_Extensions" -->
<NavigationViewItem
x:Name="ExtensionPageNavItem"
Content="Installed"
Icon="{ui:FontIcon Glyph=&#xEA86;}"
Tag="Extensions" />
<!-- x:Uid="Settings_GeneralPage_NavigationViewItem_Gallery" -->
<NavigationViewItem
x:Name="GalleryPageNavItem"
Content="Gallery"
Icon="{ui:FontIcon Glyph=&#xE719;}"
Tag="Gallery" />
<!-- xF596 is HolePunchLandscapeTop -->
<!-- "Internal Tools" page item is added dynamically from code -->
</NavigationView.MenuItems>
<Grid>
@@ -88,7 +99,7 @@
<RowDefinition Height="Auto" />
<RowDefinition Height="*" />
</Grid.RowDefinitions>
<Grid Padding="16,0">
<Grid x:Name="BreadcrumbContainer" Padding="16,0">
<BreadcrumbBar
x:Name="NavigationBreadcrumbBar"
MaxWidth="1000"
@@ -109,11 +120,92 @@
</BreadcrumbBar.Resources>
</BreadcrumbBar>
</Grid>
<Frame
x:Name="NavFrame"
Grid.Row="1"
Navigated="NavFrame_OnNavigated" />
<Grid Grid.Row="1" Grid.Column="0">
<Grid.Transitions>
<TransitionCollection>
<RepositionThemeTransition />
</TransitionCollection>
</Grid.Transitions>
<Frame x:Name="NavFrame" Navigated="NavFrame_OnNavigated" />
<controls:WinGetOperationsButton
x:Name="WinGetOperationsButtonControl"
Grid.Row="0"
Grid.Column="0"
Margin="0,0,16,16" />
</Grid>
</Grid>
</NavigationView>
<Popup x:Name="ScreenshotViewerPopup" IsLightDismissEnabled="False">
<Grid
x:Name="ScreenshotViewerOverlay"
Background="{ThemeResource AcrylicBackgroundFillColorDefaultBrush}"
IsTabStop="True"
KeyDown="ScreenshotViewerOverlay_KeyDown"
PointerWheelChanged="ScreenshotViewerOverlay_PointerWheelChanged">
<Grid.RowDefinitions>
<RowDefinition Height="Auto" />
<RowDefinition Height="*" />
<RowDefinition Height="Auto" />
</Grid.RowDefinitions>
<Button
Width="48"
Height="48"
Margin="16,48,32,0"
HorizontalAlignment="Right"
Click="CloseScreenshotViewerButton_Click"
Style="{StaticResource SubtleButtonStyle}"
ToolTipService.ToolTip="Close screenshot viewer">
<FontIcon FontSize="18" Glyph="&#xE711;" />
</Button>
<Grid Grid.Row="1" Margin="24,16,24,24">
<Border
x:Name="ScreenshotViewerImageHost"
Padding="16">
<FlipView
x:Name="ScreenshotViewerFlipView"
Background="Transparent"
SelectionChanged="ScreenshotViewerFlipView_SelectionChanged">
<FlipView.ItemTemplate>
<DataTemplate x:DataType="viewModels:ExtensionGalleryScreenshotViewModel">
<Grid MinHeight="360">
<Image
AutomationProperties.Name="{x:Bind DisplayName}"
HorizontalAlignment="Stretch"
VerticalAlignment="Stretch"
Source="{x:Bind ImageSource}"
Stretch="Uniform" />
</Grid>
</DataTemplate>
</FlipView.ItemTemplate>
</FlipView>
</Border>
</Grid>
<Border
Grid.Row="2"
Margin="0,0,0,24"
Padding="20,12"
HorizontalAlignment="Center"
Background="{ThemeResource LayerOnAcrylicPrimaryBackgroundBrush}"
BorderBrush="{ThemeResource ControlStrokeColorDefaultBrush}"
BorderThickness="1"
CornerRadius="12">
<StackPanel HorizontalAlignment="Center" Spacing="2">
<TextBlock
HorizontalAlignment="Center"
FontWeight="SemiBold"
Text="{x:Bind CurrentScreenshotDisplayName, Mode=OneWay}" />
<TextBlock
HorizontalAlignment="Center"
Foreground="{ThemeResource TextFillColorSecondaryBrush}"
Style="{StaticResource CaptionTextBlockStyle}"
Text="{x:Bind CurrentScreenshotPositionText, Mode=OneWay}" />
</StackPanel>
</Border>
</Grid>
</Popup>
</Grid>
</winuiex:WindowEx>

View File

@@ -8,12 +8,14 @@ using ManagedCommon;
using Microsoft.CmdPal.UI.Helpers;
using Microsoft.CmdPal.UI.Messages;
using Microsoft.CmdPal.UI.ViewModels;
using Microsoft.CmdPal.UI.ViewModels.Gallery;
using Microsoft.CmdPal.UI.ViewModels.Messages;
using Microsoft.UI.Input;
using Microsoft.UI.Windowing;
using Microsoft.UI.Xaml;
using Microsoft.UI.Xaml.Controls;
using Microsoft.UI.Xaml.Input;
using Microsoft.UI.Xaml.Media.Animation;
using Microsoft.UI.Xaml.Navigation;
using Windows.System;
using WinUIEx;
@@ -25,17 +27,29 @@ namespace Microsoft.CmdPal.UI.Settings;
public sealed partial class SettingsWindow : WindowEx,
IDisposable,
IRecipient<NavigateToExtensionSettingsMessage>,
IRecipient<OpenExtensionGalleryScreenshotViewerMessage>,
IRecipient<QuitMessage>
{
private readonly LocalKeyboardListener _localKeyboardListener;
private readonly NavigationViewItem? _internalNavItem;
private Storyboard? _breadcrumbStoryboard;
private IReadOnlyList<ExtensionGalleryScreenshotViewModel> _currentScreenshotSet = [];
private ExtensionGalleryScreenshotViewModel? _currentScreenshot;
public ObservableCollection<Crumb> BreadCrumbs { get; } = [];
// Gets or sets optional action invoked after NavigationView is loaded.
public Action? NavigationViewLoaded { get; set; }
public string CurrentScreenshotDisplayName => _currentScreenshot?.DisplayName ?? string.Empty;
public string CurrentScreenshotPositionText =>
_currentScreenshot is null || _currentScreenshotSet.Count == 0
? string.Empty
: $"{GetCurrentScreenshotIndex() + 1} / {_currentScreenshotSet.Count}";
public SettingsWindow()
{
this.InitializeComponent();
@@ -47,12 +61,14 @@ public sealed partial class SettingsWindow : WindowEx,
PositionCentered();
WeakReferenceMessenger.Default.Register<NavigateToExtensionSettingsMessage>(this);
WeakReferenceMessenger.Default.Register<OpenExtensionGalleryScreenshotViewerMessage>(this);
WeakReferenceMessenger.Default.Register<QuitMessage>(this);
_localKeyboardListener = new LocalKeyboardListener();
_localKeyboardListener.KeyPressed += LocalKeyboardListener_OnKeyPressed;
_localKeyboardListener.Start();
Closed += SettingsWindow_Closed;
RootElement.SizeChanged += RootElement_SizeChanged;
RootElement.AddHandler(UIElement.PointerPressedEvent, new PointerEventHandler(RootElement_OnPointerPressed), true);
if (!BuildInfo.IsCiBuild)
@@ -63,7 +79,7 @@ public sealed partial class SettingsWindow : WindowEx,
Icon = new FontIcon { Glyph = "\uEC7A" },
Tag = "Internal",
};
NavView.MenuItems.Add(_internalNavItem);
NavView.FooterMenuItems.Add(_internalNavItem);
}
else
{
@@ -124,6 +140,9 @@ public sealed partial class SettingsWindow : WindowEx,
case "Extensions":
pageType = typeof(ExtensionsPage);
break;
case "Gallery":
pageType = typeof(ExtensionGalleryPage);
break;
case "Dock":
pageType = typeof(DockSettingsPage);
break;
@@ -178,6 +197,16 @@ public sealed partial class SettingsWindow : WindowEx,
public void Receive(NavigateToExtensionSettingsMessage message) => Navigate(message.ProviderSettingsVM);
public void Receive(OpenExtensionGalleryScreenshotViewerMessage message)
{
if (message.Screenshots.Count == 0)
{
return;
}
OpenScreenshotViewer(message.Screenshot, message.Screenshots, startConnectedAnimation: true);
}
private void NavigationBreadcrumbBar_ItemClicked(BreadcrumbBar sender, BreadcrumbBarItemClickedEventArgs args)
{
if (args.Item is Crumb crumb)
@@ -229,6 +258,12 @@ public sealed partial class SettingsWindow : WindowEx,
private void TryGoBack()
{
if (ScreenshotViewerPopup.IsOpen)
{
CloseScreenshotViewer();
return;
}
if (NavFrame.CanGoBack)
{
NavFrame.GoBack();
@@ -278,14 +313,76 @@ public sealed partial class SettingsWindow : WindowEx,
}
}
private void RootElement_SizeChanged(object sender, SizeChangedEventArgs e)
{
UpdateScreenshotViewerPopupSize();
}
private void HideBreadcrumb()
{
_breadcrumbStoryboard?.Stop();
var fadeOut = new DoubleAnimation
{
To = 0,
Duration = new Duration(TimeSpan.FromMilliseconds(200)),
EasingFunction = new CubicEase { EasingMode = EasingMode.EaseIn },
};
Storyboard.SetTarget(fadeOut, BreadcrumbContainer);
Storyboard.SetTargetProperty(fadeOut, "Opacity");
_breadcrumbStoryboard = new Storyboard();
_breadcrumbStoryboard.Children.Add(fadeOut);
_breadcrumbStoryboard.Completed += (_, _) =>
{
BreadcrumbContainer.Visibility = Visibility.Collapsed;
BreadcrumbContainer.Opacity = 1;
_breadcrumbStoryboard = null;
};
_breadcrumbStoryboard.Begin();
}
private void ShowBreadcrumb()
{
_breadcrumbStoryboard?.Stop();
_breadcrumbStoryboard = null;
if (BreadcrumbContainer.Visibility == Visibility.Collapsed)
{
BreadcrumbContainer.Opacity = 0;
BreadcrumbContainer.Visibility = Visibility.Visible;
var fadeIn = new DoubleAnimation
{
To = 1,
Duration = new Duration(TimeSpan.FromMilliseconds(250)),
EasingFunction = new CubicEase { EasingMode = EasingMode.EaseOut },
};
Storyboard.SetTarget(fadeIn, BreadcrumbContainer);
Storyboard.SetTargetProperty(fadeIn, "Opacity");
_breadcrumbStoryboard = new Storyboard();
_breadcrumbStoryboard.Children.Add(fadeIn);
_breadcrumbStoryboard.Completed += (_, _) => _breadcrumbStoryboard = null;
_breadcrumbStoryboard.Begin();
}
else
{
BreadcrumbContainer.Opacity = 1;
}
}
public void Dispose()
{
CloseScreenshotViewer();
WinGetOperationsButtonControl?.Dispose();
_localKeyboardListener?.Dispose();
}
private void NavFrame_OnNavigated(object sender, NavigationEventArgs e)
{
BreadCrumbs.Clear();
ShowBreadcrumb();
if (e.SourcePageType == typeof(GeneralPage))
{
@@ -305,6 +402,21 @@ public sealed partial class SettingsWindow : WindowEx,
var pageType = RS_.GetString("Settings_PageTitles_ExtensionsPage");
BreadCrumbs.Add(new(pageType, pageType));
}
else if (e.SourcePageType == typeof(ExtensionGalleryPage))
{
NavView.SelectedItem = GalleryPageNavItem;
HideBreadcrumb();
var pageType = RS_.GetString("Settings_PageTitles_GalleryPage");
BreadCrumbs.Add(new(pageType, pageType));
}
else if (e.SourcePageType == typeof(ExtensionGalleryItemPage) && e.Parameter is ExtensionGalleryItemViewModel galleryExtension)
{
NavView.SelectedItem = GalleryPageNavItem;
HideBreadcrumb();
var galleryPageType = RS_.GetString("Settings_PageTitles_GalleryPage");
BreadCrumbs.Add(new(galleryPageType, "Gallery"));
BreadCrumbs.Add(new(galleryExtension.Title, galleryExtension));
}
else if (e.SourcePageType == typeof(DockSettingsPage))
{
NavView.SelectedItem = DockSettingsPageNavItem;
@@ -330,6 +442,153 @@ public sealed partial class SettingsWindow : WindowEx,
Logger.LogError($"Unknown breadcrumb for page type '{e.SourcePageType}'");
}
}
private void CloseScreenshotViewerButton_Click(object sender, RoutedEventArgs e)
{
CloseScreenshotViewer();
}
private void ScreenshotViewerOverlay_PointerWheelChanged(object sender, PointerRoutedEventArgs e)
{
if (!ScreenshotViewerPopup.IsOpen || _currentScreenshotSet.Count <= 1)
{
return;
}
var delta = e.GetCurrentPoint(ScreenshotViewerOverlay).Properties.MouseWheelDelta;
if (delta > 0)
{
ChangeScreenshot(-1);
e.Handled = true;
}
else if (delta < 0)
{
ChangeScreenshot(1);
e.Handled = true;
}
}
private void ScreenshotViewerOverlay_KeyDown(object sender, KeyRoutedEventArgs e)
{
if (!ScreenshotViewerPopup.IsOpen)
{
return;
}
switch (e.Key)
{
case VirtualKey.Escape:
CloseScreenshotViewer();
e.Handled = true;
break;
case VirtualKey.Left:
ChangeScreenshot(-1);
e.Handled = true;
break;
case VirtualKey.Right:
ChangeScreenshot(1);
e.Handled = true;
break;
}
}
private void ScreenshotViewerFlipView_SelectionChanged(object sender, SelectionChangedEventArgs e)
{
_currentScreenshot = ScreenshotViewerFlipView.SelectedItem as ExtensionGalleryScreenshotViewModel;
UpdateScreenshotViewerBindings();
}
private void OpenScreenshotViewer(
ExtensionGalleryScreenshotViewModel screenshot,
IReadOnlyList<ExtensionGalleryScreenshotViewModel> screenshots,
bool startConnectedAnimation)
{
_currentScreenshotSet = screenshots;
_currentScreenshot = screenshot;
UpdateScreenshotViewerBindings();
UpdateScreenshotViewerPopupSize();
ScreenshotViewerFlipView.ItemsSource = screenshots;
ScreenshotViewerFlipView.SelectedIndex = GetCurrentScreenshotIndex();
ScreenshotViewerPopup.IsOpen = true;
DispatcherQueue.TryEnqueue(() =>
{
ScreenshotViewerOverlay.UpdateLayout();
if (startConnectedAnimation)
{
var animation = ConnectedAnimationService.GetForCurrentView().GetAnimation(OpenExtensionGalleryScreenshotViewerMessage.ConnectedAnimationKey);
animation?.TryStart(ScreenshotViewerImageHost);
}
ScreenshotViewerOverlay.Focus(FocusState.Programmatic);
});
}
private void CloseScreenshotViewer()
{
if (ScreenshotViewerPopup.IsOpen)
{
ScreenshotViewerPopup.IsOpen = false;
}
ScreenshotViewerFlipView.ItemsSource = null;
ScreenshotViewerFlipView.SelectedIndex = -1;
_currentScreenshotSet = [];
_currentScreenshot = null;
UpdateScreenshotViewerBindings();
RootElement.Focus(FocusState.Programmatic);
}
private void ChangeScreenshot(int delta)
{
if (_currentScreenshotSet.Count <= 1 || ScreenshotViewerFlipView.SelectedIndex < 0)
{
return;
}
var nextIndex = (ScreenshotViewerFlipView.SelectedIndex + delta) % _currentScreenshotSet.Count;
if (nextIndex < 0)
{
nextIndex += _currentScreenshotSet.Count;
}
ScreenshotViewerFlipView.SelectedIndex = nextIndex;
}
private int GetCurrentScreenshotIndex()
{
if (_currentScreenshot is null)
{
return -1;
}
for (var i = 0; i < _currentScreenshotSet.Count; i++)
{
if (ReferenceEquals(_currentScreenshotSet[i], _currentScreenshot))
{
return i;
}
}
return Math.Clamp(_currentScreenshot.Index, 0, _currentScreenshotSet.Count - 1);
}
private void UpdateScreenshotViewerPopupSize()
{
if (RootElement.ActualWidth <= 0 || RootElement.ActualHeight <= 0)
{
return;
}
ScreenshotViewerOverlay.Width = RootElement.ActualWidth;
ScreenshotViewerOverlay.Height = RootElement.ActualHeight;
}
private void UpdateScreenshotViewerBindings()
{
Bindings.Update();
}
}
public readonly struct Crumb

View File

@@ -682,6 +682,9 @@ Right-click to remove the key combination, thereby deactivating the shortcut.</v
<data name="Settings_PageTitles_DockPage" xml:space="preserve">
<value>Dock</value>
</data>
<data name="Settings_PageTitles_GalleryPage" xml:space="preserve">
<value>Extension Gallery</value>
</data>
<data name="Settings_GeneralPage_EscapeKeyBehavior_Option_DismissEmptySearchOrGoBack.Content" xml:space="preserve">
<value>Clear search first, then go back</value>
</data>
@@ -1158,4 +1161,107 @@ Right-click to remove the key combination, thereby deactivating the shortcut.</v
<value>Right</value>
<comment>Right section label in pin to dock dialog (code access, horizontal end)</comment>
</data>
<data name="Settings_GeneralPage_NavigationViewItem_Gallery.Content" xml:space="preserve">
<value>Extension Gallery</value>
</data>
<data name="Settings_GalleryPage_Banner_Header.Text" xml:space="preserve">
<value>Extension Gallery</value>
</data>
<data name="Settings_GalleryPage_Banner_Description.Text" xml:space="preserve">
<value>Discover extensions for Command Palette built by the community.</value>
</data>
<data name="Settings_GalleryPage_Hero_Subtitle.Text" xml:space="preserve">
<value>Discover, install and manage community-built extensions</value>
</data>
<data name="Settings_GalleryPage_Hero_CreateLink.Content" xml:space="preserve">
<value>Learn how to create your own extension</value>
</data>
<data name="Settings_GalleryPage_Banner_SubmitLink.Content" xml:space="preserve">
<value>Submit your extension</value>
</data>
<data name="Settings_GalleryPage_SearchBox.PlaceholderText" xml:space="preserve">
<value>Search extensions...</value>
</data>
<data name="Settings_GalleryPage_Refresh_Button.[using:Microsoft.UI.Xaml.Controls]ToolTipService.ToolTip" xml:space="preserve">
<value>Refresh</value>
</data>
<data name="Settings_GalleryPage_Sort_Button.[using:Microsoft.UI.Xaml.Automation]AutomationProperties.Name" xml:space="preserve">
<value>Sort extensions</value>
</data>
<data name="Settings_GalleryPage_Sort_Button.[using:Microsoft.UI.Xaml.Controls]ToolTipService.ToolTip" xml:space="preserve">
<value>Sort extensions</value>
</data>
<data name="Settings_GalleryPage_Sort_Default.Text" xml:space="preserve">
<value>Gallery order</value>
</data>
<data name="Settings_GalleryPage_Sort_Name.Text" xml:space="preserve">
<value>Name</value>
</data>
<data name="Settings_GalleryPage_Sort_Author.Text" xml:space="preserve">
<value>Author</value>
</data>
<data name="Settings_GalleryPage_Sort_InstallationStatus.Text" xml:space="preserve">
<value>Installation status</value>
</data>
<data name="Settings_GalleryPage_Loading_Text.Text" xml:space="preserve">
<value>Loading extensions...</value>
</data>
<data name="Settings_GalleryPage_Error_InfoBar.Title" xml:space="preserve">
<value>Failed to load extensions</value>
</data>
<data name="Settings_GalleryPage_RateLimit_Primary.Text" xml:space="preserve">
<value>The gallery is taking a breather</value>
</data>
<data name="Settings_GalleryPage_RateLimit_Secondary.Text" xml:space="preserve">
<value>We hit the extension gallery rate limit. Please try again in a little while.</value>
</data>
<data name="Settings_GalleryPage_Cache_InfoBar.Title" xml:space="preserve">
<value>Showing cached data</value>
</data>
<data name="Settings_GalleryPage_Cache_InfoBar.Message" xml:space="preserve">
<value>Could not reach the extension gallery. Showing previously cached data.</value>
</data>
<data name="Settings_GalleryPage_CustomFeed_InfoBar.Title" xml:space="preserve">
<value>Using custom extension gallery feed</value>
</data>
<data name="Settings_GalleryPage_NoResults_Primary.Text" xml:space="preserve">
<value>No extensions found</value>
</data>
<data name="Settings_GalleryPage_NoResults_Secondary.Text" xml:space="preserve">
<value>Try a different search term.</value>
</data>
<data name="Settings_GalleryPage_Installed_Badge.Text" xml:space="preserve">
<value>Installed</value>
</data>
<data name="Settings_GalleryPage_Update_Badge.Text" xml:space="preserve">
<value>Update</value>
</data>
<data name="Settings_GalleryItemPage_Installed_Badge.Text" xml:space="preserve">
<value>Installed</value>
</data>
<data name="Settings_GalleryItemPage_Uninstall_Link.Content" xml:space="preserve">
<value>Uninstall</value>
</data>
<data name="Settings_GalleryPage_StoreButton.[using:Microsoft.UI.Xaml.Controls]ToolTipService.ToolTip" xml:space="preserve">
<value>Install from Microsoft Store</value>
</data>
<data name="Settings_GalleryPage_StoreButton_Text.Text" xml:space="preserve">
<value>Store</value>
</data>
<data name="Settings_GalleryPage_DownloadButton.[using:Microsoft.UI.Xaml.Controls]ToolTipService.ToolTip" xml:space="preserve">
<value>Download</value>
</data>
<data name="Settings_GalleryPage_DownloadButton_Text.Text" xml:space="preserve">
<value>Download</value>
</data>
<data name="Settings_GalleryPage_ByAuthor.Text" xml:space="preserve">
<value>by </value>
<comment>Prefix before author name, e.g. "by Microsoft"</comment>
</data>
<data name="WinGetOperationsButton_Root.[using:Microsoft.UI.Xaml.Controls]ToolTipService.ToolTip" xml:space="preserve">
<value>Show WinGet download and install progress</value>
</data>
<data name="WinGetOperationsButton_CancelOperation.[using:Microsoft.UI.Xaml.Controls]ToolTipService.ToolTip" xml:space="preserve">
<value>Cancel WinGet operation</value>
</data>
</root>

View File

@@ -0,0 +1,235 @@
<ResourceDictionary
xmlns="http://schemas.microsoft.com/winfx/2006/xaml/presentation"
xmlns:x="http://schemas.microsoft.com/winfx/2006/xaml"
xmlns:animatedvisuals="using:Microsoft.UI.Xaml.Controls.AnimatedVisuals"
xmlns:controls="using:Microsoft.UI.Xaml.Controls">
<!-- Accent-colored DropDownButton style, mirroring AccentButtonStyle colors onto the DefaultDropDownButtonStyle template -->
<Style x:Key="AccentDropDownButtonStyle" TargetType="controls:DropDownButton">
<Setter Property="Background" Value="{ThemeResource AccentButtonBackground}" />
<Setter Property="Foreground" Value="{ThemeResource AccentButtonForeground}" />
<Setter Property="BorderBrush" Value="{ThemeResource AccentButtonBorderBrush}" />
<Setter Property="BorderThickness" Value="{ThemeResource ButtonBorderThemeThickness}" />
<Setter Property="Padding" Value="{StaticResource ButtonPadding}" />
<Setter Property="HorizontalAlignment" Value="Left" />
<Setter Property="VerticalAlignment" Value="Center" />
<Setter Property="FontFamily" Value="{ThemeResource ContentControlThemeFontFamily}" />
<Setter Property="FontWeight" Value="Normal" />
<Setter Property="FontSize" Value="{ThemeResource ControlContentThemeFontSize}" />
<Setter Property="UseSystemFocusVisuals" Value="{StaticResource UseSystemFocusVisuals}" />
<Setter Property="FocusVisualMargin" Value="-3" />
<Setter Property="CornerRadius" Value="{ThemeResource ControlCornerRadius}" />
<Setter Property="BackgroundSizing" Value="OuterBorderEdge" />
<Setter Property="Template">
<Setter.Value>
<ControlTemplate TargetType="Button">
<Grid
x:Name="RootGrid"
Padding="{TemplateBinding Padding}"
Background="{TemplateBinding Background}"
BackgroundSizing="{TemplateBinding BackgroundSizing}"
BorderBrush="{TemplateBinding BorderBrush}"
BorderThickness="{TemplateBinding BorderThickness}"
CornerRadius="{TemplateBinding CornerRadius}">
<Grid.BackgroundTransition>
<BrushTransition Duration="0:0:0.083" />
</Grid.BackgroundTransition>
<Grid.ColumnDefinitions>
<ColumnDefinition Width="*" />
<ColumnDefinition Width="Auto" />
</Grid.ColumnDefinitions>
<ContentPresenter
x:Name="ContentPresenter"
HorizontalContentAlignment="{TemplateBinding HorizontalContentAlignment}"
VerticalContentAlignment="{TemplateBinding VerticalContentAlignment}"
AutomationProperties.AccessibilityView="Raw"
Content="{TemplateBinding Content}"
ContentTemplate="{TemplateBinding ContentTemplate}"
ContentTransitions="{TemplateBinding ContentTransitions}" />
<controls:AnimatedIcon
x:Name="ChevronIcon"
Grid.Column="1"
Width="12"
Height="12"
Margin="8,0,0,0"
controls:AnimatedIcon.State="Normal"
AutomationProperties.AccessibilityView="Raw"
Foreground="{ThemeResource AccentButtonForeground}">
<animatedvisuals:AnimatedChevronDownSmallVisualSource />
<controls:AnimatedIcon.FallbackIconSource>
<controls:FontIconSource
FontFamily="{ThemeResource SymbolThemeFontFamily}"
FontSize="8"
Glyph="&#xE96E;"
IsTextScaleFactorEnabled="False" />
</controls:AnimatedIcon.FallbackIconSource>
</controls:AnimatedIcon>
<VisualStateManager.VisualStateGroups>
<VisualStateGroup x:Name="CommonStates">
<VisualState x:Name="Normal" />
<VisualState x:Name="PointerOver">
<Storyboard>
<ObjectAnimationUsingKeyFrames Storyboard.TargetName="RootGrid" Storyboard.TargetProperty="Background">
<DiscreteObjectKeyFrame KeyTime="0" Value="{ThemeResource AccentButtonBackgroundPointerOver}" />
</ObjectAnimationUsingKeyFrames>
<ObjectAnimationUsingKeyFrames Storyboard.TargetName="RootGrid" Storyboard.TargetProperty="BorderBrush">
<DiscreteObjectKeyFrame KeyTime="0" Value="{ThemeResource AccentButtonBorderBrushPointerOver}" />
</ObjectAnimationUsingKeyFrames>
<ObjectAnimationUsingKeyFrames Storyboard.TargetName="ContentPresenter" Storyboard.TargetProperty="Foreground">
<DiscreteObjectKeyFrame KeyTime="0" Value="{ThemeResource AccentButtonForegroundPointerOver}" />
</ObjectAnimationUsingKeyFrames>
<ObjectAnimationUsingKeyFrames Storyboard.TargetName="ChevronIcon" Storyboard.TargetProperty="Foreground">
<DiscreteObjectKeyFrame KeyTime="0" Value="{ThemeResource AccentButtonForegroundPointerOver}" />
</ObjectAnimationUsingKeyFrames>
</Storyboard>
<VisualState.Setters>
<Setter Target="ChevronIcon.(controls:AnimatedIcon.State)" Value="PointerOver" />
</VisualState.Setters>
</VisualState>
<VisualState x:Name="Pressed">
<Storyboard>
<ObjectAnimationUsingKeyFrames Storyboard.TargetName="RootGrid" Storyboard.TargetProperty="Background">
<DiscreteObjectKeyFrame KeyTime="0" Value="{ThemeResource AccentButtonBackgroundPressed}" />
</ObjectAnimationUsingKeyFrames>
<ObjectAnimationUsingKeyFrames Storyboard.TargetName="RootGrid" Storyboard.TargetProperty="BorderBrush">
<DiscreteObjectKeyFrame KeyTime="0" Value="{ThemeResource AccentButtonBorderBrushPressed}" />
</ObjectAnimationUsingKeyFrames>
<ObjectAnimationUsingKeyFrames Storyboard.TargetName="ContentPresenter" Storyboard.TargetProperty="Foreground">
<DiscreteObjectKeyFrame KeyTime="0" Value="{ThemeResource AccentButtonForegroundPressed}" />
</ObjectAnimationUsingKeyFrames>
<ObjectAnimationUsingKeyFrames Storyboard.TargetName="ChevronIcon" Storyboard.TargetProperty="Foreground">
<DiscreteObjectKeyFrame KeyTime="0" Value="{ThemeResource AccentButtonForegroundPressed}" />
</ObjectAnimationUsingKeyFrames>
</Storyboard>
<VisualState.Setters>
<Setter Target="ChevronIcon.(controls:AnimatedIcon.State)" Value="Pressed" />
</VisualState.Setters>
</VisualState>
<VisualState x:Name="Disabled">
<Storyboard>
<ObjectAnimationUsingKeyFrames Storyboard.TargetName="RootGrid" Storyboard.TargetProperty="Background">
<DiscreteObjectKeyFrame KeyTime="0" Value="{ThemeResource AccentButtonBackgroundDisabled}" />
</ObjectAnimationUsingKeyFrames>
<ObjectAnimationUsingKeyFrames Storyboard.TargetName="RootGrid" Storyboard.TargetProperty="BorderBrush">
<DiscreteObjectKeyFrame KeyTime="0" Value="{ThemeResource AccentButtonBorderBrushDisabled}" />
</ObjectAnimationUsingKeyFrames>
<ObjectAnimationUsingKeyFrames Storyboard.TargetName="ContentPresenter" Storyboard.TargetProperty="Foreground">
<DiscreteObjectKeyFrame KeyTime="0" Value="{ThemeResource AccentButtonForegroundDisabled}" />
</ObjectAnimationUsingKeyFrames>
<ObjectAnimationUsingKeyFrames Storyboard.TargetName="ChevronIcon" Storyboard.TargetProperty="Foreground">
<DiscreteObjectKeyFrame KeyTime="0" Value="{ThemeResource AccentButtonForegroundDisabled}" />
</ObjectAnimationUsingKeyFrames>
</Storyboard>
<VisualState.Setters>
<Setter Target="ChevronIcon.(controls:AnimatedIcon.State)" Value="Normal" />
</VisualState.Setters>
</VisualState>
</VisualStateGroup>
</VisualStateManager.VisualStateGroups>
</Grid>
</ControlTemplate>
</Setter.Value>
</Setter>
</Style>
<Style x:Key="ScrollContainerScrollButtonStyle" TargetType="Button">
<Setter Property="Background" Value="{ThemeResource FlipViewNextPreviousButtonBackground}" />
<Setter Property="BackgroundSizing" Value="InnerBorderEdge" />
<Setter Property="Foreground" Value="{ThemeResource ButtonForeground}" />
<Setter Property="BorderBrush" Value="{ThemeResource FlipViewNextPreviousButtonBorderBrush}" />
<Setter Property="BorderThickness" Value="1" />
<Setter Property="Padding" Value="8,0,8,0" />
<Setter Property="Margin" Value="0" />
<Setter Property="HorizontalAlignment" Value="Left" />
<Setter Property="VerticalAlignment" Value="Center" />
<Setter Property="FontFamily" Value="{ThemeResource ContentControlThemeFontFamily}" />
<Setter Property="FontWeight" Value="Normal" />
<Setter Property="FontSize" Value="{ThemeResource ControlContentThemeFontSize}" />
<Setter Property="UseSystemFocusVisuals" Value="{StaticResource UseSystemFocusVisuals}" />
<Setter Property="FocusVisualMargin" Value="-3" />
<Setter Property="CornerRadius" Value="{ThemeResource ControlCornerRadius}" />
<Setter Property="Template">
<Setter.Value>
<ControlTemplate TargetType="Button">
<ContentPresenter
x:Name="ContentPresenter"
Padding="{TemplateBinding Padding}"
HorizontalContentAlignment="{TemplateBinding HorizontalContentAlignment}"
VerticalContentAlignment="{TemplateBinding VerticalContentAlignment}"
AnimatedIcon.State="Normal"
AutomationProperties.AccessibilityView="Raw"
Background="{TemplateBinding Background}"
BackgroundSizing="{TemplateBinding BackgroundSizing}"
BorderBrush="{TemplateBinding BorderBrush}"
BorderThickness="{TemplateBinding BorderThickness}"
Content="{TemplateBinding Content}"
ContentTemplate="{TemplateBinding ContentTemplate}"
ContentTransitions="{TemplateBinding ContentTransitions}"
CornerRadius="{TemplateBinding CornerRadius}">
<ContentPresenter.BackgroundTransition>
<BrushTransition Duration="0:0:0.083" />
</ContentPresenter.BackgroundTransition>
<VisualStateManager.VisualStateGroups>
<VisualStateGroup x:Name="CommonStates">
<VisualState x:Name="Normal" />
<VisualState x:Name="PointerOver">
<Storyboard>
<ObjectAnimationUsingKeyFrames Storyboard.TargetName="ContentPresenter" Storyboard.TargetProperty="Background">
<DiscreteObjectKeyFrame KeyTime="0" Value="{ThemeResource FlipViewNextPreviousButtonBackgroundPointerOver}" />
</ObjectAnimationUsingKeyFrames>
<ObjectAnimationUsingKeyFrames Storyboard.TargetName="ContentPresenter" Storyboard.TargetProperty="BorderBrush">
<DiscreteObjectKeyFrame KeyTime="0" Value="{ThemeResource FlipViewNextPreviousButtonBorderBrushPointerOver}" />
</ObjectAnimationUsingKeyFrames>
<ObjectAnimationUsingKeyFrames Storyboard.TargetName="ContentPresenter" Storyboard.TargetProperty="Foreground">
<DiscreteObjectKeyFrame KeyTime="0" Value="{ThemeResource FlipViewNextPreviousArrowForegroundPointerOver}" />
</ObjectAnimationUsingKeyFrames>
</Storyboard>
<VisualState.Setters>
<Setter Target="ContentPresenter.(AnimatedIcon.State)" Value="PointerOver" />
</VisualState.Setters>
</VisualState>
<VisualState x:Name="Pressed">
<Storyboard>
<ObjectAnimationUsingKeyFrames Storyboard.TargetName="ContentPresenter" Storyboard.TargetProperty="Background">
<DiscreteObjectKeyFrame KeyTime="0" Value="{ThemeResource FlipViewNextPreviousButtonBackgroundPressed}" />
</ObjectAnimationUsingKeyFrames>
<ObjectAnimationUsingKeyFrames Storyboard.TargetName="ContentPresenter" Storyboard.TargetProperty="BorderBrush">
<DiscreteObjectKeyFrame KeyTime="0" Value="{ThemeResource FlipViewNextPreviousButtonBorderBrushPressed}" />
</ObjectAnimationUsingKeyFrames>
<ObjectAnimationUsingKeyFrames Storyboard.TargetName="ContentPresenter" Storyboard.TargetProperty="Foreground">
<DiscreteObjectKeyFrame KeyTime="0" Value="{ThemeResource FlipViewNextPreviousArrowForegroundPressed}" />
</ObjectAnimationUsingKeyFrames>
</Storyboard>
<VisualState.Setters>
<Setter Target="ContentPresenter.(AnimatedIcon.State)" Value="Pressed" />
</VisualState.Setters>
</VisualState>
<VisualState x:Name="Disabled">
<Storyboard>
<ObjectAnimationUsingKeyFrames Storyboard.TargetName="ContentPresenter" Storyboard.TargetProperty="Background">
<DiscreteObjectKeyFrame KeyTime="0" Value="{ThemeResource ButtonBackgroundDisabled}" />
</ObjectAnimationUsingKeyFrames>
<ObjectAnimationUsingKeyFrames Storyboard.TargetName="ContentPresenter" Storyboard.TargetProperty="BorderBrush">
<DiscreteObjectKeyFrame KeyTime="0" Value="{ThemeResource ButtonBorderBrushDisabled}" />
</ObjectAnimationUsingKeyFrames>
<ObjectAnimationUsingKeyFrames Storyboard.TargetName="ContentPresenter" Storyboard.TargetProperty="Foreground">
<DiscreteObjectKeyFrame KeyTime="0" Value="{ThemeResource ButtonForegroundDisabled}" />
</ObjectAnimationUsingKeyFrames>
</Storyboard>
<VisualState.Setters>
<!-- DisabledVisual Should be handled by the control, not the animated icon. -->
<Setter Target="ContentPresenter.(AnimatedIcon.State)" Value="Normal" />
</VisualState.Setters>
</VisualState>
</VisualStateGroup>
</VisualStateManager.VisualStateGroups>
</ContentPresenter>
</ControlTemplate>
</Setter.Value>
</Setter>
</Style>
</ResourceDictionary>

View File

@@ -0,0 +1,62 @@
// 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;
using Microsoft.CmdPal.Common.ExtensionGallery.Models;
namespace Microsoft.CmdPal.Common.UnitTests.ExtensionGallery.Models;
[TestClass]
public class GalleryExtensionEntryTests
{
private static readonly string[] ExpectedScreenshotUrls =
[
"https://example.com/screenshots/1.png",
"https://example.com/screenshots/2.png",
];
[TestMethod]
public void Deserialize_UsesPlainStringManifestFields()
{
const string json = """
{
"id": "sample-extension",
"title": "Sample title",
"description": "Sample description",
"readme": "README.md",
"screenshotUrls": [
"https://example.com/screenshots/1.png",
"https://example.com/screenshots/2.png"
],
"author": { "name": "Author" },
"installSources": [{ "type": "url", "uri": "https://example.com" }]
}
""";
var entry = JsonSerializer.Deserialize(json, GallerySerializationContext.Default.GalleryExtensionEntry);
Assert.IsNotNull(entry);
Assert.AreEqual("Sample title", entry.Title);
Assert.AreEqual("Sample description", entry.Description);
Assert.AreEqual("README.md", entry.Readme);
CollectionAssert.AreEqual(ExpectedScreenshotUrls, entry.ScreenshotUrls);
}
[TestMethod]
public void Deserialize_RejectsLegacyLocalizedObjectFields()
{
const string json = """
{
"id": "sample-extension",
"title": { "en": "English title" },
"description": "Sample description",
"author": { "name": "Author" },
"installSources": [{ "type": "url", "uri": "https://example.com" }]
}
""";
Assert.ThrowsException<JsonException>(
() => JsonSerializer.Deserialize(json, GallerySerializationContext.Default.GalleryExtensionEntry));
}
}

View File

@@ -0,0 +1,675 @@
// 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.IO;
using System.Linq;
using System.Net;
using System.Net.Http;
using System.Net.Http.Headers;
using System.Text.Json;
using System.Threading;
using System.Threading.Tasks;
using Microsoft.CmdPal.Common.ExtensionGallery.Models;
using Microsoft.CmdPal.Common.ExtensionGallery.Services;
using Microsoft.Extensions.Logging.Abstractions;
namespace Microsoft.CmdPal.Common.UnitTests.ExtensionGallery.Services;
[TestClass]
public class ExtensionGalleryServiceTests
{
private readonly List<string> _temporaryDirectories = [];
[TestMethod]
public async Task RefreshAsync_PreservesCachedExtensions_WhenFreshFetchFails()
{
var cacheDirectory = CreateTempDirectory("cache");
var feedUrl = "https://example.com/extensions.json";
var requestCount = 0;
var handler = new TestHttpMessageHandler(_ =>
{
requestCount++;
if (requestCount == 1)
{
return CreateHttpResponse(
HttpStatusCode.OK,
System.Text.Encoding.UTF8.GetBytes(CreateGalleryFeedJson("sample-extension", "Sample extension")),
"application/json",
"\"feed-v1\"",
maxAge: TimeSpan.FromHours(1));
}
throw new HttpRequestException("Could not reach an extension gallery.");
});
using var serviceHandle = CreateService(() => feedUrl, cacheDirectory, handler);
var initialResult = await serviceHandle.Service.FetchExtensionsAsync();
Assert.IsFalse(initialResult.FromCache);
Assert.AreEqual(1, initialResult.Extensions.Count);
Assert.AreEqual("Sample extension", initialResult.Extensions[0].Title);
var refreshedResult = await serviceHandle.Service.RefreshAsync();
Assert.IsTrue(refreshedResult.FromCache);
Assert.IsTrue(refreshedResult.UsedFallbackCache);
Assert.IsFalse(refreshedResult.HasError);
Assert.AreEqual(1, refreshedResult.Extensions.Count);
Assert.AreEqual("Sample extension", refreshedResult.Extensions[0].Title);
Assert.AreEqual(2, handler.CallCount);
}
[TestMethod]
public async Task FetchExtensionsAsync_UsesFreshCacheWithoutFallbackWarning()
{
var cacheDirectory = CreateTempDirectory("cache");
var feedUrl = "https://example.com/extensions.json";
var handler = new TestHttpMessageHandler(_ =>
CreateHttpResponse(
HttpStatusCode.OK,
System.Text.Encoding.UTF8.GetBytes(CreateGalleryFeedJson("sample-extension", "Sample extension")),
"application/json",
"\"feed-v1\"",
maxAge: TimeSpan.FromHours(1)));
using var serviceHandle = CreateService(() => feedUrl, cacheDirectory, handler);
var initialResult = await serviceHandle.Service.FetchExtensionsAsync();
var cachedResult = await serviceHandle.Service.FetchExtensionsAsync();
Assert.IsFalse(initialResult.FromCache);
Assert.IsFalse(initialResult.UsedFallbackCache);
Assert.IsTrue(cachedResult.FromCache);
Assert.IsFalse(cachedResult.UsedFallbackCache);
Assert.IsFalse(cachedResult.HasError);
Assert.AreEqual(1, cachedResult.Extensions.Count);
Assert.AreEqual(1, handler.CallCount);
}
[TestMethod]
public async Task FetchExtensionsAsync_UsesGalleryCacheSubdirectory()
{
var appCacheDirectory = CreateTempDirectory("app-cache");
var feedUrl = "https://example.com/extensions.json";
var handler = new TestHttpMessageHandler(_ =>
CreateHttpResponse(
HttpStatusCode.OK,
System.Text.Encoding.UTF8.GetBytes(CreateGalleryFeedJson("sample-extension", "Sample extension")),
"application/json",
"\"feed-v1\"",
maxAge: TimeSpan.FromHours(1)));
using var galleryHttpClient = CreateGalleryHttpClient(
Path.Combine(appCacheDirectory, ExtensionGalleryHttpClient.CacheDirectoryName),
handler);
var service = new ExtensionGalleryService(galleryHttpClient, NullLogger<ExtensionGalleryService>.Instance, new GalleryFeedUrlProvider(() => feedUrl));
var result = await service.FetchExtensionsAsync();
Assert.IsFalse(result.HasError);
Assert.AreEqual(1, result.Extensions.Count);
var cachedFeedFiles = Directory.GetFiles(Path.Combine(appCacheDirectory, "GalleryCache"), "extensions.json", SearchOption.AllDirectories);
Assert.AreEqual(1, cachedFeedFiles.Length);
}
[TestMethod]
public async Task FetchExtensionsAsync_ReturnsRateLimitedError_WhenGalleryRespondsWithTooManyRequestsAndNoCacheIsAvailable()
{
var cacheDirectory = CreateTempDirectory("cache");
var feedUrl = "https://example.com/extensions.json";
var handler = new TestHttpMessageHandler(_ => CreateHttpResponse(HttpStatusCode.TooManyRequests, content: null, contentType: null, etag: null, maxAge: null));
using var serviceHandle = CreateService(() => feedUrl, cacheDirectory, handler);
var result = await serviceHandle.Service.FetchExtensionsAsync();
Assert.IsTrue(result.HasError);
Assert.IsTrue(result.IsRateLimited);
Assert.IsNull(result.ErrorMessage);
Assert.AreEqual(0, result.Extensions.Count);
}
[TestMethod]
public async Task FetchExtensionsAsync_AcceptsDirectoryOverride_ForLocalCompoundFeed()
{
var feedDirectory = CreateTempDirectory("feed");
var cacheDirectory = CreateTempDirectory("cache");
WriteGalleryFeed(feedDirectory, "sample-extension", "Sample extension");
using var serviceHandle = CreateService(() => feedDirectory, cacheDirectory, innerHandler: null);
var result = await serviceHandle.Service.FetchExtensionsAsync();
Assert.IsFalse(result.HasError);
Assert.IsFalse(result.FromCache);
Assert.IsFalse(result.UsedFallbackCache);
Assert.AreEqual(1, result.Extensions.Count);
Assert.AreEqual("sample-extension", result.Extensions[0].Id);
}
[TestMethod]
public async Task FetchExtensionsAsync_LocalizesAbsoluteIconUrlFromWrappedFeed()
{
var feedDirectory = CreateTempDirectory("feed");
var cacheDirectory = CreateTempDirectory("cache");
const string iconUrl = "https://example.com/icon.png";
WriteGalleryFeed(feedDirectory, "sample-extension", "Sample extension", iconUrl: iconUrl);
var feedUrl = ToFeedUri(feedDirectory);
var handler = new TestHttpMessageHandler(request =>
{
Assert.AreEqual(iconUrl, request.RequestUri!.AbsoluteUri);
return CreateHttpResponse(
HttpStatusCode.OK,
[0x01, 0x02, 0x03],
"image/png",
"\"icon-v1\"",
maxAge: TimeSpan.FromHours(1));
});
using var serviceHandle = CreateService(() => feedUrl, cacheDirectory, handler);
var result = await serviceHandle.Service.FetchExtensionsAsync();
Assert.IsFalse(result.HasError);
Assert.IsFalse(result.FromCache);
Assert.IsFalse(result.UsedFallbackCache);
Assert.AreEqual(1, result.Extensions.Count);
Assert.AreEqual("Sample extension", result.Extensions[0].Title);
var localizedIconUri = new Uri(result.Extensions[0].IconUrl!);
Assert.IsTrue(localizedIconUri.IsFile);
Assert.IsTrue(File.Exists(localizedIconUri.LocalPath));
Assert.AreEqual(1, handler.CallCount);
}
[TestMethod]
public async Task FetchExtensionsAsync_ResolvesRelativeIconUrlFromWrappedFileFeed()
{
var feedDirectory = CreateTempDirectory("feed");
var cacheDirectory = CreateTempDirectory("cache");
var expectedIconPath = Path.Combine(feedDirectory, "extensions", "sample-extension", "icon.png");
WriteGalleryFeed(
feedDirectory,
"sample-extension",
"Sample extension",
iconUrl: "extensions/sample-extension/icon.png",
createLocalIcon: true);
var feedUrl = ToFeedUri(feedDirectory);
using var serviceHandle = CreateService(() => feedUrl, cacheDirectory, innerHandler: null);
var result = await serviceHandle.Service.FetchExtensionsAsync();
Assert.IsFalse(result.HasError);
Assert.AreEqual(1, result.Extensions.Count);
Assert.AreEqual(new Uri(expectedIconPath).AbsoluteUri, result.Extensions[0].IconUrl);
}
[TestMethod]
public async Task FetchExtensionsAsync_ResolvesRelativeScreenshotUrlsFromWrappedFileFeed()
{
var feedDirectory = CreateTempDirectory("feed");
var cacheDirectory = CreateTempDirectory("cache");
var expectedFirstScreenshotPath = Path.Combine(feedDirectory, "extensions", "sample-extension", "screenshots", "1.png");
var expectedSecondScreenshotPath = Path.Combine(feedDirectory, "extensions", "sample-extension", "screenshots", "2.png");
WriteGalleryFeed(
feedDirectory,
"sample-extension",
"Sample extension",
screenshotUrls:
[
"extensions/sample-extension/screenshots/1.png",
"extensions/sample-extension/screenshots/2.png",
],
createLocalScreenshots: true);
var feedUrl = ToFeedUri(feedDirectory);
using var serviceHandle = CreateService(() => feedUrl, cacheDirectory, innerHandler: null);
var result = await serviceHandle.Service.FetchExtensionsAsync();
Assert.IsFalse(result.HasError);
Assert.AreEqual(1, result.Extensions.Count);
CollectionAssert.AreEqual(
new[]
{
new Uri(expectedFirstScreenshotPath).AbsoluteUri,
new Uri(expectedSecondScreenshotPath).AbsoluteUri,
},
result.Extensions[0].ScreenshotUrls);
}
[TestMethod]
public async Task FetchExtensionsAsync_ParsesWrappedGalleryFormat_WithInlineExtensions()
{
var feedDirectory = CreateTempDirectory("feed");
var cacheDirectory = CreateTempDirectory("cache");
var iconDirectory = Path.Combine(feedDirectory, "extensions", "test-extension");
Directory.CreateDirectory(iconDirectory);
var expectedIconPath = Path.Combine(iconDirectory, "icon.png");
File.WriteAllBytes(expectedIconPath, [0x01, 0x02, 0x03]);
var wrappedJson = """
{
"version": "1.0",
"extensions": [
{
"id": "test-extension",
"title": "Test Extension",
"description": "A test extension",
"author": { "name": "Test Author" },
"tags": ["test"],
"iconUrl": "extensions/test-extension/icon.png",
"installSources": []
}
]
}
""";
File.WriteAllText(Path.Combine(feedDirectory, "extensions.json"), wrappedJson);
var feedUrl = ToFeedUri(feedDirectory);
using var serviceHandle = CreateService(() => feedUrl, cacheDirectory, innerHandler: null);
var result = await serviceHandle.Service.FetchExtensionsAsync();
Assert.IsFalse(result.HasError);
Assert.IsFalse(result.UsedFallbackCache);
Assert.AreEqual(1, result.Extensions.Count);
Assert.AreEqual("test-extension", result.Extensions[0].Id);
Assert.AreEqual("Test Extension", result.Extensions[0].Title);
Assert.AreEqual("A test extension", result.Extensions[0].Description);
Assert.AreEqual("Test Author", result.Extensions[0].Author.Name);
Assert.AreEqual(new Uri(expectedIconPath).AbsoluteUri, result.Extensions[0].IconUrl);
}
[TestMethod]
public async Task FetchExtensionsAsync_ReusesFreshHttpIconCache_WithoutAnotherNetworkCall()
{
var cacheDirectory = CreateTempDirectory("cache");
var feedUrl = "https://example.com/extensions.json";
var iconUrl = "https://example.com/icons/sample.png";
var handler = new TestHttpMessageHandler(request =>
{
if (request.RequestUri!.AbsoluteUri.Equals(feedUrl, StringComparison.OrdinalIgnoreCase))
{
return CreateHttpResponse(
HttpStatusCode.OK,
System.Text.Encoding.UTF8.GetBytes(CreateGalleryFeedJson("sample-extension", "Sample extension", iconUrl)),
"application/json",
"\"feed-v1\"",
maxAge: TimeSpan.FromHours(1));
}
return CreateHttpResponse(
HttpStatusCode.OK,
[0x01, 0x02, 0x03],
"image/png",
"\"icon-v1\"",
maxAge: TimeSpan.FromHours(1));
});
using var serviceHandle = CreateService(() => feedUrl, cacheDirectory, handler);
var firstResult = await serviceHandle.Service.FetchExtensionsAsync();
await Task.Delay(50);
var secondResult = await serviceHandle.Service.FetchExtensionsAsync();
var firstCachedIconUri = new Uri(firstResult.Extensions[0].IconUrl!);
var secondCachedIconUri = new Uri(secondResult.Extensions[0].IconUrl!);
Assert.AreEqual(firstCachedIconUri, secondCachedIconUri);
Assert.AreEqual(2, handler.CallCount);
Assert.IsTrue(firstCachedIconUri.IsFile);
Assert.IsTrue(File.Exists(firstCachedIconUri.LocalPath));
}
[TestMethod]
public async Task FetchExtensionsAsync_RevalidatesStaleHttpIconCache_WithEtag()
{
var cacheDirectory = CreateTempDirectory("cache");
var feedUrl = "https://example.com/extensions.json";
var iconUrl = "https://example.com/icons/sample.png";
var iconRequestCount = 0;
var handler = new TestHttpMessageHandler(request =>
{
if (request.RequestUri!.AbsoluteUri.Equals(feedUrl, StringComparison.OrdinalIgnoreCase))
{
return CreateHttpResponse(
HttpStatusCode.OK,
System.Text.Encoding.UTF8.GetBytes(CreateGalleryFeedJson("sample-extension", "Sample extension", iconUrl)),
"application/json",
"\"feed-v1\"",
maxAge: TimeSpan.FromHours(1));
}
iconRequestCount++;
if (iconRequestCount == 1)
{
return CreateHttpResponse(
HttpStatusCode.OK,
[0x01, 0x02, 0x03],
"image/png",
"\"icon-v1\"",
maxAge: TimeSpan.Zero);
}
Assert.AreEqual("\"icon-v1\"", request.Headers.IfNoneMatch.FirstOrDefault()?.Tag);
return CreateHttpResponse(HttpStatusCode.NotModified, content: null, contentType: null, etag: "\"icon-v1\"", maxAge: TimeSpan.Zero);
});
using var serviceHandle = CreateService(() => feedUrl, cacheDirectory, handler);
var firstResult = await serviceHandle.Service.FetchExtensionsAsync();
await Task.Delay(50);
var secondResult = await serviceHandle.Service.FetchExtensionsAsync();
var firstCachedIconUri = new Uri(firstResult.Extensions[0].IconUrl!);
var secondCachedIconUri = new Uri(secondResult.Extensions[0].IconUrl!);
Assert.AreEqual(firstCachedIconUri, secondCachedIconUri);
Assert.AreEqual(3, handler.CallCount);
}
[TestMethod]
public async Task RefreshAsync_PrunesObsoleteCachedIcons_AfterSuccessfulRefresh()
{
var cacheDirectory = CreateTempDirectory("cache");
var feedUrl = "https://example.com/extensions.json";
var retainedIconUrl = "https://example.com/icons/current.png";
var obsoleteIconUrl = "https://example.com/icons/obsolete.png";
var currentFeedJson = CreateGalleryFeedJson(
new GalleryExtensionEntry
{
Id = "current-extension",
Title = "Current extension",
Description = "Current extension",
Author = new GalleryAuthor { Name = "Sample author" },
IconUrl = retainedIconUrl,
InstallSources = [],
},
new GalleryExtensionEntry
{
Id = "obsolete-extension",
Title = "Obsolete extension",
Description = "Obsolete extension",
Author = new GalleryAuthor { Name = "Sample author" },
IconUrl = obsoleteIconUrl,
InstallSources = [],
});
var handler = new TestHttpMessageHandler(request =>
{
var requestUri = request.RequestUri!.AbsoluteUri;
if (requestUri.Equals(feedUrl, StringComparison.OrdinalIgnoreCase))
{
return CreateHttpResponse(
HttpStatusCode.OK,
System.Text.Encoding.UTF8.GetBytes(currentFeedJson),
"application/json",
"\"feed-v1\"",
maxAge: TimeSpan.FromHours(1));
}
if (requestUri.Equals(retainedIconUrl, StringComparison.OrdinalIgnoreCase)
|| requestUri.Equals(obsoleteIconUrl, StringComparison.OrdinalIgnoreCase))
{
return CreateHttpResponse(
HttpStatusCode.OK,
[0x01, 0x02, 0x03],
"image/png",
"\"icon-v1\"",
maxAge: TimeSpan.FromHours(1));
}
throw new InvalidOperationException($"Unexpected request URI '{requestUri}'.");
});
using var serviceHandle = CreateService(() => feedUrl, cacheDirectory, handler);
var initialResult = await serviceHandle.Service.FetchExtensionsAsync();
Assert.IsFalse(initialResult.HasError);
var retainedCachedIconUri = new Uri(initialResult.Extensions.Single(entry => entry.Id == "current-extension").IconUrl!);
var obsoleteCachedIconUri = new Uri(initialResult.Extensions.Single(entry => entry.Id == "obsolete-extension").IconUrl!);
Assert.IsTrue(File.Exists(retainedCachedIconUri.LocalPath));
Assert.IsTrue(File.Exists(obsoleteCachedIconUri.LocalPath));
currentFeedJson = CreateGalleryFeedJson(
new GalleryExtensionEntry
{
Id = "current-extension",
Title = "Current extension",
Description = "Current extension",
Author = new GalleryAuthor { Name = "Sample author" },
IconUrl = retainedIconUrl,
InstallSources = [],
});
var refreshedResult = await serviceHandle.Service.RefreshAsync();
Assert.IsFalse(refreshedResult.HasError);
Assert.IsFalse(refreshedResult.UsedFallbackCache);
Assert.AreEqual(1, refreshedResult.Extensions.Count);
Assert.IsTrue(File.Exists(retainedCachedIconUri.LocalPath));
Assert.IsFalse(File.Exists(obsoleteCachedIconUri.LocalPath));
Assert.IsFalse(Directory.Exists(Path.GetDirectoryName(obsoleteCachedIconUri.LocalPath)!));
}
[TestMethod]
public async Task RefreshAsync_DoesNotPruneCachedIcons_WhenRefreshFallsBackToCache()
{
var cacheDirectory = CreateTempDirectory("cache");
var feedUrl = "https://example.com/extensions.json";
var iconUrl = "https://example.com/icons/sample.png";
var requestCount = 0;
var handler = new TestHttpMessageHandler(request =>
{
var requestUri = request.RequestUri!.AbsoluteUri;
if (requestUri.Equals(feedUrl, StringComparison.OrdinalIgnoreCase))
{
requestCount++;
if (requestCount == 1)
{
return CreateHttpResponse(
HttpStatusCode.OK,
System.Text.Encoding.UTF8.GetBytes(CreateGalleryFeedJson("sample-extension", "Sample extension", iconUrl)),
"application/json",
"\"feed-v1\"",
maxAge: TimeSpan.FromHours(1));
}
throw new HttpRequestException("Could not reach an extension gallery.");
}
if (requestUri.Equals(iconUrl, StringComparison.OrdinalIgnoreCase))
{
return CreateHttpResponse(
HttpStatusCode.OK,
[0x01, 0x02, 0x03],
"image/png",
"\"icon-v1\"",
maxAge: TimeSpan.FromHours(1));
}
throw new InvalidOperationException($"Unexpected request URI '{requestUri}'.");
});
using var serviceHandle = CreateService(() => feedUrl, cacheDirectory, handler);
var initialResult = await serviceHandle.Service.FetchExtensionsAsync();
Assert.IsFalse(initialResult.HasError);
var cachedIconUri = new Uri(initialResult.Extensions[0].IconUrl!);
Assert.IsTrue(File.Exists(cachedIconUri.LocalPath));
var refreshedResult = await serviceHandle.Service.RefreshAsync();
Assert.IsFalse(refreshedResult.HasError);
Assert.IsTrue(refreshedResult.UsedFallbackCache);
Assert.IsTrue(File.Exists(cachedIconUri.LocalPath));
}
[TestCleanup]
public void Cleanup()
{
foreach (var directory in _temporaryDirectories)
{
if (Directory.Exists(directory))
{
Directory.Delete(directory, recursive: true);
}
}
}
private string CreateTempDirectory(string name)
{
var directory = Path.Combine(Path.GetTempPath(), "CmdPal.ExtensionGalleryServiceTests", name, Guid.NewGuid().ToString("N"));
Directory.CreateDirectory(directory);
_temporaryDirectories.Add(directory);
return directory;
}
private static string ToFeedUri(string directory)
{
return new Uri(Path.Combine(directory, "extensions.json")).AbsoluteUri;
}
private static TestServiceHandle CreateService(GalleryFeedUrlProvider feedUrlProvider, string cacheDirectory, HttpMessageHandler? innerHandler)
{
var galleryHttpClient = CreateGalleryHttpClient(cacheDirectory, innerHandler);
var service = new ExtensionGalleryService(galleryHttpClient, NullLogger<ExtensionGalleryService>.Instance, feedUrlProvider);
return new TestServiceHandle(service, galleryHttpClient);
}
private static ExtensionGalleryHttpClient CreateGalleryHttpClient(string cacheDirectory, HttpMessageHandler? innerHandler)
{
return new ExtensionGalleryHttpClient(cacheDirectory, innerHandler, NullLogger<ExtensionGalleryHttpClient>.Instance);
}
private static void WriteGalleryFeed(
string rootDirectory,
string extensionId,
string title,
string? iconUrl = null,
bool createLocalIcon = false,
IReadOnlyList<string>? screenshotUrls = null,
bool createLocalScreenshots = false)
{
var extensionDirectory = Path.Combine(rootDirectory, "extensions", extensionId);
Directory.CreateDirectory(extensionDirectory);
if (createLocalIcon)
{
File.WriteAllBytes(Path.Combine(extensionDirectory, "icon.png"), [0x01, 0x02, 0x03]);
}
if (createLocalScreenshots && screenshotUrls is not null)
{
for (var i = 0; i < screenshotUrls.Count; i++)
{
var relativePath = screenshotUrls[i].Replace('/', Path.DirectorySeparatorChar);
var screenshotPath = Path.Combine(rootDirectory, relativePath);
Directory.CreateDirectory(Path.GetDirectoryName(screenshotPath)!);
File.WriteAllBytes(screenshotPath, [0x01, 0x02, 0x03]);
}
}
var entry = new GalleryExtensionEntry
{
Id = extensionId,
Title = title,
Description = "Sample description",
Author = new GalleryAuthor { Name = "Sample author" },
IconUrl = iconUrl,
ScreenshotUrls = screenshotUrls?.ToList() ?? [],
InstallSources = [],
};
File.WriteAllText(
Path.Combine(rootDirectory, "extensions.json"),
JsonSerializer.Serialize(
new GalleryRemoteIndex
{
Extensions =
[
entry,
],
},
GallerySerializationContext.Default.GalleryRemoteIndex));
}
private static string CreateGalleryFeedJson(string extensionId, string title, string? iconUrl = null)
{
return CreateGalleryFeedJson(
new GalleryExtensionEntry
{
Id = extensionId,
Title = title,
Description = "Sample description",
Author = new GalleryAuthor { Name = "Sample author" },
IconUrl = iconUrl,
InstallSources = [],
});
}
private static string CreateGalleryFeedJson(params GalleryExtensionEntry[] entries)
{
return JsonSerializer.Serialize(
new GalleryRemoteIndex
{
Extensions = [.. entries],
},
GallerySerializationContext.Default.GalleryRemoteIndex);
}
private static HttpResponseMessage CreateHttpResponse(
HttpStatusCode statusCode,
byte[]? content,
string? contentType,
string? etag,
TimeSpan? maxAge)
{
var response = new HttpResponseMessage(statusCode);
if (content is not null)
{
response.Content = new ByteArrayContent(content);
if (!string.IsNullOrWhiteSpace(contentType))
{
response.Content.Headers.ContentType = new MediaTypeHeaderValue(contentType);
}
}
if (!string.IsNullOrWhiteSpace(etag))
{
response.Headers.ETag = new EntityTagHeaderValue(etag);
}
if (maxAge is TimeSpan timeToLive)
{
response.Headers.CacheControl = new CacheControlHeaderValue { MaxAge = timeToLive };
}
return response;
}
private sealed class TestHttpMessageHandler(Func<HttpRequestMessage, HttpResponseMessage> responder) : HttpMessageHandler
{
public int CallCount { get; private set; }
protected override Task<HttpResponseMessage> SendAsync(HttpRequestMessage request, CancellationToken cancellationToken)
{
CallCount++;
return Task.FromResult(responder(request));
}
}
private sealed class TestServiceHandle(ExtensionGalleryService service, ExtensionGalleryHttpClient galleryHttpClient) : IDisposable
{
public ExtensionGalleryService Service { get; } = service;
public void Dispose()
{
galleryHttpClient.Dispose();
}
}
}

View File

@@ -0,0 +1,105 @@
// 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.CmdPal.Common.WinGet.Services;
namespace Microsoft.CmdPal.Common.UnitTests.WinGet.Services;
[TestClass]
public class WinGetOperationTrackerServiceTests
{
[TestMethod]
public void StartOperation_AddsOperationAndRaisesStartedEvent()
{
var service = new WinGetOperationTrackerService();
WinGetPackageOperation? raisedOperation = null;
service.OperationStarted += (_, e) => raisedOperation = e.Operation;
var operation = service.StartOperation("Microsoft.PowerToys", "PowerToys", WinGetPackageOperationKind.Install);
Assert.AreEqual(operation, raisedOperation);
Assert.AreEqual(1, service.Operations.Count);
Assert.AreEqual(operation, service.Operations[0]);
Assert.AreEqual(WinGetPackageOperationState.Queued, operation.State);
Assert.IsTrue(operation.IsIndeterminate);
}
[TestMethod]
public void UpdateOperation_UpdatesSnapshotAndRaisesUpdatedEvent()
{
var service = new WinGetOperationTrackerService();
var operation = service.StartOperation("Microsoft.PowerToys", "PowerToys", WinGetPackageOperationKind.Install);
WinGetPackageOperation? raisedOperation = null;
service.OperationUpdated += (_, e) => raisedOperation = e.Operation;
var updated = service.UpdateOperation(
operation.OperationId,
WinGetPackageOperationState.Downloading,
isIndeterminate: false,
progressPercent: 42,
bytesDownloaded: 420,
bytesRequired: 1000);
Assert.IsNotNull(updated);
Assert.AreEqual(updated, raisedOperation);
Assert.AreEqual(WinGetPackageOperationState.Downloading, updated.State);
Assert.AreEqual(42u, updated.ProgressPercent);
Assert.AreEqual(420UL, updated.BytesDownloaded);
Assert.AreEqual(1000UL, updated.BytesRequired);
Assert.IsFalse(updated.IsIndeterminate);
}
[TestMethod]
public void CompleteOperation_MarksOperationCompletedAndRaisesCompletedEvent()
{
var service = new WinGetOperationTrackerService();
var operation = service.StartOperation("Microsoft.PowerToys", "PowerToys", WinGetPackageOperationKind.Install);
WinGetPackageOperation? raisedOperation = null;
service.OperationCompleted += (_, e) => raisedOperation = e.Operation;
var completed = service.CompleteOperation(operation.OperationId, WinGetPackageOperationState.Failed, "No catalog");
Assert.IsNotNull(completed);
Assert.AreEqual(completed, raisedOperation);
Assert.AreEqual(WinGetPackageOperationState.Failed, completed.State);
Assert.AreEqual("No catalog", completed.ErrorMessage);
Assert.IsTrue(completed.IsCompleted);
Assert.IsNotNull(completed.CompletedAt);
}
[TestMethod]
public void GetLatestOperation_IsCaseInsensitive()
{
var service = new WinGetOperationTrackerService();
var operation = service.StartOperation("Microsoft.PowerToys", "PowerToys", WinGetPackageOperationKind.Install);
var latest = service.GetLatestOperation("microsoft.powertoys");
Assert.AreEqual(operation, latest);
}
[TestMethod]
public void TryCancelOperation_InvokesRegisteredCallbackAndClearsCancelableState()
{
var service = new WinGetOperationTrackerService();
var operation = service.StartOperation("Microsoft.PowerToys", "PowerToys", WinGetPackageOperationKind.Install);
var cancelInvoked = false;
service.RegisterCancellationHandler(operation.OperationId, () => cancelInvoked = true);
var registeredOperation = service.GetLatestOperation(operation.PackageId);
Assert.IsNotNull(registeredOperation);
Assert.IsTrue(registeredOperation!.CanCancel);
var wasCanceled = service.TryCancelOperation(operation.OperationId);
Assert.IsTrue(wasCanceled);
Assert.IsTrue(cancelInvoked);
var latest = service.GetLatestOperation(operation.PackageId);
Assert.IsNotNull(latest);
Assert.IsFalse(latest!.CanCancel);
}
}

View File

@@ -0,0 +1,51 @@
// 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.Threading.Tasks;
using Microsoft.CmdPal.Common.WinGet.Services;
namespace Microsoft.CmdPal.Common.UnitTests.WinGet.Services;
[TestClass]
public class WinGetPackageManagerServiceTests
{
[TestMethod]
public async Task SearchPackagesAsync_ReturnsUnavailableResult_WhenFactoryIsUnavailable()
{
var service = new WinGetPackageManagerService(() => null);
var result = await service.SearchPackagesAsync("PowerToys");
Assert.IsFalse(service.State.IsAvailable);
Assert.IsTrue(result.IsUnavailable);
Assert.IsFalse(result.IsSuccess);
Assert.IsNull(result.Value);
Assert.AreEqual(service.State.Message, result.ErrorMessage);
}
[TestMethod]
public async Task SearchCommandPaletteExtensionsAsync_ReturnsUnavailableResult_WhenFactoryIsUnavailable()
{
var service = new WinGetPackageManagerService(() => null);
var result = await service.SearchCommandPaletteExtensionsAsync();
Assert.IsFalse(service.State.IsAvailable);
Assert.IsTrue(result.IsUnavailable);
Assert.IsFalse(result.IsSuccess);
Assert.IsNull(result.Value);
Assert.AreEqual(service.State.Message, result.ErrorMessage);
}
[TestMethod]
public async Task RefreshCatalogsAsync_ReturnsFalse_WhenFactoryIsUnavailable()
{
var service = new WinGetPackageManagerService(() => null);
var refreshed = await service.RefreshCatalogsAsync();
Assert.IsFalse(refreshed);
Assert.IsFalse(service.State.IsAvailable);
}
}

View File

@@ -0,0 +1,540 @@
// 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.Common.ExtensionGallery.Models;
using Microsoft.CmdPal.Common.WinGet.Models;
using Microsoft.CmdPal.Common.WinGet.Services;
using Microsoft.CmdPal.UI.ViewModels.Gallery;
using Microsoft.Extensions.Logging.Abstractions;
using Microsoft.VisualStudio.TestTools.UnitTesting;
using Moq;
namespace Microsoft.CmdPal.UI.ViewModels.UnitTests;
[TestClass]
public class ExtensionGalleryItemViewModelTests
{
private static readonly Uri ExpectedIconPlaceholderUri = new Uri("ms-appx:///Assets/Icons/ExtensionIconPlaceholder.png");
[TestMethod]
public void Constructor_UsesPlaceholderIcon_WhenIconIsMissing()
{
var entry = CreateEntry(iconUrl: null);
var viewModel = CreateViewModel(entry);
Assert.AreEqual(ExpectedIconPlaceholderUri, viewModel.IconUri);
}
[TestMethod]
public void Constructor_UsesPlaceholderIcon_WhenIconUriIsInvalid()
{
var entry = CreateEntry(iconUrl: "iconUrl.png");
var viewModel = CreateViewModel(entry);
Assert.AreEqual(ExpectedIconPlaceholderUri, viewModel.IconUri);
}
[TestMethod]
public void Constructor_UsesAbsoluteIconUri_WhenIconUriIsValid()
{
var expected = new Uri("https://example.com/iconUrl.png");
var entry = CreateEntry(iconUrl: expected.AbsoluteUri);
var viewModel = CreateViewModel(entry);
Assert.AreEqual(expected, viewModel.IconUri);
}
[TestMethod]
public void Constructor_UsesFallbackDisplayValues_WhenLocalizedMetadataMissing()
{
var entry = new GalleryExtensionEntry
{
Id = "fallback-extension",
Title = string.Empty,
Description = string.Empty,
Author = new GalleryAuthor { Name = string.Empty },
InstallSources = new List<GalleryInstallSource>(),
};
var viewModel = CreateViewModel(entry);
Assert.AreEqual("fallback-extension", viewModel.DisplayTitle);
Assert.AreEqual("No description available.", viewModel.DisplayDescription);
Assert.AreEqual("Unknown author", viewModel.DisplayAuthorName);
Assert.IsTrue(viewModel.ShowUnknownSourceIndicator);
}
[TestMethod]
public void Constructor_NormalizesAndCollectsSources_Generically()
{
var entry = new GalleryExtensionEntry
{
Id = "source-extension",
Title = "Source extension",
Description = "Source extension description",
Author = new GalleryAuthor { Name = "Author" },
InstallSources =
[
new GalleryInstallSource { Type = "winget", Id = "Contoso.Extension" },
new GalleryInstallSource { Type = "msstore", Id = "9TEST1234ABC" },
new GalleryInstallSource { Type = "url", Uri = "https://github.com/contoso/extension" },
new GalleryInstallSource { Type = "customSourceType", Uri = "https://example.com/custom" },
],
};
var viewModel = CreateViewModel(entry);
Assert.IsTrue(viewModel.HasWinGetSource);
Assert.IsTrue(viewModel.HasStoreSource);
Assert.IsTrue(viewModel.HasGitHubSource);
Assert.IsTrue(viewModel.HasUnknownSource);
Assert.IsTrue(viewModel.Sources.Count >= 4);
}
[TestMethod]
public void Constructor_EnablesCopyCommand_WhenWinGetIdIsAvailable()
{
var entry = new GalleryExtensionEntry
{
Id = "copy-command-extension",
Title = "Copy command extension",
Description = "Copy command extension description",
Author = new GalleryAuthor { Name = "Author" },
InstallSources =
[
new GalleryInstallSource { Type = "winget", Id = "Contoso.Extension" },
],
};
var viewModel = CreateViewModel(entry);
Assert.IsTrue(viewModel.CanCopyWinGetInstallCommand);
Assert.AreEqual("winget install --id Contoso.Extension", viewModel.WinGetInstallCommand);
}
[TestMethod]
public void Constructor_DisablesCopyCommand_WhenWinGetIdIsMissing()
{
var entry = new GalleryExtensionEntry
{
Id = "copy-command-no-id-extension",
Title = "Copy command no id extension",
Description = "Copy command no id extension description",
Author = new GalleryAuthor { Name = "Author" },
InstallSources =
[
new GalleryInstallSource { Type = "winget", Id = string.Empty },
],
};
var viewModel = CreateViewModel(entry);
Assert.IsFalse(viewModel.CanCopyWinGetInstallCommand);
Assert.AreEqual(string.Empty, viewModel.WinGetInstallCommand);
}
[TestMethod]
public void Constructor_ExposesManifestTags_WhenProvided()
{
var entry = new GalleryExtensionEntry
{
Id = "tagged-extension",
Title = "Tagged extension",
Description = "Tagged extension description",
Author = new GalleryAuthor { Name = "Author" },
Tags =
[
"developer tools",
"productivity",
],
InstallSources = [],
};
var viewModel = CreateViewModel(entry);
Assert.IsTrue(viewModel.HasTags);
Assert.AreEqual("developer tools, productivity", viewModel.TagsText);
}
[TestMethod]
public void Constructor_ExposesScreenshots_WhenUrisAreValid()
{
var firstScreenshotUri = "https://example.com/screenshots/1.png";
var secondScreenshotUri = "file:///C:/extensions/sample/screenshots/2.png";
var entry = new GalleryExtensionEntry
{
Id = "screenshots-extension",
Title = "Screenshots extension",
Description = "Screenshots extension description",
Author = new GalleryAuthor { Name = "Author" },
ScreenshotUrls =
[
firstScreenshotUri,
secondScreenshotUri,
],
InstallSources = [],
};
var viewModel = CreateViewModel(entry);
Assert.IsTrue(viewModel.HasScreenshots);
Assert.AreEqual(2, viewModel.Screenshots.Count);
Assert.AreEqual(new Uri(firstScreenshotUri), viewModel.Screenshots[0].Uri);
Assert.AreEqual("Screenshot 1", viewModel.Screenshots[0].DisplayName);
Assert.AreEqual(new Uri(secondScreenshotUri), viewModel.Screenshots[1].Uri);
Assert.AreEqual("Screenshot 2", viewModel.Screenshots[1].DisplayName);
}
[TestMethod]
public void Constructor_IgnoresInvalidScreenshotUris()
{
var entry = new GalleryExtensionEntry
{
Id = "invalid-screenshots-extension",
Title = "Invalid screenshots extension",
Description = "Invalid screenshots extension description",
Author = new GalleryAuthor { Name = "Author" },
ScreenshotUrls =
[
"not-a-uri",
"relative/path.png",
"https://example.com/screenshots/valid.png",
"https://example.com/screenshots/valid.png",
],
InstallSources = [],
};
var viewModel = CreateViewModel(entry);
Assert.IsTrue(viewModel.HasScreenshots);
Assert.AreEqual(1, viewModel.Screenshots.Count);
Assert.AreEqual(new Uri("https://example.com/screenshots/valid.png"), viewModel.Screenshots[0].Uri);
}
[TestMethod]
public void ApplyWinGetPackageInfo_UpdatesStatus_WhenDetailsAreMissing()
{
var entry = new GalleryExtensionEntry
{
Id = "winget-status-extension",
Title = "WinGet status extension",
Description = "WinGet status extension description",
Author = new GalleryAuthor { Name = "Author" },
InstallSources =
[
new GalleryInstallSource { Type = "winget", Id = "Contoso.Extension" },
],
};
var viewModel = CreateViewModel(entry);
viewModel.ApplyWinGetPackageInfo(
new WinGetPackageInfo(
new WinGetPackageStatus(
IsInstalled: true,
IsInstalledStateKnown: true,
IsUpdateAvailable: false,
IsUpdateStateKnown: true),
Details: null));
Assert.IsTrue(viewModel.IsInstalled);
Assert.IsTrue(viewModel.IsInstalledStateKnown);
Assert.IsFalse(viewModel.IsUpdateAvailable);
Assert.IsTrue(viewModel.IsUpdateStateKnown);
Assert.IsFalse(viewModel.HasSourceMetadataDetails);
}
[TestMethod]
public void ShowWinGetStatusDetails_IsFalse_WhenWinGetStatusDuplicatesInstallStatus()
{
var entry = new GalleryExtensionEntry
{
Id = "winget-status-extension",
Title = "WinGet status extension",
Description = "WinGet status extension description",
Author = new GalleryAuthor { Name = "Author" },
InstallSources =
[
new GalleryInstallSource { Type = "winget", Id = "Contoso.Extension" },
],
};
var viewModel = CreateViewModel(entry);
viewModel.ApplyWinGetPackageInfo(
new WinGetPackageInfo(
new WinGetPackageStatus(
IsInstalled: false,
IsInstalledStateKnown: true,
IsUpdateAvailable: false,
IsUpdateStateKnown: true),
Details: null));
Assert.AreEqual("Not installed", viewModel.InstallStatusText);
Assert.AreEqual("Not installed.", viewModel.WinGetStatusText);
Assert.IsFalse(viewModel.ShowWinGetStatusDetails);
}
[TestMethod]
public void ApplyWinGetPackageInfo_AttachesSourceDetails_WhenMetadataIsAvailable()
{
var entry = new GalleryExtensionEntry
{
Id = "winget-details-extension",
Title = "WinGet details extension",
Description = "WinGet details extension description",
Author = new GalleryAuthor { Name = "Author" },
InstallSources =
[
new GalleryInstallSource { Type = "winget", Id = "Contoso.Extension" },
],
};
var details = new WinGetPackageDetails(
Name: "Contoso Extension",
Version: "1.2.3",
Summary: "Summary",
Description: "Description",
Publisher: "Contoso",
PublisherUrl: "https://contoso.example/publisher",
PublisherSupportUrl: "https://contoso.example/support",
Author: "Contoso Team",
License: "MIT",
LicenseUrl: "https://contoso.example/license",
PackageUrl: "https://contoso.example/package",
ReleaseNotes: "Release notes",
ReleaseNotesUrl: "https://contoso.example/release-notes",
IconUrl: "https://contoso.example/iconUrl.png",
DocumentationLinks:
[
new WinGetNamedLink("Docs", "https://contoso.example/docs"),
],
Tags:
[
"utility",
"productivity",
]);
var viewModel = CreateViewModel(entry);
viewModel.ApplyWinGetPackageInfo(
new WinGetPackageInfo(
new WinGetPackageStatus(
IsInstalled: true,
IsInstalledStateKnown: true,
IsUpdateAvailable: true,
IsUpdateStateKnown: true),
details));
Assert.IsTrue(viewModel.HasSourceMetadataDetails);
GallerySourceInfo? wingetSource = null;
for (var i = 0; i < viewModel.Sources.Count; i++)
{
if (string.Equals(viewModel.Sources[i].Kind, "winget", StringComparison.OrdinalIgnoreCase))
{
wingetSource = viewModel.Sources[i];
break;
}
}
Assert.IsNotNull(wingetSource);
var sourceDetails = wingetSource!.Details;
Assert.IsNotNull(sourceDetails);
Assert.AreEqual("Summary", sourceDetails.Summary);
Assert.AreEqual("1.2.3", sourceDetails.Version);
Assert.IsTrue(sourceDetails.Items.Count > 0);
Assert.IsTrue(sourceDetails.Tags.Count > 0);
}
[TestMethod]
public void ApplyTrackedOperation_ShowsTileProgress_WhenDownloading()
{
var entry = new GalleryExtensionEntry
{
Id = "winget-progress-extension",
Title = "WinGet progress extension",
Description = "WinGet progress extension description",
Author = new GalleryAuthor { Name = "Author" },
InstallSources =
[
new GalleryInstallSource { Type = "winget", Id = "Contoso.Extension" },
],
};
var viewModel = CreateViewModel(entry);
viewModel.ApplyTrackedOperation(new WinGetPackageOperation(
OperationId: Guid.NewGuid(),
PackageId: "Contoso.Extension",
PackageName: "Contoso Extension",
Kind: WinGetPackageOperationKind.Install,
State: WinGetPackageOperationState.Downloading,
CanCancel: true,
IsIndeterminate: false,
ProgressPercent: 42,
BytesDownloaded: 420,
BytesRequired: 1000,
ErrorMessage: null,
StartedAt: DateTimeOffset.UtcNow,
UpdatedAt: DateTimeOffset.UtcNow,
CompletedAt: null));
Assert.IsTrue(viewModel.IsWinGetActionInProgress);
Assert.IsTrue(viewModel.ShowWinGetActionIndicator);
Assert.IsTrue(viewModel.ShowWinGetActionStatus);
Assert.IsTrue(viewModel.CanCancelWinGetAction);
Assert.IsTrue(viewModel.ShowCancelWinGetActionButton);
Assert.IsFalse(viewModel.IsWinGetActionIndeterminate);
Assert.AreEqual(42d, viewModel.WinGetActionProgressValue);
StringAssert.Contains(viewModel.WinGetActionMessage!, "42%");
}
[TestMethod]
public void CancelWinGetActionCommand_RequestsCancellation_FromTracker()
{
var entry = new GalleryExtensionEntry
{
Id = "winget-cancel-extension",
Title = "WinGet cancel extension",
Description = "WinGet cancel extension description",
Author = new GalleryAuthor { Name = "Author" },
InstallSources =
[
new GalleryInstallSource { Type = "winget", Id = "Contoso.Extension" },
],
};
var operation = new WinGetPackageOperation(
OperationId: Guid.NewGuid(),
PackageId: "Contoso.Extension",
PackageName: "Contoso Extension",
Kind: WinGetPackageOperationKind.Install,
State: WinGetPackageOperationState.Downloading,
CanCancel: true,
IsIndeterminate: false,
ProgressPercent: 42,
BytesDownloaded: 420,
BytesRequired: 1000,
ErrorMessage: null,
StartedAt: DateTimeOffset.UtcNow,
UpdatedAt: DateTimeOffset.UtcNow,
CompletedAt: null);
var tracker = new Mock<IWinGetOperationTrackerService>();
tracker.Setup(t => t.GetLatestOperation("Contoso.Extension")).Returns(operation);
tracker.Setup(t => t.TryCancelOperation(operation.OperationId)).Returns(true);
var viewModel = CreateViewModel(entry, winGetOperationTrackerService: tracker.Object);
viewModel.ApplyTrackedOperation(operation);
Assert.IsTrue(viewModel.CancelWinGetActionCommand.CanExecute(null));
viewModel.CancelWinGetActionCommand.Execute(null);
tracker.Verify(t => t.TryCancelOperation(operation.OperationId), Times.Once);
}
[TestMethod]
public void ShowInstallButton_IsTrue_WhenNotInstalled()
{
var entry = CreateEntry(iconUrl: null);
var viewModel = CreateViewModel(entry);
Assert.IsTrue(viewModel.ShowInstallButton);
Assert.IsFalse(viewModel.ShowInstalledBadge);
}
[TestMethod]
public void ShowInstallButton_IsFalse_WhenInstalledWithNoUpdate()
{
var entry = new GalleryExtensionEntry
{
Id = "installed-extension",
Title = "Installed extension",
Description = "Installed extension description",
Author = new GalleryAuthor { Name = "Author" },
InstallSources =
[
new GalleryInstallSource { Type = "winget", Id = "Contoso.Extension" },
],
};
var viewModel = CreateViewModel(entry);
viewModel.ApplyWinGetPackageInfo(
new WinGetPackageInfo(
new WinGetPackageStatus(
IsInstalled: true,
IsInstalledStateKnown: true,
IsUpdateAvailable: false,
IsUpdateStateKnown: true),
Details: null));
Assert.IsFalse(viewModel.ShowInstallButton);
Assert.IsTrue(viewModel.ShowInstalledBadge);
}
[TestMethod]
public void ShowInstallButton_IsTrue_WhenInstalledWithUpdateAvailable()
{
var entry = new GalleryExtensionEntry
{
Id = "update-extension",
Title = "Update extension",
Description = "Update extension description",
Author = new GalleryAuthor { Name = "Author" },
InstallSources =
[
new GalleryInstallSource { Type = "winget", Id = "Contoso.Extension" },
],
};
var viewModel = CreateViewModel(entry);
viewModel.ApplyWinGetPackageInfo(
new WinGetPackageInfo(
new WinGetPackageStatus(
IsInstalled: true,
IsInstalledStateKnown: true,
IsUpdateAvailable: true,
IsUpdateStateKnown: true),
Details: null));
Assert.IsTrue(viewModel.ShowInstallButton);
Assert.IsFalse(viewModel.ShowInstalledBadge);
}
[TestMethod]
public void OpenInstalledAppsCommand_IsNotNull()
{
var entry = CreateEntry(iconUrl: null);
var viewModel = CreateViewModel(entry);
Assert.IsNotNull(viewModel.OpenInstalledAppsCommand);
Assert.IsTrue(viewModel.OpenInstalledAppsCommand.CanExecute(null));
}
private static GalleryExtensionEntry CreateEntry(string? iconUrl)
{
return new GalleryExtensionEntry
{
Id = "sample-extension",
Title = "Sample",
Description = "Sample extension",
Author = new GalleryAuthor { Name = "Sample Author" },
IconUrl = iconUrl,
InstallSources = new List<GalleryInstallSource>(),
};
}
private static ExtensionGalleryItemViewModel CreateViewModel(
GalleryExtensionEntry entry,
IWinGetPackageManagerService? winGetPackageManagerService = null,
IWinGetPackageStatusService? winGetPackageStatusService = null,
IWinGetOperationTrackerService? winGetOperationTrackerService = null)
{
return new ExtensionGalleryItemViewModel(
entry,
NullLogger<ExtensionGalleryItemViewModel>.Instance,
winGetPackageManagerService,
winGetPackageStatusService,
winGetOperationTrackerService);
}
}

View File

@@ -0,0 +1,585 @@
// 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.Linq;
using System.Threading;
using System.Threading.Tasks;
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.Gallery;
using Microsoft.Extensions.Logging.Abstractions;
using Microsoft.VisualStudio.TestTools.UnitTesting;
using Moq;
namespace Microsoft.CmdPal.UI.ViewModels.UnitTests;
[TestClass]
public class ExtensionGalleryViewModelTests
{
private static readonly string[] FeaturedOrderIds = ["beta", "alpha", "gamma"];
private static readonly string[] NameOrderIds = ["alpha", "beta", "gamma"];
private static readonly string[] AuthorOrderIds = ["second", "third", "first"];
private static readonly string[] InstallationStatusOrderIds = ["update", "installed", "not-installed"];
[TestMethod]
public async Task LoadAsync_DoesNotBlockOnSlowSynchronousInstalledStatusKickoff()
{
var galleryService = new Mock<IExtensionGalleryService>();
galleryService.Setup(s => s.IsCustomFeed).Returns(false);
galleryService.Setup(s => s.GetBaseUrl()).Returns("https://example.com/index.json");
galleryService
.Setup(s => s.FetchExtensionsAsync(It.IsAny<CancellationToken>()))
.ReturnsAsync(new GalleryFetchResult
{
Extensions =
[
new GalleryExtensionEntry
{
Id = "feed-extension",
Title = "Feed Extension",
Description = "Curated feed entry",
Author = new GalleryAuthor { Name = "Feed Author" },
InstallSources = [],
},
],
});
var extensionService = new Mock<IExtensionService>();
extensionService
.Setup(s => s.GetInstalledExtensionsAsync(true))
.Returns(() =>
{
Thread.Sleep(1500);
return Task.FromResult<IEnumerable<IExtensionWrapper>>(Array.Empty<IExtensionWrapper>());
});
using var viewModel = new ExtensionGalleryViewModel(
galleryService.Object,
extensionService.Object,
NullLogger<ExtensionGalleryViewModel>.Instance,
CreateGalleryExtensionViewModelFactory());
var loadTask = viewModel.LoadAsync();
var completedTask = await Task.WhenAny(loadTask, Task.Delay(TimeSpan.FromSeconds(1)));
Assert.AreSame(loadTask, completedTask);
Assert.IsTrue(loadTask.IsCompletedSuccessfully);
Assert.AreEqual(1, viewModel.FilteredEntries.Count);
Assert.AreEqual("Feed Extension", viewModel.FilteredEntries[0].Title);
}
[TestMethod]
public async Task RefreshCommand_RefreshesInstalledAndWinGetStateInBackground()
{
var galleryService = new Mock<IExtensionGalleryService>();
galleryService.Setup(s => s.IsCustomFeed).Returns(false);
galleryService.Setup(s => s.GetBaseUrl()).Returns("https://example.com/index.json");
galleryService
.Setup(s => s.RefreshAsync(It.IsAny<CancellationToken>()))
.ReturnsAsync(new GalleryFetchResult
{
Extensions =
[
new GalleryExtensionEntry
{
Id = "feed-extension",
Title = "Feed Extension",
Description = "Curated feed entry",
Author = new GalleryAuthor { Name = "Feed Author" },
InstallSources =
[
new GalleryInstallSource { Type = "winget", Id = "Contoso.FeedExtension" },
],
},
],
});
var extensionService = new Mock<IExtensionService>();
extensionService
.Setup(s => s.RefreshInstalledExtensionsAsync(true))
.ReturnsAsync(Array.Empty<IExtensionWrapper>());
var winGetService = new Mock<IWinGetPackageManagerService>();
winGetService.Setup(s => s.State).Returns(new WinGetServiceState(true, null));
winGetService
.Setup(s => s.RefreshCatalogsAsync(It.IsAny<CancellationToken>()))
.ReturnsAsync(true);
var winGetStatusService = new Mock<IWinGetPackageStatusService>();
winGetStatusService
.Setup(s => s.TryGetPackageInfosAsync(It.IsAny<IEnumerable<string>>(), It.IsAny<CancellationToken>()))
.ReturnsAsync(new Dictionary<string, WinGetPackageInfo>(StringComparer.OrdinalIgnoreCase));
using var viewModel = new ExtensionGalleryViewModel(
galleryService.Object,
extensionService.Object,
NullLogger<ExtensionGalleryViewModel>.Instance,
CreateGalleryExtensionViewModelFactory(winGetService.Object, winGetStatusService.Object),
winGetService.Object,
winGetStatusService.Object,
winGetOperationTrackerService: null);
await viewModel.RefreshCommand.ExecuteAsync(null);
Assert.AreEqual(1, viewModel.FilteredEntries.Count);
Assert.AreEqual("Feed Extension", viewModel.FilteredEntries[0].Title);
await WaitForConditionAsync(() =>
{
try
{
extensionService.Verify(s => s.RefreshInstalledExtensionsAsync(true), Times.Once);
winGetService.Verify(s => s.RefreshCatalogsAsync(It.IsAny<CancellationToken>()), Times.Once);
return true;
}
catch
{
return false;
}
});
}
[TestMethod]
public async Task RefreshCommand_DoesNotBlockOnSlowSynchronousStatusRefreshKickoff()
{
var galleryService = new Mock<IExtensionGalleryService>();
galleryService.Setup(s => s.IsCustomFeed).Returns(false);
galleryService.Setup(s => s.GetBaseUrl()).Returns("https://example.com/index.json");
galleryService
.Setup(s => s.RefreshAsync(It.IsAny<CancellationToken>()))
.ReturnsAsync(new GalleryFetchResult
{
Extensions =
[
new GalleryExtensionEntry
{
Id = "feed-extension",
Title = "Feed Extension",
Description = "Curated feed entry",
Author = new GalleryAuthor { Name = "Feed Author" },
InstallSources =
[
new GalleryInstallSource { Type = "winget", Id = "Contoso.FeedExtension" },
],
},
],
});
var extensionService = new Mock<IExtensionService>();
extensionService
.Setup(s => s.RefreshInstalledExtensionsAsync(true))
.Returns(() =>
{
Thread.Sleep(1500);
return Task.FromResult<IEnumerable<IExtensionWrapper>>(Array.Empty<IExtensionWrapper>());
});
var winGetService = new Mock<IWinGetPackageManagerService>();
winGetService.Setup(s => s.State).Returns(new WinGetServiceState(true, null));
winGetService
.Setup(s => s.RefreshCatalogsAsync(It.IsAny<CancellationToken>()))
.Returns(() =>
{
Thread.Sleep(1500);
return Task.FromResult(true);
});
var winGetStatusService = new Mock<IWinGetPackageStatusService>();
winGetStatusService
.Setup(s => s.TryGetPackageInfosAsync(It.IsAny<IEnumerable<string>>(), It.IsAny<CancellationToken>()))
.Returns(() =>
{
Thread.Sleep(1500);
return Task.FromResult<IReadOnlyDictionary<string, WinGetPackageInfo>?>(
new Dictionary<string, WinGetPackageInfo>(StringComparer.OrdinalIgnoreCase));
});
using var viewModel = new ExtensionGalleryViewModel(
galleryService.Object,
extensionService.Object,
NullLogger<ExtensionGalleryViewModel>.Instance,
CreateGalleryExtensionViewModelFactory(winGetService.Object, winGetStatusService.Object),
winGetService.Object,
winGetStatusService.Object,
winGetOperationTrackerService: null);
var refreshTask = viewModel.RefreshCommand.ExecuteAsync(null);
var completedTask = await Task.WhenAny(refreshTask, Task.Delay(TimeSpan.FromSeconds(1)));
Assert.AreSame(refreshTask, completedTask);
Assert.IsTrue(refreshTask.IsCompletedSuccessfully);
Assert.AreEqual(1, viewModel.FilteredEntries.Count);
Assert.AreEqual("Feed Extension", viewModel.FilteredEntries[0].Title);
}
[TestMethod]
public async Task LoadAsync_DoesNotShowFallbackCacheWarning_ForNormalCacheHits()
{
var galleryService = new Mock<IExtensionGalleryService>();
galleryService.Setup(s => s.IsCustomFeed).Returns(false);
galleryService.Setup(s => s.GetBaseUrl()).Returns("https://example.com/index.json");
galleryService
.Setup(s => s.FetchExtensionsAsync(It.IsAny<CancellationToken>()))
.ReturnsAsync(new GalleryFetchResult
{
Extensions =
[
new GalleryExtensionEntry
{
Id = "cached-extension",
Title = "Cached Extension",
Description = "Cached feed entry",
Author = new GalleryAuthor { Name = "Feed Author" },
InstallSources = [],
},
],
FromCache = true,
UsedFallbackCache = false,
});
var extensionService = new Mock<IExtensionService>();
extensionService
.Setup(s => s.GetInstalledExtensionsAsync(true))
.ReturnsAsync(Array.Empty<IExtensionWrapper>());
using var viewModel = new ExtensionGalleryViewModel(
galleryService.Object,
extensionService.Object,
NullLogger<ExtensionGalleryViewModel>.Instance,
CreateGalleryExtensionViewModelFactory());
await viewModel.LoadAsync();
Assert.IsTrue(viewModel.FromCache);
Assert.IsFalse(viewModel.UsedFallbackCache);
}
[TestMethod]
public async Task LoadAsync_ShowsFallbackCacheWarning_WhenServiceFallsBackToCache()
{
var galleryService = new Mock<IExtensionGalleryService>();
galleryService.Setup(s => s.IsCustomFeed).Returns(false);
galleryService.Setup(s => s.GetBaseUrl()).Returns("https://example.com/index.json");
galleryService
.Setup(s => s.FetchExtensionsAsync(It.IsAny<CancellationToken>()))
.ReturnsAsync(new GalleryFetchResult
{
Extensions =
[
new GalleryExtensionEntry
{
Id = "cached-extension",
Title = "Cached Extension",
Description = "Cached feed entry",
Author = new GalleryAuthor { Name = "Feed Author" },
InstallSources = [],
},
],
FromCache = true,
UsedFallbackCache = true,
});
var extensionService = new Mock<IExtensionService>();
extensionService
.Setup(s => s.GetInstalledExtensionsAsync(true))
.ReturnsAsync(Array.Empty<IExtensionWrapper>());
using var viewModel = new ExtensionGalleryViewModel(
galleryService.Object,
extensionService.Object,
NullLogger<ExtensionGalleryViewModel>.Instance,
CreateGalleryExtensionViewModelFactory());
await viewModel.LoadAsync();
Assert.IsTrue(viewModel.FromCache);
Assert.IsTrue(viewModel.UsedFallbackCache);
}
[TestMethod]
public async Task LoadAsync_ShowsErrorSurface_WhenGalleryIsRateLimited()
{
var galleryService = new Mock<IExtensionGalleryService>();
galleryService.Setup(s => s.IsCustomFeed).Returns(false);
galleryService.Setup(s => s.GetBaseUrl()).Returns("https://example.com/index.json");
galleryService
.Setup(s => s.FetchExtensionsAsync(It.IsAny<CancellationToken>()))
.ReturnsAsync(new GalleryFetchResult
{
HasError = true,
IsRateLimited = true,
ErrorMessage = null,
});
var extensionService = new Mock<IExtensionService>();
extensionService
.Setup(s => s.GetInstalledExtensionsAsync(true))
.ReturnsAsync(Array.Empty<IExtensionWrapper>());
using var viewModel = new ExtensionGalleryViewModel(
galleryService.Object,
extensionService.Object,
NullLogger<ExtensionGalleryViewModel>.Instance,
CreateGalleryExtensionViewModelFactory());
await viewModel.LoadAsync();
Assert.IsTrue(viewModel.HasError);
Assert.IsTrue(viewModel.IsRateLimitedError);
Assert.IsTrue(viewModel.ShowErrorSurface);
Assert.IsFalse(viewModel.ShowErrorInfoBar);
Assert.AreEqual("The gallery is taking a breather", viewModel.ErrorDisplayTitle);
Assert.AreEqual(0, viewModel.FilteredEntries.Count);
}
[TestMethod]
public async Task LoadAsync_ShowsGenericErrorSurface_WhenGalleryLoadFailsWithoutEntries()
{
var galleryService = new Mock<IExtensionGalleryService>();
galleryService.Setup(s => s.IsCustomFeed).Returns(false);
galleryService.Setup(s => s.GetBaseUrl()).Returns("https://example.com/index.json");
galleryService
.Setup(s => s.FetchExtensionsAsync(It.IsAny<CancellationToken>()))
.ReturnsAsync(new GalleryFetchResult
{
HasError = true,
ErrorMessage = "Service unavailable",
});
var extensionService = new Mock<IExtensionService>();
extensionService
.Setup(s => s.GetInstalledExtensionsAsync(true))
.ReturnsAsync(Array.Empty<IExtensionWrapper>());
using var viewModel = new ExtensionGalleryViewModel(
galleryService.Object,
extensionService.Object,
NullLogger<ExtensionGalleryViewModel>.Instance,
CreateGalleryExtensionViewModelFactory());
await viewModel.LoadAsync();
Assert.IsTrue(viewModel.HasError);
Assert.IsFalse(viewModel.IsRateLimitedError);
Assert.IsTrue(viewModel.ShowErrorSurface);
Assert.IsFalse(viewModel.ShowErrorInfoBar);
Assert.AreEqual("Failed to load extensions", viewModel.ErrorDisplayTitle);
Assert.AreEqual("Service unavailable", viewModel.ErrorDisplayMessage);
Assert.AreEqual(0, viewModel.FilteredEntries.Count);
}
[TestMethod]
public async Task SortByNameCommand_SortsEntriesByTitle()
{
var galleryService = CreateGalleryService(
CreateGalleryEntry("beta", "Beta Extension", "Contoso B"),
CreateGalleryEntry("alpha", "Alpha Extension", "Contoso A"),
CreateGalleryEntry("gamma", "Gamma Extension", "Contoso C"));
var extensionService = new Mock<IExtensionService>();
extensionService
.Setup(s => s.GetInstalledExtensionsAsync(true))
.ReturnsAsync(Array.Empty<IExtensionWrapper>());
using var viewModel = new ExtensionGalleryViewModel(
galleryService.Object,
extensionService.Object,
NullLogger<ExtensionGalleryViewModel>.Instance,
CreateGalleryExtensionViewModelFactory());
await viewModel.LoadAsync();
CollectionAssert.AreEqual(
FeaturedOrderIds,
viewModel.FilteredEntries.Select(entry => entry.Id).ToArray());
viewModel.SortByNameCommand.Execute(null);
CollectionAssert.AreEqual(
NameOrderIds,
viewModel.FilteredEntries.Select(entry => entry.Id).ToArray());
}
[TestMethod]
public async Task SortByAuthorCommand_SortsEntriesByAuthor()
{
var galleryService = CreateGalleryService(
CreateGalleryEntry("first", "First Extension", "Charlie"),
CreateGalleryEntry("second", "Second Extension", "Alice"),
CreateGalleryEntry("third", "Third Extension", "Bob"));
var extensionService = new Mock<IExtensionService>();
extensionService
.Setup(s => s.GetInstalledExtensionsAsync(true))
.ReturnsAsync(Array.Empty<IExtensionWrapper>());
using var viewModel = new ExtensionGalleryViewModel(
galleryService.Object,
extensionService.Object,
NullLogger<ExtensionGalleryViewModel>.Instance,
CreateGalleryExtensionViewModelFactory());
await viewModel.LoadAsync();
viewModel.SortByAuthorCommand.Execute(null);
CollectionAssert.AreEqual(
AuthorOrderIds,
viewModel.FilteredEntries.Select(entry => entry.Id).ToArray());
}
[TestMethod]
public async Task SortByInstallationStatusCommand_SortsUpdatesBeforeInstalledBeforeNotInstalled()
{
var galleryService = CreateGalleryService(
CreateGalleryEntry(
"not-installed",
"Not Installed Extension",
"Author A",
winGetId: "Contoso.NotInstalled"),
CreateGalleryEntry(
"installed",
"Installed Extension",
"Author B",
packageFamilyName: "Contoso.Installed_12345"),
CreateGalleryEntry(
"update",
"Update Extension",
"Author C",
winGetId: "Contoso.Update"));
var extensionService = new Mock<IExtensionService>();
extensionService
.Setup(s => s.GetInstalledExtensionsAsync(true))
.ReturnsAsync(
[
CreateInstalledExtensionWrapper("Contoso.Installed_12345"),
]);
var winGetStatusService = new Mock<IWinGetPackageStatusService>();
winGetStatusService
.Setup(s => s.TryGetPackageInfosAsync(It.IsAny<IEnumerable<string>>(), It.IsAny<CancellationToken>()))
.ReturnsAsync(new Dictionary<string, WinGetPackageInfo>(StringComparer.OrdinalIgnoreCase)
{
["Contoso.NotInstalled"] = new(
new WinGetPackageStatus(
IsInstalled: false,
IsInstalledStateKnown: true,
IsUpdateAvailable: false,
IsUpdateStateKnown: true),
Details: null),
["Contoso.Update"] = new(
new WinGetPackageStatus(
IsInstalled: true,
IsInstalledStateKnown: true,
IsUpdateAvailable: true,
IsUpdateStateKnown: true),
Details: null),
});
using var viewModel = new ExtensionGalleryViewModel(
galleryService.Object,
extensionService.Object,
NullLogger<ExtensionGalleryViewModel>.Instance,
CreateGalleryExtensionViewModelFactory(winGetPackageStatusService: winGetStatusService.Object),
winGetPackageManagerService: null,
winGetStatusService.Object,
winGetOperationTrackerService: null);
await viewModel.LoadAsync();
await WaitForConditionAsync(() =>
viewModel.FilteredEntries.Count == 3
&& viewModel.FilteredEntries.Any(entry => entry.Id == "update" && entry.IsUpdateAvailable)
&& viewModel.FilteredEntries.Any(entry => entry.Id == "not-installed" && entry.IsInstalledStateKnown));
viewModel.SortByInstallationStatusCommand.Execute(null);
CollectionAssert.AreEqual(
InstallationStatusOrderIds,
viewModel.FilteredEntries.Select(entry => entry.Id).ToArray());
}
private static Mock<IExtensionGalleryService> CreateGalleryService(params GalleryExtensionEntry[] entries)
{
var galleryService = new Mock<IExtensionGalleryService>();
galleryService.Setup(s => s.IsCustomFeed).Returns(false);
galleryService.Setup(s => s.GetBaseUrl()).Returns("https://example.com/index.json");
galleryService
.Setup(s => s.FetchExtensionsAsync(It.IsAny<CancellationToken>()))
.ReturnsAsync(new GalleryFetchResult
{
Extensions = [.. entries],
});
return galleryService;
}
private static GalleryExtensionEntry CreateGalleryEntry(
string id,
string title,
string authorName,
string? winGetId = null,
string? packageFamilyName = null)
{
List<GalleryInstallSource> installSources = [];
if (!string.IsNullOrWhiteSpace(winGetId))
{
installSources.Add(new GalleryInstallSource
{
Type = "winget",
Id = winGetId,
});
}
return new GalleryExtensionEntry
{
Id = id,
Title = title,
Description = $"{title} description",
Author = new GalleryAuthor { Name = authorName },
Detection = string.IsNullOrWhiteSpace(packageFamilyName)
? null
: new GalleryDetection { PackageFamilyName = packageFamilyName },
InstallSources = installSources,
};
}
private static IExtensionWrapper CreateInstalledExtensionWrapper(string packageFamilyName)
{
var wrapper = new Mock<IExtensionWrapper>();
wrapper.SetupGet(w => w.PackageFamilyName).Returns(packageFamilyName);
return wrapper.Object;
}
private static ExtensionGalleryItemViewModelFactory CreateGalleryExtensionViewModelFactory(
IWinGetPackageManagerService? winGetPackageManagerService = null,
IWinGetPackageStatusService? winGetPackageStatusService = null,
IWinGetOperationTrackerService? winGetOperationTrackerService = null)
{
return new ExtensionGalleryItemViewModelFactory(
NullLogger<ExtensionGalleryItemViewModel>.Instance,
winGetPackageManagerService,
winGetPackageStatusService,
winGetOperationTrackerService);
}
private static async Task WaitForConditionAsync(Func<bool> condition, int timeoutMilliseconds = 2000)
{
var start = Environment.TickCount64;
while (!condition())
{
if (Environment.TickCount64 - start >= timeoutMilliseconds)
{
Assert.Fail("Timed out waiting for the expected condition.");
}
await Task.Delay(25);
}
}
}

View File

@@ -19,6 +19,7 @@
</ItemGroup>
<ItemGroup>
<ProjectReference Include="..\..\Microsoft.CmdPal.Common\Microsoft.CmdPal.Common.csproj" />
<ProjectReference Include="..\..\Microsoft.CmdPal.UI.ViewModels\Microsoft.CmdPal.UI.ViewModels.csproj" />
<ProjectReference Include="..\Microsoft.CmdPal.Ext.UnitTestsBase\Microsoft.CmdPal.Ext.UnitTestBase.csproj" />
</ItemGroup>

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