mirror of
https://github.com/microsoft/PowerToys.git
synced 2026-07-03 09:00:04 +02:00
Compare commits
32 Commits
dependabot
...
dev/jpolas
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
06a22ca72a | ||
|
|
adb805c273 | ||
|
|
d58a39a594 | ||
|
|
955a497799 | ||
|
|
964e5af435 | ||
|
|
fbc3a63af9 | ||
|
|
25aebbfb38 | ||
|
|
46c62cc7bd | ||
|
|
3b5d8575ca | ||
|
|
5067cc21ed | ||
|
|
496ce3f890 | ||
|
|
d233b22bfb | ||
|
|
0e4da80564 | ||
|
|
4fd340152a | ||
|
|
6892960ce6 | ||
|
|
37bd1b4cb5 | ||
|
|
461e474396 | ||
|
|
ddcc462aa1 | ||
|
|
cf76163aa0 | ||
|
|
237b33a136 | ||
|
|
71d342a2e6 | ||
|
|
d133b6f3e7 | ||
|
|
570cb85b58 | ||
|
|
d81e784b0a | ||
|
|
d3ba693eed | ||
|
|
40c54b3d1f | ||
|
|
f042f3a33e | ||
|
|
b4ce152fb7 | ||
|
|
8ad1456521 | ||
|
|
d3448ee133 | ||
|
|
0d1cc4c047 | ||
|
|
d01bd697a2 |
1
.github/actions/spell-check/allow/names.txt
vendored
1
.github/actions/spell-check/allow/names.txt
vendored
@@ -209,6 +209,7 @@ Bilibili
|
||||
BVID
|
||||
capturevideosample
|
||||
cmdow
|
||||
contoso
|
||||
Contoso
|
||||
Controlz
|
||||
cortana
|
||||
|
||||
5
.github/actions/spell-check/excludes.txt
vendored
5
.github/actions/spell-check/excludes.txt
vendored
@@ -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$
|
||||
|
||||
1
.github/actions/spell-check/expect.txt
vendored
1
.github/actions/spell-check/expect.txt
vendored
@@ -1332,6 +1332,7 @@ resmimetype
|
||||
RESOURCEID
|
||||
RESTORETOMAXIMIZED
|
||||
RETURNONLYFSDIRS
|
||||
Revalidates
|
||||
RGBQUAD
|
||||
rgbs
|
||||
rgelt
|
||||
|
||||
@@ -0,0 +1,12 @@
|
||||
// Copyright (c) Microsoft Corporation
|
||||
// The Microsoft Corporation licenses this file to you under the MIT license.
|
||||
// See the LICENSE file in the project root for more information.
|
||||
|
||||
namespace Microsoft.CmdPal.Common.ExtensionGallery.Models;
|
||||
|
||||
public sealed class GalleryAuthor
|
||||
{
|
||||
public string Name { get; set; } = string.Empty;
|
||||
|
||||
public string? Url { get; set; }
|
||||
}
|
||||
@@ -0,0 +1,10 @@
|
||||
// Copyright (c) Microsoft Corporation
|
||||
// The Microsoft Corporation licenses this file to you under the MIT license.
|
||||
// See the LICENSE file in the project root for more information.
|
||||
|
||||
namespace Microsoft.CmdPal.Common.ExtensionGallery.Models;
|
||||
|
||||
public sealed class GalleryDetection
|
||||
{
|
||||
public string? PackageFamilyName { get; set; }
|
||||
}
|
||||
@@ -0,0 +1,32 @@
|
||||
// Copyright (c) Microsoft Corporation
|
||||
// The Microsoft Corporation licenses this file to you under the MIT license.
|
||||
// See the LICENSE file in the project root for more information.
|
||||
|
||||
namespace Microsoft.CmdPal.Common.ExtensionGallery.Models;
|
||||
|
||||
public sealed class GalleryExtensionEntry
|
||||
{
|
||||
public string Id { get; set; } = string.Empty;
|
||||
|
||||
public string Title { get; set; } = string.Empty;
|
||||
|
||||
public string Description { get; set; } = string.Empty;
|
||||
|
||||
public string? ShortDescription { get; set; }
|
||||
|
||||
public GalleryAuthor Author { get; set; } = new();
|
||||
|
||||
public string? Homepage { get; set; }
|
||||
|
||||
public string? Readme { get; set; }
|
||||
|
||||
public string? IconUrl { get; set; }
|
||||
|
||||
public List<string> ScreenshotUrls { get; set; } = [];
|
||||
|
||||
public List<GalleryInstallSource> InstallSources { get; set; } = [];
|
||||
|
||||
public GalleryDetection? Detection { get; set; }
|
||||
|
||||
public List<string> Tags { get; set; } = [];
|
||||
}
|
||||
@@ -0,0 +1,14 @@
|
||||
// Copyright (c) Microsoft Corporation
|
||||
// The Microsoft Corporation licenses this file to you under the MIT license.
|
||||
// See the LICENSE file in the project root for more information.
|
||||
|
||||
namespace Microsoft.CmdPal.Common.ExtensionGallery.Models;
|
||||
|
||||
public sealed class GalleryInstallSource
|
||||
{
|
||||
public string Type { get; set; } = string.Empty;
|
||||
|
||||
public string? Id { get; set; }
|
||||
|
||||
public string? Uri { get; set; }
|
||||
}
|
||||
@@ -0,0 +1,13 @@
|
||||
// Copyright (c) Microsoft Corporation
|
||||
// The Microsoft Corporation licenses this file to you under the MIT license.
|
||||
// See the LICENSE file in the project root for more information.
|
||||
|
||||
namespace Microsoft.CmdPal.Common.ExtensionGallery.Models;
|
||||
|
||||
/// <summary>
|
||||
/// Represents the wrapped gallery index format where extension data is inline.
|
||||
/// </summary>
|
||||
public sealed class GalleryRemoteIndex
|
||||
{
|
||||
public List<GalleryExtensionEntry> Extensions { get; set; } = [];
|
||||
}
|
||||
@@ -0,0 +1,14 @@
|
||||
// Copyright (c) Microsoft Corporation
|
||||
// The Microsoft Corporation licenses this file to you under the MIT license.
|
||||
// See the LICENSE file in the project root for more information.
|
||||
|
||||
using System.Text.Json.Serialization;
|
||||
|
||||
namespace Microsoft.CmdPal.Common.ExtensionGallery.Models;
|
||||
|
||||
[JsonSerializable(typeof(GalleryExtensionEntry))]
|
||||
[JsonSerializable(typeof(GalleryRemoteIndex))]
|
||||
[JsonSourceGenerationOptions(PropertyNameCaseInsensitive = true)]
|
||||
public sealed partial class GallerySerializationContext : JsonSerializerContext
|
||||
{
|
||||
}
|
||||
@@ -0,0 +1,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;
|
||||
}
|
||||
@@ -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();
|
||||
}
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
@@ -0,0 +1,57 @@
|
||||
// Copyright (c) Microsoft Corporation
|
||||
// The Microsoft Corporation licenses this file to you under the MIT license.
|
||||
// See the LICENSE file in the project root for more information.
|
||||
|
||||
using Microsoft.CmdPal.Common.Services;
|
||||
using Microsoft.CmdPal.Common.Services.HttpCaching;
|
||||
using Microsoft.Extensions.Logging;
|
||||
|
||||
namespace Microsoft.CmdPal.Common.ExtensionGallery.Services;
|
||||
|
||||
/// <summary>
|
||||
/// Identifies the HTTP client instance used by the extension gallery.
|
||||
/// </summary>
|
||||
public sealed partial class ExtensionGalleryHttpClient : IDisposable
|
||||
{
|
||||
internal const string CacheDirectoryName = "GalleryCache";
|
||||
private const int TimeoutSeconds = 15;
|
||||
private const string UserAgent = "PowerToys-CmdPal/1.0";
|
||||
private readonly HttpCachingClient _cache;
|
||||
|
||||
internal static readonly TimeSpan DefaultTimeToLive = TimeSpan.FromHours(4);
|
||||
|
||||
public ExtensionGalleryHttpClient(IApplicationInfoService applicationInfoService, ILogger<ExtensionGalleryHttpClient> logger)
|
||||
: this(applicationInfoService, innerHandler: null, logger)
|
||||
{
|
||||
}
|
||||
|
||||
internal ExtensionGalleryHttpClient(IApplicationInfoService applicationInfoService, HttpMessageHandler? innerHandler, ILogger<ExtensionGalleryHttpClient> logger)
|
||||
: this(
|
||||
Path.Combine(applicationInfoService.CacheDirectory, CacheDirectoryName),
|
||||
innerHandler,
|
||||
logger)
|
||||
{
|
||||
ArgumentNullException.ThrowIfNull(applicationInfoService);
|
||||
}
|
||||
|
||||
internal ExtensionGalleryHttpClient(string cacheDirectory, HttpMessageHandler? innerHandler, ILogger<ExtensionGalleryHttpClient> logger)
|
||||
{
|
||||
ArgumentException.ThrowIfNullOrWhiteSpace(cacheDirectory);
|
||||
ArgumentNullException.ThrowIfNull(logger);
|
||||
|
||||
_cache = new HttpCachingClient(
|
||||
cacheDirectory,
|
||||
DefaultTimeToLive,
|
||||
TimeSpan.FromSeconds(TimeoutSeconds),
|
||||
UserAgent,
|
||||
innerHandler,
|
||||
logger);
|
||||
}
|
||||
|
||||
internal HttpCachingClient Cache => _cache;
|
||||
|
||||
public void Dispose()
|
||||
{
|
||||
_cache.Dispose();
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,404 @@
|
||||
// Copyright (c) Microsoft Corporation
|
||||
// The Microsoft Corporation licenses this file to you under the MIT license.
|
||||
// See the LICENSE file in the project root for more information.
|
||||
|
||||
using System.Diagnostics.CodeAnalysis;
|
||||
using System.Net;
|
||||
using System.Text.Json;
|
||||
using Microsoft.CmdPal.Common.ExtensionGallery.Models;
|
||||
using Microsoft.Extensions.Logging;
|
||||
using MEL = Microsoft.Extensions.Logging;
|
||||
|
||||
namespace Microsoft.CmdPal.Common.ExtensionGallery.Services;
|
||||
|
||||
public sealed partial class ExtensionGalleryService : IExtensionGalleryService
|
||||
{
|
||||
private const string DefaultFeedUrl = "https://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);
|
||||
}
|
||||
@@ -0,0 +1,7 @@
|
||||
// Copyright (c) Microsoft Corporation
|
||||
// The Microsoft Corporation licenses this file to you under the MIT license.
|
||||
// See the LICENSE file in the project root for more information.
|
||||
|
||||
namespace Microsoft.CmdPal.Common.ExtensionGallery.Services;
|
||||
|
||||
public delegate string? GalleryFeedUrlProvider();
|
||||
@@ -0,0 +1,42 @@
|
||||
// Copyright (c) Microsoft Corporation
|
||||
// The Microsoft Corporation licenses this file to you under the MIT license.
|
||||
// See the LICENSE file in the project root for more information.
|
||||
|
||||
using Microsoft.CmdPal.Common.ExtensionGallery.Models;
|
||||
|
||||
namespace Microsoft.CmdPal.Common.ExtensionGallery.Services;
|
||||
|
||||
public sealed record GalleryFetchResult
|
||||
{
|
||||
/// <summary>
|
||||
/// Gets or sets the gallery entries returned by the fetch operation.
|
||||
/// </summary>
|
||||
public List<GalleryExtensionEntry> Extensions { get; init; } = [];
|
||||
|
||||
/// <summary>
|
||||
/// Gets or sets a value indicating whether the result was loaded from cache.
|
||||
/// </summary>
|
||||
public bool FromCache { get; init; }
|
||||
|
||||
/// <summary>
|
||||
/// Gets or sets a value indicating whether the service had to fall back to cached data
|
||||
/// because a remote refresh could not be completed successfully.
|
||||
/// </summary>
|
||||
public bool UsedFallbackCache { get; init; }
|
||||
|
||||
/// <summary>
|
||||
/// Gets or sets a value indicating whether the fetch failed because the gallery responded
|
||||
/// with HTTP 429 Too Many Requests and no cached fallback data was available.
|
||||
/// </summary>
|
||||
public bool IsRateLimited { get; init; }
|
||||
|
||||
/// <summary>
|
||||
/// Gets or sets a value indicating whether the fetch operation completed with an error.
|
||||
/// </summary>
|
||||
public bool HasError { get; init; }
|
||||
|
||||
/// <summary>
|
||||
/// Gets or sets the error message associated with the fetch operation, when available.
|
||||
/// </summary>
|
||||
public string? ErrorMessage { get; init; }
|
||||
}
|
||||
@@ -0,0 +1,38 @@
|
||||
// Copyright (c) Microsoft Corporation
|
||||
// The Microsoft Corporation licenses this file to you under the MIT license.
|
||||
// See the LICENSE file in the project root for more information.
|
||||
|
||||
namespace Microsoft.CmdPal.Common.ExtensionGallery.Services;
|
||||
|
||||
public interface IExtensionGalleryService
|
||||
{
|
||||
/// <summary>
|
||||
/// Fetches the gallery feed.
|
||||
/// Falls back to cached data on failure.
|
||||
/// Returned entries are normalized for local display, including icon URIs.
|
||||
/// </summary>
|
||||
/// <param name="cancellationToken">A token that cancels the fetch operation.</param>
|
||||
/// <returns>The fetched gallery data, optionally populated from cache.</returns>
|
||||
Task<GalleryFetchResult> FetchExtensionsAsync(CancellationToken cancellationToken = default);
|
||||
|
||||
/// <summary>
|
||||
/// Attempts to fetch fresh data from the feed.
|
||||
/// Falls back to cached data if the refresh fails.
|
||||
/// Returned entries are normalized for local display, including icon URIs.
|
||||
/// </summary>
|
||||
/// <param name="cancellationToken">A token that cancels the refresh operation.</param>
|
||||
/// <returns>The refreshed gallery data, optionally populated from cache.</returns>
|
||||
Task<GalleryFetchResult> RefreshAsync(CancellationToken cancellationToken = default);
|
||||
|
||||
/// <summary>
|
||||
/// Gets the configured gallery feed URL.
|
||||
/// For compatibility this method keeps its historical name, but it returns the full feed endpoint.
|
||||
/// </summary>
|
||||
/// <returns>The configured gallery feed endpoint.</returns>
|
||||
string GetBaseUrl();
|
||||
|
||||
/// <summary>
|
||||
/// Returns true if a custom (non-default) feed URL is configured.
|
||||
/// </summary>
|
||||
bool IsCustomFeed { get; }
|
||||
}
|
||||
@@ -22,6 +22,7 @@
|
||||
|
||||
<PropertyGroup>
|
||||
<CsWinRTAotOptimizerEnabled>true</CsWinRTAotOptimizerEnabled>
|
||||
<CsWinRTIncludes>Microsoft.Management.Deployment</CsWinRTIncludes>
|
||||
</PropertyGroup>
|
||||
|
||||
<ItemGroup>
|
||||
@@ -29,8 +30,18 @@
|
||||
<ProjectReference Include="..\..\..\common\ManagedCommon\ManagedCommon.csproj" />
|
||||
</ItemGroup>
|
||||
|
||||
<ItemGroup>
|
||||
<AdditionalFiles Include="NativeMethods.txt" />
|
||||
<AdditionalFiles Include="NativeMethods.json" />
|
||||
</ItemGroup>
|
||||
|
||||
<ItemGroup>
|
||||
<PackageReference Include="Microsoft.Extensions.Hosting" />
|
||||
<PackageReference Include="Microsoft.WindowsPackageManager.ComInterop">
|
||||
<NoWarn>NU1701</NoWarn>
|
||||
<GeneratePathProperty>true</GeneratePathProperty>
|
||||
<IncludeAssets>none</IncludeAssets>
|
||||
</PackageReference>
|
||||
<PackageReference Include="Microsoft.Windows.CsWin32">
|
||||
<PrivateAssets>all</PrivateAssets>
|
||||
<IncludeAssets>runtime; build; native; contentfiles; analyzers; buildtransitive</IncludeAssets>
|
||||
@@ -40,6 +51,11 @@
|
||||
<!-- This line forces the WebView2 version used by Windows App SDK to be the one we expect from Directory.Packages.props . -->
|
||||
</ItemGroup>
|
||||
|
||||
<ItemGroup>
|
||||
<CsWinRTInputs Include="$(PkgMicrosoft_WindowsPackageManager_ComInterop)\lib\uap10.0\Microsoft.Management.Deployment.winmd" />
|
||||
<Content Include="$(PkgMicrosoft_WindowsPackageManager_ComInterop)\lib\uap10.0\Microsoft.Management.Deployment.winmd" Link="Microsoft.Management.Deployment.winmd" CopyToOutputDirectory="PreserveNewest" />
|
||||
</ItemGroup>
|
||||
|
||||
<ItemGroup>
|
||||
<EmbeddedResource Update="Properties\Resources.resx">
|
||||
<Generator>ResXFileCodeGenerator</Generator>
|
||||
|
||||
@@ -12,8 +12,9 @@ MonitorFromWindow
|
||||
|
||||
SHOW_WINDOW_CMD
|
||||
ShellExecuteEx
|
||||
SEE_MASK_INVOKEIDLIST
|
||||
SEE_MASK_INVOKEIDLIST
|
||||
|
||||
GetFileAttributes
|
||||
FILE_FLAGS_AND_ATTRIBUTES
|
||||
INVALID_FILE_ATTRIBUTES
|
||||
INVALID_FILE_ATTRIBUTES
|
||||
CoCreateInstance
|
||||
|
||||
@@ -8,6 +8,7 @@ using System.Security.Principal;
|
||||
using Microsoft.CmdPal.Common.Helpers;
|
||||
using Microsoft.CommandPalette.Extensions.Toolkit;
|
||||
using Windows.ApplicationModel;
|
||||
using Windows.Storage;
|
||||
|
||||
namespace Microsoft.CmdPal.Common.Services;
|
||||
|
||||
@@ -16,6 +17,9 @@ namespace Microsoft.CmdPal.Common.Services;
|
||||
/// </summary>
|
||||
public sealed class ApplicationInfoService : IApplicationInfoService
|
||||
{
|
||||
private const string UnpackagedCacheDirectoryName = "Cache";
|
||||
|
||||
private readonly Lazy<string> _cacheDirectory;
|
||||
private readonly Lazy<string> _configDirectory = new(() => Utilities.BaseSettingsPath("Microsoft.CmdPal"));
|
||||
private readonly Lazy<bool> _isElevated;
|
||||
private readonly Lazy<string> _logDirectory;
|
||||
@@ -28,6 +32,7 @@ public sealed class ApplicationInfoService : IApplicationInfoService
|
||||
/// </summary>
|
||||
public ApplicationInfoService()
|
||||
{
|
||||
_cacheDirectory = new Lazy<string>(DetermineCacheDirectory);
|
||||
_packagingFlavor = new Lazy<AppPackagingFlavor>(DeterminePackagingFlavor);
|
||||
_isElevated = new Lazy<bool>(DetermineElevationStatus);
|
||||
_logDirectory = new Lazy<string>(() => _getLogDirectory?.Invoke() ?? "Not available");
|
||||
@@ -62,6 +67,8 @@ public sealed class ApplicationInfoService : IApplicationInfoService
|
||||
|
||||
public string ConfigDirectory => _configDirectory.Value;
|
||||
|
||||
public string CacheDirectory => _cacheDirectory.Value;
|
||||
|
||||
public bool IsElevated => _isElevated.Value;
|
||||
|
||||
public string GetApplicationInfoSummary()
|
||||
@@ -84,9 +91,33 @@ public sealed class ApplicationInfoService : IApplicationInfoService
|
||||
Paths:
|
||||
Log directory: {LogDirectory}
|
||||
Config directory: {ConfigDirectory}
|
||||
Cache directory: {CacheDirectory}
|
||||
""";
|
||||
}
|
||||
|
||||
private string DetermineCacheDirectory()
|
||||
{
|
||||
if (PackagingFlavor != AppPackagingFlavor.Packaged)
|
||||
{
|
||||
return Path.Combine(Utilities.BaseSettingsPath("Microsoft.CmdPal"), UnpackagedCacheDirectoryName);
|
||||
}
|
||||
|
||||
try
|
||||
{
|
||||
var cacheDirectory = ApplicationData.Current.LocalCacheFolder.Path;
|
||||
if (!string.IsNullOrWhiteSpace(cacheDirectory))
|
||||
{
|
||||
return cacheDirectory;
|
||||
}
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
CoreLogger.LogError("Failed to resolve packaged cache directory", ex);
|
||||
}
|
||||
|
||||
return Path.Combine(Utilities.BaseSettingsPath("Microsoft.CmdPal"), UnpackagedCacheDirectoryName);
|
||||
}
|
||||
|
||||
private static AppPackagingFlavor DeterminePackagingFlavor()
|
||||
{
|
||||
// Try to determine if running as packaged
|
||||
|
||||
@@ -0,0 +1,12 @@
|
||||
// Copyright (c) Microsoft Corporation
|
||||
// The Microsoft Corporation licenses this file to you under the MIT license.
|
||||
// See the LICENSE file in the project root for more information.
|
||||
|
||||
namespace Microsoft.CmdPal.Common.Services.HttpCaching.Abstraction;
|
||||
|
||||
internal sealed class CachedHttpFetchResult(CachedHttpResource resource, bool usedFallbackCache)
|
||||
{
|
||||
public CachedHttpResource Resource { get; } = resource;
|
||||
|
||||
public bool UsedFallbackCache { get; } = usedFallbackCache;
|
||||
}
|
||||
@@ -0,0 +1,18 @@
|
||||
// Copyright (c) Microsoft Corporation
|
||||
// The Microsoft Corporation licenses this file to you under the MIT license.
|
||||
// See the LICENSE file in the project root for more information.
|
||||
|
||||
namespace Microsoft.CmdPal.Common.Services.HttpCaching.Abstraction;
|
||||
|
||||
internal sealed class CachedHttpResource(string contentPath, string? contentType, bool fromCache, bool wasRevalidated)
|
||||
{
|
||||
public string ContentPath { get; } = Path.GetFullPath(contentPath);
|
||||
|
||||
public Uri ContentUri => new(ContentPath);
|
||||
|
||||
public string? ContentType { get; } = contentType;
|
||||
|
||||
public bool FromCache { get; } = fromCache;
|
||||
|
||||
public bool WasRevalidated { get; } = wasRevalidated;
|
||||
}
|
||||
@@ -0,0 +1,26 @@
|
||||
// Copyright (c) Microsoft Corporation
|
||||
// The Microsoft Corporation licenses this file to you under the MIT license.
|
||||
// See the LICENSE file in the project root for more information.
|
||||
|
||||
namespace Microsoft.CmdPal.Common.Services.HttpCaching.Abstraction;
|
||||
|
||||
internal sealed class CachedHttpResourceEntry(
|
||||
Uri resourceUri,
|
||||
string entryDirectory,
|
||||
string metadataPath,
|
||||
string payloadPath,
|
||||
string payloadFileName,
|
||||
HttpResourceCacheMetadata? metadata)
|
||||
{
|
||||
public Uri ResourceUri { get; } = resourceUri;
|
||||
|
||||
public string EntryDirectory { get; } = Path.GetFullPath(entryDirectory);
|
||||
|
||||
public string MetadataPath { get; } = Path.GetFullPath(metadataPath);
|
||||
|
||||
public string PayloadPath { get; } = Path.GetFullPath(payloadPath);
|
||||
|
||||
public string PayloadFileName { get; } = payloadFileName;
|
||||
|
||||
public HttpResourceCacheMetadata? Metadata { get; } = metadata;
|
||||
}
|
||||
@@ -0,0 +1,22 @@
|
||||
// Copyright (c) Microsoft Corporation
|
||||
// The Microsoft Corporation licenses this file to you under the MIT license.
|
||||
// See the LICENSE file in the project root for more information.
|
||||
|
||||
namespace Microsoft.CmdPal.Common.Services.HttpCaching.Abstraction;
|
||||
|
||||
internal sealed class HttpResourceCacheMetadata
|
||||
{
|
||||
public string? ContentType { get; set; }
|
||||
|
||||
public string? ETag { get; set; }
|
||||
|
||||
public DateTimeOffset? ExpiresUtc { get; set; }
|
||||
|
||||
public string FileName { get; set; } = "payload.bin";
|
||||
|
||||
public DateTimeOffset? LastModifiedUtc { get; set; }
|
||||
|
||||
public DateTimeOffset LastValidatedUtc { get; set; }
|
||||
|
||||
public string SourceUri { get; set; } = string.Empty;
|
||||
}
|
||||
@@ -0,0 +1,20 @@
|
||||
// Copyright (c) Microsoft Corporation
|
||||
// The Microsoft Corporation licenses this file to you under the MIT license.
|
||||
// See the LICENSE file in the project root for more information.
|
||||
|
||||
namespace Microsoft.CmdPal.Common.Services.HttpCaching.Abstraction;
|
||||
|
||||
internal interface IHttpResourceCacheStore
|
||||
{
|
||||
CachedHttpResourceEntry GetEntry(Uri resourceUri, string? fileNameHint = null);
|
||||
|
||||
CachedHttpResource? TryGetFresh(CachedHttpResourceEntry entry, TimeSpan? timeToLiveOverride);
|
||||
|
||||
CachedHttpResource? TryGetCached(CachedHttpResourceEntry entry, bool fromCache, bool wasRevalidated);
|
||||
|
||||
CachedHttpResource? UpdateAfterNotModified(CachedHttpResourceEntry entry, HttpResponseMessage response);
|
||||
|
||||
Task<CachedHttpResource> SaveResponseAsync(CachedHttpResourceEntry entry, HttpResponseMessage response, CancellationToken cancellationToken);
|
||||
|
||||
void Prune(IEnumerable<Uri> retainedResourceUris);
|
||||
}
|
||||
@@ -0,0 +1,375 @@
|
||||
// Copyright (c) Microsoft Corporation
|
||||
// The Microsoft Corporation licenses this file to you under the MIT license.
|
||||
// See the LICENSE file in the project root for more information.
|
||||
|
||||
using System.Net;
|
||||
using System.Security.Cryptography;
|
||||
using System.Text;
|
||||
using System.Text.Json;
|
||||
using Microsoft.CmdPal.Common.Services.HttpCaching.Abstraction;
|
||||
using Microsoft.Extensions.Logging;
|
||||
using MEL = Microsoft.Extensions.Logging;
|
||||
|
||||
namespace Microsoft.CmdPal.Common.Services.HttpCaching;
|
||||
|
||||
internal sealed class FileSystemHttpResourceCacheStore : IHttpResourceCacheStore
|
||||
{
|
||||
private const string MetadataFileName = "metadata.json";
|
||||
private const string DefaultPayloadFileName = "payload.bin";
|
||||
private static readonly Action<MEL.ILogger, string, Exception?> LogFailedToEnumerateHttpResourceCacheMessage = LoggerMessage.Define<string>(
|
||||
LogLevel.Error,
|
||||
new EventId(1, nameof(LogFailedToEnumerateHttpResourceCache)),
|
||||
"Failed to enumerate HTTP resource cache '{CacheDirectory}' for pruning.");
|
||||
|
||||
private static readonly Action<MEL.ILogger, string, Exception?> LogFailedToLoadCachedMetadataMessage = LoggerMessage.Define<string>(
|
||||
LogLevel.Error,
|
||||
new EventId(2, nameof(LogFailedToLoadCachedMetadata)),
|
||||
"Failed to load cached metadata from '{MetadataPath}'.");
|
||||
|
||||
private static readonly Action<MEL.ILogger, string, Exception?> LogFailedToSaveCachedMetadataMessage = LoggerMessage.Define<string>(
|
||||
LogLevel.Error,
|
||||
new EventId(3, nameof(LogFailedToSaveCachedMetadata)),
|
||||
"Failed to save cached metadata to '{MetadataPath}'.");
|
||||
|
||||
private static readonly Action<MEL.ILogger, string, Exception?> LogFailedToDeleteCachedHttpResourceDirectoryMessage = LoggerMessage.Define<string>(
|
||||
LogLevel.Error,
|
||||
new EventId(4, nameof(LogFailedToDeleteCachedHttpResourceDirectory)),
|
||||
"Failed to delete cached HTTP resource directory '{EntryDirectory}'.");
|
||||
|
||||
private readonly string _cacheDirectory;
|
||||
private readonly TimeSpan _defaultTimeToLive;
|
||||
private readonly MEL.ILogger _logger;
|
||||
|
||||
public FileSystemHttpResourceCacheStore(string cacheDirectory, TimeSpan defaultTimeToLive, MEL.ILogger logger)
|
||||
{
|
||||
ArgumentException.ThrowIfNullOrWhiteSpace(cacheDirectory);
|
||||
ArgumentNullException.ThrowIfNull(logger);
|
||||
|
||||
_cacheDirectory = cacheDirectory;
|
||||
_defaultTimeToLive = defaultTimeToLive;
|
||||
_logger = logger;
|
||||
|
||||
Directory.CreateDirectory(_cacheDirectory);
|
||||
}
|
||||
|
||||
public CachedHttpResourceEntry GetEntry(Uri resourceUri, string? fileNameHint = null)
|
||||
{
|
||||
ArgumentNullException.ThrowIfNull(resourceUri);
|
||||
|
||||
var entryDirectory = GetEntryDirectory(resourceUri);
|
||||
Directory.CreateDirectory(entryDirectory);
|
||||
|
||||
var metadataPath = Path.Combine(entryDirectory, MetadataFileName);
|
||||
var metadata = TryLoadMetadata(metadataPath);
|
||||
var payloadFileName = ResolvePayloadFileName(resourceUri, fileNameHint, metadata);
|
||||
var payloadPath = Path.Combine(entryDirectory, payloadFileName);
|
||||
|
||||
return new CachedHttpResourceEntry(
|
||||
resourceUri,
|
||||
entryDirectory,
|
||||
metadataPath,
|
||||
payloadPath,
|
||||
payloadFileName,
|
||||
metadata);
|
||||
}
|
||||
|
||||
public CachedHttpResource? TryGetFresh(CachedHttpResourceEntry entry, TimeSpan? timeToLiveOverride)
|
||||
{
|
||||
ArgumentNullException.ThrowIfNull(entry);
|
||||
|
||||
if (!File.Exists(entry.PayloadPath) || !IsFresh(entry.Metadata, timeToLiveOverride))
|
||||
{
|
||||
return null;
|
||||
}
|
||||
|
||||
return CreateCachedResource(entry.PayloadPath, entry.Metadata, fromCache: true, wasRevalidated: false);
|
||||
}
|
||||
|
||||
public CachedHttpResource? TryGetCached(CachedHttpResourceEntry entry, bool fromCache, bool wasRevalidated)
|
||||
{
|
||||
ArgumentNullException.ThrowIfNull(entry);
|
||||
|
||||
if (!File.Exists(entry.PayloadPath))
|
||||
{
|
||||
return null;
|
||||
}
|
||||
|
||||
return CreateCachedResource(entry.PayloadPath, entry.Metadata, fromCache, wasRevalidated);
|
||||
}
|
||||
|
||||
public CachedHttpResource? UpdateAfterNotModified(CachedHttpResourceEntry entry, HttpResponseMessage response)
|
||||
{
|
||||
ArgumentNullException.ThrowIfNull(entry);
|
||||
ArgumentNullException.ThrowIfNull(response);
|
||||
|
||||
if (!File.Exists(entry.PayloadPath))
|
||||
{
|
||||
return null;
|
||||
}
|
||||
|
||||
var refreshedMetadata = UpdateMetadata(entry.Metadata, entry.ResourceUri, response, entry.PayloadFileName, DateTimeOffset.UtcNow);
|
||||
TrySaveMetadata(entry.MetadataPath, refreshedMetadata);
|
||||
return CreateCachedResource(entry.PayloadPath, refreshedMetadata, fromCache: true, wasRevalidated: true);
|
||||
}
|
||||
|
||||
public async Task<CachedHttpResource> SaveResponseAsync(CachedHttpResourceEntry entry, HttpResponseMessage response, CancellationToken cancellationToken)
|
||||
{
|
||||
ArgumentNullException.ThrowIfNull(entry);
|
||||
ArgumentNullException.ThrowIfNull(response);
|
||||
ArgumentNullException.ThrowIfNull(response.Content);
|
||||
|
||||
var tempPath = Path.Combine(entry.EntryDirectory, $"{entry.PayloadFileName}.tmp");
|
||||
try
|
||||
{
|
||||
await using var sourceStream = await response.Content.ReadAsStreamAsync(cancellationToken);
|
||||
await using (var destinationStream = new FileStream(tempPath, FileMode.Create, FileAccess.Write, FileShare.None, 81920, FileOptions.Asynchronous))
|
||||
{
|
||||
await sourceStream.CopyToAsync(destinationStream, cancellationToken);
|
||||
}
|
||||
|
||||
File.Move(tempPath, entry.PayloadPath, overwrite: true);
|
||||
}
|
||||
finally
|
||||
{
|
||||
if (File.Exists(tempPath))
|
||||
{
|
||||
File.Delete(tempPath);
|
||||
}
|
||||
}
|
||||
|
||||
var updatedMetadata = UpdateMetadata(entry.Metadata, entry.ResourceUri, response, entry.PayloadFileName, DateTimeOffset.UtcNow);
|
||||
TrySaveMetadata(entry.MetadataPath, updatedMetadata);
|
||||
return CreateCachedResource(entry.PayloadPath, updatedMetadata, fromCache: false, wasRevalidated: entry.Metadata is not null);
|
||||
}
|
||||
|
||||
public void Prune(IEnumerable<Uri> retainedResourceUris)
|
||||
{
|
||||
ArgumentNullException.ThrowIfNull(retainedResourceUris);
|
||||
|
||||
HashSet<string> retainedEntryDirectories = new(StringComparer.OrdinalIgnoreCase);
|
||||
foreach (var retainedResourceUri in retainedResourceUris)
|
||||
{
|
||||
if (!IsSupportedHttpUri(retainedResourceUri))
|
||||
{
|
||||
continue;
|
||||
}
|
||||
|
||||
retainedEntryDirectories.Add(Path.GetFullPath(GetEntryDirectory(retainedResourceUri)));
|
||||
}
|
||||
|
||||
try
|
||||
{
|
||||
foreach (var entryDirectory in Directory.EnumerateDirectories(_cacheDirectory))
|
||||
{
|
||||
var fullEntryDirectory = Path.GetFullPath(entryDirectory);
|
||||
if (retainedEntryDirectories.Contains(fullEntryDirectory))
|
||||
{
|
||||
continue;
|
||||
}
|
||||
|
||||
TryDeleteEntryDirectory(fullEntryDirectory);
|
||||
}
|
||||
}
|
||||
catch (Exception ex) when (ex is IOException or UnauthorizedAccessException or DirectoryNotFoundException)
|
||||
{
|
||||
LogFailedToEnumerateHttpResourceCache(_logger, _cacheDirectory, ex);
|
||||
}
|
||||
}
|
||||
|
||||
private bool IsFresh(HttpResourceCacheMetadata? metadata, TimeSpan? timeToLiveOverride)
|
||||
{
|
||||
if (metadata is null)
|
||||
{
|
||||
return false;
|
||||
}
|
||||
|
||||
var now = DateTimeOffset.UtcNow;
|
||||
if (metadata.ExpiresUtc is { } expiresUtc)
|
||||
{
|
||||
return expiresUtc > now;
|
||||
}
|
||||
|
||||
var effectiveTimeToLive = timeToLiveOverride ?? _defaultTimeToLive;
|
||||
return metadata.LastValidatedUtc + effectiveTimeToLive > now;
|
||||
}
|
||||
|
||||
private static CachedHttpResource CreateCachedResource(
|
||||
string payloadPath,
|
||||
HttpResourceCacheMetadata? metadata,
|
||||
bool fromCache,
|
||||
bool wasRevalidated)
|
||||
{
|
||||
return new CachedHttpResource(
|
||||
payloadPath,
|
||||
metadata?.ContentType,
|
||||
fromCache,
|
||||
wasRevalidated);
|
||||
}
|
||||
|
||||
private static HttpResourceCacheMetadata UpdateMetadata(
|
||||
HttpResourceCacheMetadata? metadata,
|
||||
Uri resourceUri,
|
||||
HttpResponseMessage response,
|
||||
string payloadFileName,
|
||||
DateTimeOffset now)
|
||||
{
|
||||
return new HttpResourceCacheMetadata
|
||||
{
|
||||
ContentType = response.Content?.Headers.ContentType?.MediaType ?? metadata?.ContentType,
|
||||
ETag = response.Headers.ETag?.ToString() ?? metadata?.ETag,
|
||||
ExpiresUtc = GetExpirationUtc(response, now),
|
||||
FileName = payloadFileName,
|
||||
LastModifiedUtc = response.Content?.Headers.LastModified ?? metadata?.LastModifiedUtc,
|
||||
LastValidatedUtc = now,
|
||||
SourceUri = resourceUri.AbsoluteUri,
|
||||
};
|
||||
}
|
||||
|
||||
private static DateTimeOffset? GetExpirationUtc(HttpResponseMessage response, DateTimeOffset now)
|
||||
{
|
||||
if (response.Headers.CacheControl?.MaxAge is { } maxAge)
|
||||
{
|
||||
return now + maxAge;
|
||||
}
|
||||
|
||||
return response.Content?.Headers.Expires;
|
||||
}
|
||||
|
||||
private string GetEntryDirectory(Uri resourceUri)
|
||||
{
|
||||
var normalizedResourceName = BuildEntryName(resourceUri);
|
||||
if (normalizedResourceName.Length > 48)
|
||||
{
|
||||
normalizedResourceName = normalizedResourceName[..48];
|
||||
}
|
||||
|
||||
var hash = Convert.ToHexString(SHA256.HashData(Encoding.UTF8.GetBytes(resourceUri.AbsoluteUri)));
|
||||
return Path.Combine(_cacheDirectory, $"{normalizedResourceName}_{hash}");
|
||||
}
|
||||
|
||||
private static string BuildEntryName(Uri resourceUri)
|
||||
{
|
||||
var host = SanitizeFileName(resourceUri.Host);
|
||||
var fileName = SanitizeFileName(Path.GetFileName(Uri.UnescapeDataString(resourceUri.AbsolutePath)));
|
||||
|
||||
if (string.IsNullOrWhiteSpace(fileName))
|
||||
{
|
||||
fileName = DefaultPayloadFileName;
|
||||
}
|
||||
|
||||
if (string.IsNullOrWhiteSpace(host))
|
||||
{
|
||||
return fileName;
|
||||
}
|
||||
|
||||
return $"{host}_{fileName}";
|
||||
}
|
||||
|
||||
private static string ResolvePayloadFileName(Uri resourceUri, string? fileNameHint, HttpResourceCacheMetadata? metadata)
|
||||
{
|
||||
var candidate = metadata?.FileName;
|
||||
if (!string.IsNullOrWhiteSpace(candidate))
|
||||
{
|
||||
return candidate;
|
||||
}
|
||||
|
||||
candidate = fileNameHint;
|
||||
if (string.IsNullOrWhiteSpace(candidate))
|
||||
{
|
||||
candidate = Path.GetFileName(Uri.UnescapeDataString(resourceUri.AbsolutePath));
|
||||
}
|
||||
|
||||
candidate = SanitizeFileName(candidate);
|
||||
return string.IsNullOrWhiteSpace(candidate) ? DefaultPayloadFileName : candidate;
|
||||
}
|
||||
|
||||
private static string SanitizeFileName(string? value)
|
||||
{
|
||||
if (string.IsNullOrWhiteSpace(value))
|
||||
{
|
||||
return string.Empty;
|
||||
}
|
||||
|
||||
StringBuilder builder = new(value.Length);
|
||||
for (var i = 0; i < value.Length; i++)
|
||||
{
|
||||
var current = value[i];
|
||||
builder.Append(Path.GetInvalidFileNameChars().Contains(current) ? '_' : current);
|
||||
}
|
||||
|
||||
return builder
|
||||
.ToString()
|
||||
.Trim()
|
||||
.Trim('.', ' ');
|
||||
}
|
||||
|
||||
private static bool IsSupportedHttpUri(Uri resourceUri)
|
||||
{
|
||||
return resourceUri.Scheme.Equals(Uri.UriSchemeHttp, StringComparison.OrdinalIgnoreCase)
|
||||
|| resourceUri.Scheme.Equals(Uri.UriSchemeHttps, StringComparison.OrdinalIgnoreCase);
|
||||
}
|
||||
|
||||
private HttpResourceCacheMetadata? TryLoadMetadata(string metadataPath)
|
||||
{
|
||||
try
|
||||
{
|
||||
if (!File.Exists(metadataPath))
|
||||
{
|
||||
return null;
|
||||
}
|
||||
|
||||
var json = File.ReadAllText(metadataPath);
|
||||
return JsonSerializer.Deserialize(json, HttpResourceCacheJsonContext.Default.HttpResourceCacheMetadata) as HttpResourceCacheMetadata;
|
||||
}
|
||||
catch (Exception ex) when (ex is IOException or JsonException)
|
||||
{
|
||||
LogFailedToLoadCachedMetadata(_logger, metadataPath, ex);
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
private void TrySaveMetadata(string metadataPath, HttpResourceCacheMetadata metadata)
|
||||
{
|
||||
try
|
||||
{
|
||||
var json = JsonSerializer.Serialize(metadata, HttpResourceCacheJsonContext.Default.HttpResourceCacheMetadata);
|
||||
File.WriteAllText(metadataPath, json);
|
||||
}
|
||||
catch (Exception ex) when (ex is IOException or UnauthorizedAccessException)
|
||||
{
|
||||
LogFailedToSaveCachedMetadata(_logger, metadataPath, ex);
|
||||
}
|
||||
}
|
||||
|
||||
private void TryDeleteEntryDirectory(string entryDirectory)
|
||||
{
|
||||
try
|
||||
{
|
||||
Directory.Delete(entryDirectory, recursive: true);
|
||||
}
|
||||
catch (Exception ex) when (ex is IOException or UnauthorizedAccessException or DirectoryNotFoundException)
|
||||
{
|
||||
LogFailedToDeleteCachedHttpResourceDirectory(_logger, entryDirectory, ex);
|
||||
}
|
||||
}
|
||||
|
||||
private static void LogFailedToEnumerateHttpResourceCache(MEL.ILogger logger, string cacheDirectory, Exception exception)
|
||||
{
|
||||
LogFailedToEnumerateHttpResourceCacheMessage(logger, cacheDirectory, exception);
|
||||
}
|
||||
|
||||
private static void LogFailedToLoadCachedMetadata(MEL.ILogger logger, string metadataPath, Exception exception)
|
||||
{
|
||||
LogFailedToLoadCachedMetadataMessage(logger, metadataPath, exception);
|
||||
}
|
||||
|
||||
private static void LogFailedToSaveCachedMetadata(MEL.ILogger logger, string metadataPath, Exception exception)
|
||||
{
|
||||
LogFailedToSaveCachedMetadataMessage(logger, metadataPath, exception);
|
||||
}
|
||||
|
||||
private static void LogFailedToDeleteCachedHttpResourceDirectory(MEL.ILogger logger, string entryDirectory, Exception exception)
|
||||
{
|
||||
LogFailedToDeleteCachedHttpResourceDirectoryMessage(logger, entryDirectory, exception);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,104 @@
|
||||
// Copyright (c) Microsoft Corporation
|
||||
// The Microsoft Corporation licenses this file to you under the MIT license.
|
||||
// See the LICENSE file in the project root for more information.
|
||||
|
||||
using Microsoft.CmdPal.Common.Services.HttpCaching.Abstraction;
|
||||
using MEL = Microsoft.Extensions.Logging;
|
||||
|
||||
namespace Microsoft.CmdPal.Common.Services.HttpCaching;
|
||||
|
||||
internal sealed partial class HttpCachingClient : IDisposable
|
||||
{
|
||||
private readonly HttpClient _httpClient;
|
||||
private readonly IHttpResourceCacheStore _cacheStore;
|
||||
private readonly HttpResourceCacheHandler _cacheHandler;
|
||||
|
||||
public HttpCachingClient(
|
||||
string cacheDirectory,
|
||||
TimeSpan defaultTimeToLive,
|
||||
TimeSpan timeout,
|
||||
string? userAgent,
|
||||
HttpMessageHandler? innerHandler,
|
||||
MEL.ILogger logger)
|
||||
: this(
|
||||
new FileSystemHttpResourceCacheStore(cacheDirectory, defaultTimeToLive, logger),
|
||||
timeout,
|
||||
userAgent,
|
||||
innerHandler,
|
||||
logger)
|
||||
{
|
||||
}
|
||||
|
||||
public HttpCachingClient(
|
||||
IHttpResourceCacheStore cacheStore,
|
||||
TimeSpan timeout,
|
||||
string? userAgent,
|
||||
HttpMessageHandler? innerHandler,
|
||||
MEL.ILogger logger)
|
||||
{
|
||||
ArgumentNullException.ThrowIfNull(cacheStore);
|
||||
ArgumentNullException.ThrowIfNull(logger);
|
||||
|
||||
_cacheStore = cacheStore;
|
||||
_cacheHandler = new HttpResourceCacheHandler(cacheStore, innerHandler ?? new HttpClientHandler(), logger);
|
||||
_httpClient = new HttpClient(_cacheHandler) { Timeout = timeout };
|
||||
|
||||
if (!string.IsNullOrWhiteSpace(userAgent))
|
||||
{
|
||||
_httpClient.DefaultRequestHeaders.UserAgent.ParseAdd(userAgent);
|
||||
}
|
||||
}
|
||||
|
||||
public async Task<CachedHttpFetchResult> GetResourceAsync(
|
||||
Uri resourceUri,
|
||||
string? fileNameHint = null,
|
||||
bool forceRefresh = false,
|
||||
TimeSpan? timeToLiveOverride = null,
|
||||
CancellationToken cancellationToken = default)
|
||||
{
|
||||
ArgumentNullException.ThrowIfNull(resourceUri);
|
||||
|
||||
if (!IsSupportedHttpUri(resourceUri))
|
||||
{
|
||||
throw new InvalidOperationException($"Unsupported HTTP resource URI scheme '{resourceUri.Scheme}'.");
|
||||
}
|
||||
|
||||
using var request = new HttpRequestMessage(HttpMethod.Get, resourceUri);
|
||||
HttpResourceCacheHandler.ConfigureRequest(
|
||||
request,
|
||||
fileNameHint: fileNameHint,
|
||||
forceRefresh: forceRefresh,
|
||||
timeToLiveOverride: timeToLiveOverride);
|
||||
|
||||
using var response = await _httpClient.SendAsync(request, HttpCompletionOption.ResponseHeadersRead, cancellationToken);
|
||||
response.EnsureSuccessStatusCode();
|
||||
|
||||
var cacheInfo = HttpResourceCacheHandler.GetResponseInfo(response);
|
||||
if (cacheInfo.Resource is null)
|
||||
{
|
||||
throw new InvalidOperationException($"The HTTP cache did not produce a cached resource for '{resourceUri}'.");
|
||||
}
|
||||
|
||||
return new CachedHttpFetchResult(cacheInfo.Resource, cacheInfo.UsedFallbackCache);
|
||||
}
|
||||
|
||||
public void Prune(IEnumerable<Uri> retainedResourceUris)
|
||||
{
|
||||
ArgumentNullException.ThrowIfNull(retainedResourceUris);
|
||||
|
||||
List<Uri> retainedUris = [.. retainedResourceUris];
|
||||
_cacheHandler.AddInflightResourceUris(retainedUris);
|
||||
_cacheStore.Prune(retainedUris);
|
||||
}
|
||||
|
||||
public void Dispose()
|
||||
{
|
||||
_httpClient.Dispose();
|
||||
}
|
||||
|
||||
private static bool IsSupportedHttpUri(Uri resourceUri)
|
||||
{
|
||||
return resourceUri.Scheme.Equals(Uri.UriSchemeHttp, StringComparison.OrdinalIgnoreCase)
|
||||
|| resourceUri.Scheme.Equals(Uri.UriSchemeHttps, StringComparison.OrdinalIgnoreCase);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,284 @@
|
||||
// Copyright (c) Microsoft Corporation
|
||||
// The Microsoft Corporation licenses this file to you under the MIT license.
|
||||
// See the LICENSE file in the project root for more information.
|
||||
|
||||
using System.Diagnostics.CodeAnalysis;
|
||||
using System.Net;
|
||||
using System.Net.Http.Headers;
|
||||
using Microsoft.CmdPal.Common.Services.HttpCaching.Abstraction;
|
||||
using Microsoft.Extensions.Logging;
|
||||
using MEL = Microsoft.Extensions.Logging;
|
||||
|
||||
namespace Microsoft.CmdPal.Common.Services.HttpCaching;
|
||||
|
||||
internal sealed partial class HttpResourceCacheHandler : DelegatingHandler
|
||||
{
|
||||
private static readonly HttpRequestOptionsKey<HttpResourceCacheRequestOptions> RequestOptionsKey = new("CmdPal.HttpResourceCache.RequestOptions");
|
||||
private static readonly HttpRequestOptionsKey<CachedHttpResponseInfo> ResponseInfoKey = new("CmdPal.HttpResourceCache.ResponseInfo");
|
||||
private static readonly Action<MEL.ILogger, string, Exception?> LogFailedToCacheHttpResourceMessage = LoggerMessage.Define<string>(
|
||||
LogLevel.Error,
|
||||
new EventId(0, nameof(LogFailedToCacheHttpResource)),
|
||||
"Failed to cache HTTP resource '{ResourceUri}'.");
|
||||
|
||||
private readonly IHttpResourceCacheStore _cacheStore;
|
||||
private readonly MEL.ILogger _logger;
|
||||
private readonly Lock _lock = new();
|
||||
private readonly Dictionary<string, Task<CachedHttpResponseInfo?>> _inflightFetches = new(StringComparer.Ordinal);
|
||||
|
||||
public HttpResourceCacheHandler(IHttpResourceCacheStore cacheStore, HttpMessageHandler innerHandler, MEL.ILogger logger)
|
||||
{
|
||||
ArgumentNullException.ThrowIfNull(cacheStore);
|
||||
ArgumentNullException.ThrowIfNull(innerHandler);
|
||||
ArgumentNullException.ThrowIfNull(logger);
|
||||
|
||||
_cacheStore = cacheStore;
|
||||
_logger = logger;
|
||||
InnerHandler = innerHandler;
|
||||
}
|
||||
|
||||
public static void ConfigureRequest(
|
||||
HttpRequestMessage request,
|
||||
string? fileNameHint = null,
|
||||
bool forceRefresh = false,
|
||||
TimeSpan? timeToLiveOverride = null)
|
||||
{
|
||||
ArgumentNullException.ThrowIfNull(request);
|
||||
|
||||
request.Options.Set(
|
||||
RequestOptionsKey,
|
||||
new HttpResourceCacheRequestOptions(fileNameHint, forceRefresh, timeToLiveOverride));
|
||||
}
|
||||
|
||||
public static CachedHttpResponseInfo GetResponseInfo(HttpResponseMessage response)
|
||||
{
|
||||
ArgumentNullException.ThrowIfNull(response);
|
||||
|
||||
return response.RequestMessage?.Options.TryGetValue(ResponseInfoKey, out var responseInfo) == true
|
||||
? responseInfo
|
||||
: CachedHttpResponseInfo.None;
|
||||
}
|
||||
|
||||
public static bool TryGetResponseInfo(HttpResponseMessage response, [NotNullWhen(true)] out CachedHttpResponseInfo? responseInfo)
|
||||
{
|
||||
ArgumentNullException.ThrowIfNull(response);
|
||||
if (response.RequestMessage?.Options.TryGetValue(ResponseInfoKey, out responseInfo) == true)
|
||||
{
|
||||
return true;
|
||||
}
|
||||
|
||||
responseInfo = null;
|
||||
return false;
|
||||
}
|
||||
|
||||
internal void AddInflightResourceUris(ICollection<Uri> retainedResourceUris)
|
||||
{
|
||||
ArgumentNullException.ThrowIfNull(retainedResourceUris);
|
||||
|
||||
lock (_lock)
|
||||
{
|
||||
foreach (var inflightKey in _inflightFetches.Keys)
|
||||
{
|
||||
if (!Uri.TryCreate(inflightKey, UriKind.Absolute, out var inflightUri))
|
||||
{
|
||||
continue;
|
||||
}
|
||||
|
||||
retainedResourceUris.Add(inflightUri);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
protected async override Task<HttpResponseMessage> SendAsync(HttpRequestMessage request, CancellationToken cancellationToken)
|
||||
{
|
||||
ArgumentNullException.ThrowIfNull(request);
|
||||
|
||||
if (!CanCache(request))
|
||||
{
|
||||
return await base.SendAsync(request, cancellationToken);
|
||||
}
|
||||
|
||||
var options = request.Options.TryGetValue(RequestOptionsKey, out var requestOptions)
|
||||
? requestOptions
|
||||
: HttpResourceCacheRequestOptions.Default;
|
||||
var fetchResult = await GetOrFetchAsync(request, options, cancellationToken);
|
||||
if (fetchResult?.Resource is null)
|
||||
{
|
||||
throw new HttpRequestException($"Could not reach HTTP resource '{request.RequestUri}'.");
|
||||
}
|
||||
|
||||
return CreateResponse(request, fetchResult);
|
||||
}
|
||||
|
||||
private Task<CachedHttpResponseInfo?> GetOrFetchAsync(
|
||||
HttpRequestMessage request,
|
||||
HttpResourceCacheRequestOptions options,
|
||||
CancellationToken cancellationToken)
|
||||
{
|
||||
var inflightKey = request.RequestUri!.AbsoluteUri;
|
||||
|
||||
lock (_lock)
|
||||
{
|
||||
if (_inflightFetches.TryGetValue(inflightKey, out var existingTask))
|
||||
{
|
||||
return existingTask;
|
||||
}
|
||||
|
||||
var fetchTask = GetOrFetchCoreAsync(request, options, cancellationToken);
|
||||
_inflightFetches[inflightKey] = fetchTask;
|
||||
|
||||
_ = fetchTask.ContinueWith(
|
||||
_ =>
|
||||
{
|
||||
lock (_lock)
|
||||
{
|
||||
_inflightFetches.Remove(inflightKey);
|
||||
}
|
||||
},
|
||||
CancellationToken.None,
|
||||
TaskContinuationOptions.None,
|
||||
TaskScheduler.Default);
|
||||
|
||||
return fetchTask;
|
||||
}
|
||||
}
|
||||
|
||||
private async Task<CachedHttpResponseInfo?> GetOrFetchCoreAsync(
|
||||
HttpRequestMessage request,
|
||||
HttpResourceCacheRequestOptions options,
|
||||
CancellationToken cancellationToken)
|
||||
{
|
||||
var entry = _cacheStore.GetEntry(request.RequestUri!, options.FileNameHint);
|
||||
if (!options.ForceRefresh && _cacheStore.TryGetFresh(entry, options.TimeToLiveOverride) is { } freshResource)
|
||||
{
|
||||
return new CachedHttpResponseInfo(freshResource, usedFallbackCache: false);
|
||||
}
|
||||
|
||||
try
|
||||
{
|
||||
using var networkRequest = CloneRequest(request, entry.Metadata);
|
||||
using var response = await base.SendAsync(networkRequest, cancellationToken);
|
||||
|
||||
if (response.StatusCode == HttpStatusCode.NotModified)
|
||||
{
|
||||
var revalidatedResource = _cacheStore.UpdateAfterNotModified(entry, response);
|
||||
return revalidatedResource is null
|
||||
? null
|
||||
: new CachedHttpResponseInfo(revalidatedResource, usedFallbackCache: false);
|
||||
}
|
||||
|
||||
response.EnsureSuccessStatusCode();
|
||||
var cachedResource = await _cacheStore.SaveResponseAsync(entry, response, cancellationToken);
|
||||
return new CachedHttpResponseInfo(cachedResource, usedFallbackCache: false);
|
||||
}
|
||||
catch (OperationCanceledException)
|
||||
{
|
||||
throw;
|
||||
}
|
||||
catch (HttpRequestException ex)
|
||||
{
|
||||
LogFailedToCacheHttpResource(_logger, request.RequestUri!.AbsoluteUri, ex);
|
||||
|
||||
var cachedResource = _cacheStore.TryGetCached(entry, fromCache: true, wasRevalidated: false);
|
||||
if (cachedResource is not null)
|
||||
{
|
||||
return new CachedHttpResponseInfo(cachedResource, usedFallbackCache: true);
|
||||
}
|
||||
|
||||
throw;
|
||||
}
|
||||
catch (IOException ex)
|
||||
{
|
||||
LogFailedToCacheHttpResource(_logger, request.RequestUri!.AbsoluteUri, ex);
|
||||
|
||||
var cachedResource = _cacheStore.TryGetCached(entry, fromCache: true, wasRevalidated: false);
|
||||
if (cachedResource is not null)
|
||||
{
|
||||
return new CachedHttpResponseInfo(cachedResource, usedFallbackCache: true);
|
||||
}
|
||||
|
||||
throw new HttpRequestException($"Could not reach HTTP resource '{request.RequestUri}'.", ex);
|
||||
}
|
||||
}
|
||||
|
||||
private static HttpResponseMessage CreateResponse(HttpRequestMessage request, CachedHttpResponseInfo responseInfo)
|
||||
{
|
||||
var contentStream = new FileStream(
|
||||
responseInfo.Resource!.ContentPath,
|
||||
FileMode.Open,
|
||||
FileAccess.Read,
|
||||
FileShare.Read,
|
||||
81920,
|
||||
FileOptions.Asynchronous | FileOptions.SequentialScan);
|
||||
var response = new HttpResponseMessage(HttpStatusCode.OK)
|
||||
{
|
||||
Content = new StreamContent(contentStream),
|
||||
RequestMessage = request,
|
||||
};
|
||||
|
||||
if (!string.IsNullOrWhiteSpace(responseInfo.Resource.ContentType))
|
||||
{
|
||||
response.Content.Headers.ContentType = MediaTypeHeaderValue.Parse(responseInfo.Resource.ContentType);
|
||||
}
|
||||
|
||||
request.Options.Set(ResponseInfoKey, responseInfo);
|
||||
return response;
|
||||
}
|
||||
|
||||
private static HttpRequestMessage CloneRequest(HttpRequestMessage request, HttpResourceCacheMetadata? metadata)
|
||||
{
|
||||
var clone = new HttpRequestMessage(request.Method, request.RequestUri)
|
||||
{
|
||||
Version = request.Version,
|
||||
VersionPolicy = request.VersionPolicy,
|
||||
};
|
||||
|
||||
foreach (var header in request.Headers)
|
||||
{
|
||||
clone.Headers.TryAddWithoutValidation(header.Key, header.Value);
|
||||
}
|
||||
|
||||
if (!string.IsNullOrWhiteSpace(metadata?.ETag) && !clone.Headers.Contains("If-None-Match"))
|
||||
{
|
||||
clone.Headers.TryAddWithoutValidation("If-None-Match", metadata.ETag);
|
||||
}
|
||||
|
||||
if (metadata?.LastModifiedUtc is { } lastModifiedUtc && clone.Headers.IfModifiedSince is null)
|
||||
{
|
||||
clone.Headers.IfModifiedSince = lastModifiedUtc;
|
||||
}
|
||||
|
||||
return clone;
|
||||
}
|
||||
|
||||
private static bool CanCache(HttpRequestMessage request)
|
||||
{
|
||||
return request.Method == HttpMethod.Get
|
||||
&& request.RequestUri is { } requestUri
|
||||
&& IsSupportedHttpUri(requestUri);
|
||||
}
|
||||
|
||||
private static bool IsSupportedHttpUri(Uri resourceUri)
|
||||
{
|
||||
return resourceUri.Scheme.Equals(Uri.UriSchemeHttp, StringComparison.OrdinalIgnoreCase)
|
||||
|| resourceUri.Scheme.Equals(Uri.UriSchemeHttps, StringComparison.OrdinalIgnoreCase);
|
||||
}
|
||||
|
||||
private static void LogFailedToCacheHttpResource(MEL.ILogger logger, string resourceUri, Exception exception)
|
||||
{
|
||||
LogFailedToCacheHttpResourceMessage(logger, resourceUri, exception);
|
||||
}
|
||||
|
||||
private sealed record HttpResourceCacheRequestOptions(string? FileNameHint, bool ForceRefresh, TimeSpan? TimeToLiveOverride)
|
||||
{
|
||||
public static HttpResourceCacheRequestOptions Default { get; } = new(FileNameHint: null, ForceRefresh: false, TimeToLiveOverride: null);
|
||||
}
|
||||
|
||||
internal sealed class CachedHttpResponseInfo(CachedHttpResource? resource, bool usedFallbackCache)
|
||||
{
|
||||
public static CachedHttpResponseInfo None { get; } = new(resource: null, usedFallbackCache: false);
|
||||
|
||||
public CachedHttpResource? Resource { get; } = resource;
|
||||
|
||||
public bool UsedFallbackCache { get; } = usedFallbackCache;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,13 @@
|
||||
// Copyright (c) Microsoft Corporation
|
||||
// The Microsoft Corporation licenses this file to you under the MIT license.
|
||||
// See the LICENSE file in the project root for more information.
|
||||
|
||||
using System.Text.Json.Serialization;
|
||||
using Microsoft.CmdPal.Common.Services.HttpCaching.Abstraction;
|
||||
|
||||
namespace Microsoft.CmdPal.Common.Services.HttpCaching;
|
||||
|
||||
[JsonSerializable(typeof(HttpResourceCacheMetadata))]
|
||||
internal sealed partial class HttpResourceCacheJsonContext : JsonSerializerContext
|
||||
{
|
||||
}
|
||||
@@ -29,6 +29,12 @@ public interface IApplicationInfoService
|
||||
/// </summary>
|
||||
string ConfigDirectory { get; }
|
||||
|
||||
/// <summary>
|
||||
/// Gets the directory path where application cache files are stored.
|
||||
/// This location should be safe to recreate and should not be used for durable settings.
|
||||
/// </summary>
|
||||
string CacheDirectory { get; }
|
||||
|
||||
/// <summary>
|
||||
/// Gets a value indicating whether the application is running with administrator privileges.
|
||||
/// </summary>
|
||||
|
||||
@@ -8,21 +8,61 @@ namespace Microsoft.CmdPal.Common.Services;
|
||||
|
||||
public interface IExtensionService
|
||||
{
|
||||
/// <summary>
|
||||
/// Gets the currently cached installed Command Palette extensions.
|
||||
/// </summary>
|
||||
/// <param name="includeDisabledExtensions">True to include disabled extensions in the result.</param>
|
||||
/// <returns>A sequence of installed Command Palette extensions from the current in-memory cache.</returns>
|
||||
Task<IEnumerable<IExtensionWrapper>> GetInstalledExtensionsAsync(bool includeDisabledExtensions = false);
|
||||
|
||||
/// <summary>
|
||||
/// Forces a fresh scan of installed Command Palette extensions and updates the in-memory cache.
|
||||
/// </summary>
|
||||
/// <param name="includeDisabledExtensions">True to include disabled extensions in the result.</param>
|
||||
/// <returns>A sequence of installed Command Palette extensions after the cache has been rebuilt.</returns>
|
||||
Task<IEnumerable<IExtensionWrapper>> RefreshInstalledExtensionsAsync(bool includeDisabledExtensions = false);
|
||||
|
||||
// Task<IEnumerable<string>> GetInstalledHomeWidgetPackageFamilyNamesAsync(bool includeDisabledExtensions = false);
|
||||
/// <summary>
|
||||
/// Gets the installed Command Palette extensions for a specific provider type.
|
||||
/// </summary>
|
||||
/// <param name="providerType">The provider type to match.</param>
|
||||
/// <param name="includeDisabledExtensions">True to include disabled extensions in the result.</param>
|
||||
/// <returns>A sequence of installed Command Palette extensions for the requested provider type.</returns>
|
||||
Task<IEnumerable<IExtensionWrapper>> GetInstalledExtensionsAsync(Microsoft.CommandPalette.Extensions.ProviderType providerType, bool includeDisabledExtensions = false);
|
||||
|
||||
/// <summary>
|
||||
/// Gets a cached installed extension by its unique id.
|
||||
/// </summary>
|
||||
/// <param name="extensionUniqueId">The unique id of the extension to look up.</param>
|
||||
/// <returns>The cached extension if found; otherwise, null.</returns>
|
||||
IExtensionWrapper? GetInstalledExtension(string extensionUniqueId);
|
||||
|
||||
/// <summary>
|
||||
/// Signals running extensions to stop.
|
||||
/// </summary>
|
||||
Task SignalStopExtensionsAsync();
|
||||
|
||||
/// <summary>
|
||||
/// Raised when one or more extensions are added to the installed set.
|
||||
/// </summary>
|
||||
event TypedEventHandler<IExtensionService, IEnumerable<IExtensionWrapper>>? OnExtensionAdded;
|
||||
|
||||
/// <summary>
|
||||
/// Raised when one or more extensions are removed from the installed set.
|
||||
/// </summary>
|
||||
event TypedEventHandler<IExtensionService, IEnumerable<IExtensionWrapper>>? OnExtensionRemoved;
|
||||
|
||||
/// <summary>
|
||||
/// Enables an installed extension by unique id.
|
||||
/// </summary>
|
||||
/// <param name="extensionUniqueId">The unique id of the extension to enable.</param>
|
||||
void EnableExtension(string extensionUniqueId);
|
||||
|
||||
/// <summary>
|
||||
/// Disables an installed extension by unique id.
|
||||
/// </summary>
|
||||
/// <param name="extensionUniqueId">The unique id of the extension to disable.</param>
|
||||
void DisableExtension(string extensionUniqueId);
|
||||
|
||||
///// <summary>
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
@@ -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.");
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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,
|
||||
}
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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);
|
||||
@@ -0,0 +1,9 @@
|
||||
// Copyright (c) Microsoft Corporation
|
||||
// The Microsoft Corporation licenses this file to you under the MIT license.
|
||||
// See the LICENSE file in the project root for more information.
|
||||
|
||||
namespace Microsoft.CmdPal.Common.WinGet.Models;
|
||||
|
||||
public sealed record WinGetNamedLink(
|
||||
string Label,
|
||||
string Url);
|
||||
@@ -0,0 +1,23 @@
|
||||
// Copyright (c) Microsoft Corporation
|
||||
// The Microsoft Corporation licenses this file to you under the MIT license.
|
||||
// See the LICENSE file in the project root for more information.
|
||||
|
||||
namespace Microsoft.CmdPal.Common.WinGet.Models;
|
||||
|
||||
public sealed record WinGetPackageDetails(
|
||||
string? Name,
|
||||
string? Version,
|
||||
string? Summary,
|
||||
string? Description,
|
||||
string? Publisher,
|
||||
string? PublisherUrl,
|
||||
string? PublisherSupportUrl,
|
||||
string? Author,
|
||||
string? License,
|
||||
string? LicenseUrl,
|
||||
string? PackageUrl,
|
||||
string? ReleaseNotes,
|
||||
string? ReleaseNotesUrl,
|
||||
string? IconUrl,
|
||||
IReadOnlyList<WinGetNamedLink> DocumentationLinks,
|
||||
IReadOnlyList<string> Tags);
|
||||
@@ -0,0 +1,9 @@
|
||||
// Copyright (c) Microsoft Corporation
|
||||
// The Microsoft Corporation licenses this file to you under the MIT license.
|
||||
// See the LICENSE file in the project root for more information.
|
||||
|
||||
namespace Microsoft.CmdPal.Common.WinGet.Models;
|
||||
|
||||
public sealed record WinGetPackageInfo(
|
||||
WinGetPackageStatus Status,
|
||||
WinGetPackageDetails? Details);
|
||||
@@ -0,0 +1,27 @@
|
||||
// Copyright (c) Microsoft Corporation
|
||||
// The Microsoft Corporation licenses this file to you under the MIT license.
|
||||
// See the LICENSE file in the project root for more information.
|
||||
|
||||
namespace Microsoft.CmdPal.Common.WinGet.Models;
|
||||
|
||||
public sealed record WinGetPackageOperation(
|
||||
Guid OperationId,
|
||||
string PackageId,
|
||||
string PackageName,
|
||||
WinGetPackageOperationKind Kind,
|
||||
WinGetPackageOperationState State,
|
||||
bool CanCancel,
|
||||
bool IsIndeterminate,
|
||||
uint? ProgressPercent,
|
||||
ulong? BytesDownloaded,
|
||||
ulong? BytesRequired,
|
||||
string? ErrorMessage,
|
||||
DateTimeOffset StartedAt,
|
||||
DateTimeOffset UpdatedAt,
|
||||
DateTimeOffset? CompletedAt)
|
||||
{
|
||||
public bool IsCompleted =>
|
||||
State is WinGetPackageOperationState.Succeeded
|
||||
or WinGetPackageOperationState.Failed
|
||||
or WinGetPackageOperationState.Canceled;
|
||||
}
|
||||
@@ -0,0 +1,11 @@
|
||||
// Copyright (c) Microsoft Corporation
|
||||
// The Microsoft Corporation licenses this file to you under the MIT license.
|
||||
// See the LICENSE file in the project root for more information.
|
||||
|
||||
namespace Microsoft.CmdPal.Common.WinGet.Models;
|
||||
|
||||
public enum WinGetPackageOperationKind
|
||||
{
|
||||
Install = 0,
|
||||
Uninstall = 1,
|
||||
}
|
||||
@@ -0,0 +1,10 @@
|
||||
// Copyright (c) Microsoft Corporation
|
||||
// The Microsoft Corporation licenses this file to you under the MIT license.
|
||||
// See the LICENSE file in the project root for more information.
|
||||
|
||||
namespace Microsoft.CmdPal.Common.WinGet.Models;
|
||||
|
||||
public sealed record WinGetPackageOperationResult(
|
||||
bool Succeeded,
|
||||
bool IsUnavailable,
|
||||
string? ErrorMessage);
|
||||
@@ -0,0 +1,17 @@
|
||||
// Copyright (c) Microsoft Corporation
|
||||
// The Microsoft Corporation licenses this file to you under the MIT license.
|
||||
// See the LICENSE file in the project root for more information.
|
||||
|
||||
namespace Microsoft.CmdPal.Common.WinGet.Models;
|
||||
|
||||
public enum WinGetPackageOperationState
|
||||
{
|
||||
Queued = 0,
|
||||
Downloading = 1,
|
||||
Installing = 2,
|
||||
Uninstalling = 3,
|
||||
PostProcessing = 4,
|
||||
Succeeded = 5,
|
||||
Failed = 6,
|
||||
Canceled = 7,
|
||||
}
|
||||
@@ -0,0 +1,11 @@
|
||||
// Copyright (c) Microsoft Corporation
|
||||
// The Microsoft Corporation licenses this file to you under the MIT license.
|
||||
// See the LICENSE file in the project root for more information.
|
||||
|
||||
namespace Microsoft.CmdPal.Common.WinGet.Models;
|
||||
|
||||
public sealed record WinGetPackageStatus(
|
||||
bool IsInstalled,
|
||||
bool IsInstalledStateKnown,
|
||||
bool IsUpdateAvailable,
|
||||
bool IsUpdateStateKnown);
|
||||
@@ -0,0 +1,14 @@
|
||||
// Copyright (c) Microsoft Corporation
|
||||
// The Microsoft Corporation licenses this file to you under the MIT license.
|
||||
// See the LICENSE file in the project root for more information.
|
||||
|
||||
namespace Microsoft.CmdPal.Common.WinGet.Models;
|
||||
|
||||
[System.Diagnostics.CodeAnalysis.SuppressMessage("StyleCop.CSharp.MaintainabilityRules", "SA1649:File name should match first type name", Justification = "Generic result type.")]
|
||||
public sealed record WinGetQueryResult<T>(
|
||||
T? Value,
|
||||
bool IsUnavailable,
|
||||
string? ErrorMessage)
|
||||
{
|
||||
public bool IsSuccess => string.IsNullOrWhiteSpace(ErrorMessage);
|
||||
}
|
||||
@@ -0,0 +1,9 @@
|
||||
// Copyright (c) Microsoft Corporation
|
||||
// The Microsoft Corporation licenses this file to you under the MIT license.
|
||||
// See the LICENSE file in the project root for more information.
|
||||
|
||||
namespace Microsoft.CmdPal.Common.WinGet.Models;
|
||||
|
||||
public sealed record WinGetServiceState(
|
||||
bool IsAvailable,
|
||||
string? Message);
|
||||
@@ -0,0 +1,44 @@
|
||||
// Copyright (c) Microsoft Corporation
|
||||
// The Microsoft Corporation licenses this file to you under the MIT license.
|
||||
// See the LICENSE file in the project root for more information.
|
||||
|
||||
using Microsoft.CmdPal.Common.WinGet.Models;
|
||||
|
||||
namespace Microsoft.CmdPal.Common.WinGet.Services;
|
||||
|
||||
public interface IWinGetOperationTrackerService
|
||||
{
|
||||
/// <summary>
|
||||
/// Gets the current and recently completed WinGet operations started by Command Palette.
|
||||
/// </summary>
|
||||
IReadOnlyList<WinGetPackageOperation> Operations { get; }
|
||||
|
||||
/// <summary>
|
||||
/// Raised when a new tracked WinGet operation starts.
|
||||
/// </summary>
|
||||
event EventHandler<WinGetPackageOperationEventArgs>? OperationStarted;
|
||||
|
||||
/// <summary>
|
||||
/// Raised when a tracked WinGet operation reports new progress.
|
||||
/// </summary>
|
||||
event EventHandler<WinGetPackageOperationEventArgs>? OperationUpdated;
|
||||
|
||||
/// <summary>
|
||||
/// Raised when a tracked WinGet operation completes.
|
||||
/// </summary>
|
||||
event EventHandler<WinGetPackageOperationEventArgs>? OperationCompleted;
|
||||
|
||||
/// <summary>
|
||||
/// Gets the newest tracked operation for a WinGet package id.
|
||||
/// </summary>
|
||||
/// <param name="packageId">The WinGet package id.</param>
|
||||
/// <returns>The newest tracked operation for the package, or null when none is tracked.</returns>
|
||||
WinGetPackageOperation? GetLatestOperation(string packageId);
|
||||
|
||||
/// <summary>
|
||||
/// Requests cancellation for a tracked WinGet operation when supported.
|
||||
/// </summary>
|
||||
/// <param name="operationId">The tracked operation id.</param>
|
||||
/// <returns>True when a cancellation request was issued; otherwise, false.</returns>
|
||||
bool TryCancelOperation(Guid operationId);
|
||||
}
|
||||
@@ -0,0 +1,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);
|
||||
}
|
||||
@@ -0,0 +1,32 @@
|
||||
// Copyright (c) Microsoft Corporation
|
||||
// The Microsoft Corporation licenses this file to you under the MIT license.
|
||||
// See the LICENSE file in the project root for more information.
|
||||
|
||||
using Microsoft.CmdPal.Common.WinGet.Models;
|
||||
|
||||
namespace Microsoft.CmdPal.Common.WinGet.Services;
|
||||
|
||||
public interface IWinGetPackageStatusService
|
||||
{
|
||||
/// <summary>
|
||||
/// Tries to resolve WinGet package information for the provided package ids.
|
||||
/// Returns null when WinGet lookups are unavailable.
|
||||
/// </summary>
|
||||
/// <param name="packageIds">The WinGet package ids to resolve.</param>
|
||||
/// <param name="cancellationToken">A token that cancels the lookup.</param>
|
||||
/// <returns>A package-info map keyed by package id, or null when status lookups are unavailable.</returns>
|
||||
Task<IReadOnlyDictionary<string, WinGetPackageInfo>?> TryGetPackageInfosAsync(
|
||||
IEnumerable<string> packageIds,
|
||||
CancellationToken cancellationToken = default);
|
||||
|
||||
/// <summary>
|
||||
/// Tries to resolve WinGet install/update status for the provided package ids.
|
||||
/// Returns null when status detection is unavailable.
|
||||
/// </summary>
|
||||
/// <param name="packageIds">The WinGet package ids to inspect.</param>
|
||||
/// <param name="cancellationToken">A token that cancels the lookup.</param>
|
||||
/// <returns>A package-status map keyed by package id, or null when status detection is unavailable.</returns>
|
||||
Task<IReadOnlyDictionary<string, WinGetPackageStatus>?> TryGetPackageStatusesAsync(
|
||||
IEnumerable<string> packageIds,
|
||||
CancellationToken cancellationToken = default);
|
||||
}
|
||||
@@ -0,0 +1,248 @@
|
||||
// Copyright (c) Microsoft Corporation
|
||||
// The Microsoft Corporation licenses this file to you under the MIT license.
|
||||
// See the LICENSE file in the project root for more information.
|
||||
|
||||
using Microsoft.CmdPal.Common.WinGet.Models;
|
||||
|
||||
namespace Microsoft.CmdPal.Common.WinGet.Services;
|
||||
|
||||
public sealed class WinGetOperationTrackerService : IWinGetOperationTrackerService
|
||||
{
|
||||
private const int MaxTrackedOperations = 100;
|
||||
|
||||
private static readonly StringComparer OrdinalIgnoreCase = StringComparer.OrdinalIgnoreCase;
|
||||
|
||||
private readonly Lock _operationsLock = new();
|
||||
private readonly List<WinGetPackageOperation> _operations = [];
|
||||
private readonly Dictionary<Guid, Action> _cancelCallbacks = [];
|
||||
|
||||
public event EventHandler<WinGetPackageOperationEventArgs>? OperationStarted;
|
||||
|
||||
public event EventHandler<WinGetPackageOperationEventArgs>? OperationUpdated;
|
||||
|
||||
public event EventHandler<WinGetPackageOperationEventArgs>? OperationCompleted;
|
||||
|
||||
public IReadOnlyList<WinGetPackageOperation> Operations
|
||||
{
|
||||
get
|
||||
{
|
||||
lock (_operationsLock)
|
||||
{
|
||||
return _operations.ToArray();
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
public WinGetPackageOperation? GetLatestOperation(string packageId)
|
||||
{
|
||||
if (string.IsNullOrWhiteSpace(packageId))
|
||||
{
|
||||
return null;
|
||||
}
|
||||
|
||||
lock (_operationsLock)
|
||||
{
|
||||
for (var i = 0; i < _operations.Count; i++)
|
||||
{
|
||||
if (OrdinalIgnoreCase.Equals(_operations[i].PackageId, packageId))
|
||||
{
|
||||
return _operations[i];
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return null;
|
||||
}
|
||||
|
||||
internal WinGetPackageOperation StartOperation(string packageId, string packageName, WinGetPackageOperationKind kind)
|
||||
{
|
||||
var now = DateTimeOffset.UtcNow;
|
||||
var operation = new WinGetPackageOperation(
|
||||
OperationId: Guid.NewGuid(),
|
||||
PackageId: packageId,
|
||||
PackageName: packageName,
|
||||
Kind: kind,
|
||||
State: WinGetPackageOperationState.Queued,
|
||||
CanCancel: false,
|
||||
IsIndeterminate: true,
|
||||
ProgressPercent: null,
|
||||
BytesDownloaded: null,
|
||||
BytesRequired: null,
|
||||
ErrorMessage: null,
|
||||
StartedAt: now,
|
||||
UpdatedAt: now,
|
||||
CompletedAt: null);
|
||||
|
||||
lock (_operationsLock)
|
||||
{
|
||||
_operations.Insert(0, operation);
|
||||
TrimCompletedOperationsNoLock();
|
||||
}
|
||||
|
||||
OperationStarted?.Invoke(this, new WinGetPackageOperationEventArgs(operation));
|
||||
return operation;
|
||||
}
|
||||
|
||||
public bool TryCancelOperation(Guid operationId)
|
||||
{
|
||||
Action? cancelCallback = null;
|
||||
WinGetPackageOperation? updated = null;
|
||||
|
||||
lock (_operationsLock)
|
||||
{
|
||||
var index = FindOperationIndexNoLock(operationId);
|
||||
if (index < 0 || _operations[index].IsCompleted || !_cancelCallbacks.Remove(operationId, out cancelCallback) || cancelCallback is null)
|
||||
{
|
||||
return false;
|
||||
}
|
||||
|
||||
updated = _operations[index] with
|
||||
{
|
||||
CanCancel = false,
|
||||
UpdatedAt = DateTimeOffset.UtcNow,
|
||||
};
|
||||
|
||||
_operations[index] = updated;
|
||||
}
|
||||
|
||||
OperationUpdated?.Invoke(this, new WinGetPackageOperationEventArgs(updated));
|
||||
|
||||
try
|
||||
{
|
||||
cancelCallback();
|
||||
return true;
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
CoreLogger.LogWarning($"Failed to cancel WinGet operation '{operationId}': {ex.Message}");
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
internal WinGetPackageOperation? RegisterCancellationHandler(Guid operationId, Action cancelCallback)
|
||||
{
|
||||
ArgumentNullException.ThrowIfNull(cancelCallback);
|
||||
|
||||
WinGetPackageOperation? updated = null;
|
||||
|
||||
lock (_operationsLock)
|
||||
{
|
||||
var index = FindOperationIndexNoLock(operationId);
|
||||
if (index < 0 || _operations[index].IsCompleted)
|
||||
{
|
||||
return null;
|
||||
}
|
||||
|
||||
_cancelCallbacks[operationId] = cancelCallback;
|
||||
updated = _operations[index] with
|
||||
{
|
||||
CanCancel = true,
|
||||
UpdatedAt = DateTimeOffset.UtcNow,
|
||||
};
|
||||
|
||||
_operations[index] = updated;
|
||||
}
|
||||
|
||||
OperationUpdated?.Invoke(this, new WinGetPackageOperationEventArgs(updated));
|
||||
return updated;
|
||||
}
|
||||
|
||||
internal WinGetPackageOperation? UpdateOperation(
|
||||
Guid operationId,
|
||||
WinGetPackageOperationState state,
|
||||
bool isIndeterminate,
|
||||
uint? progressPercent = null,
|
||||
ulong? bytesDownloaded = null,
|
||||
ulong? bytesRequired = null)
|
||||
{
|
||||
WinGetPackageOperation? updated = null;
|
||||
|
||||
lock (_operationsLock)
|
||||
{
|
||||
var index = FindOperationIndexNoLock(operationId);
|
||||
if (index < 0)
|
||||
{
|
||||
return null;
|
||||
}
|
||||
|
||||
updated = _operations[index] with
|
||||
{
|
||||
State = state,
|
||||
CanCancel = _operations[index].CanCancel,
|
||||
IsIndeterminate = isIndeterminate,
|
||||
ProgressPercent = progressPercent,
|
||||
BytesDownloaded = bytesDownloaded,
|
||||
BytesRequired = bytesRequired,
|
||||
UpdatedAt = DateTimeOffset.UtcNow,
|
||||
};
|
||||
|
||||
_operations[index] = updated;
|
||||
}
|
||||
|
||||
OperationUpdated?.Invoke(this, new WinGetPackageOperationEventArgs(updated));
|
||||
return updated;
|
||||
}
|
||||
|
||||
internal WinGetPackageOperation? CompleteOperation(Guid operationId, WinGetPackageOperationState state, string? errorMessage = null)
|
||||
{
|
||||
WinGetPackageOperation? completed = null;
|
||||
|
||||
lock (_operationsLock)
|
||||
{
|
||||
var index = FindOperationIndexNoLock(operationId);
|
||||
if (index < 0)
|
||||
{
|
||||
return null;
|
||||
}
|
||||
|
||||
_cancelCallbacks.Remove(operationId);
|
||||
|
||||
var now = DateTimeOffset.UtcNow;
|
||||
completed = _operations[index] with
|
||||
{
|
||||
State = state,
|
||||
CanCancel = false,
|
||||
IsIndeterminate = false,
|
||||
ProgressPercent = state == WinGetPackageOperationState.Succeeded ? 100u : _operations[index].ProgressPercent,
|
||||
ErrorMessage = errorMessage,
|
||||
UpdatedAt = now,
|
||||
CompletedAt = now,
|
||||
};
|
||||
|
||||
_operations[index] = completed;
|
||||
TrimCompletedOperationsNoLock();
|
||||
}
|
||||
|
||||
OperationCompleted?.Invoke(this, new WinGetPackageOperationEventArgs(completed));
|
||||
return completed;
|
||||
}
|
||||
|
||||
private int FindOperationIndexNoLock(Guid operationId)
|
||||
{
|
||||
for (var i = 0; i < _operations.Count; i++)
|
||||
{
|
||||
if (_operations[i].OperationId == operationId)
|
||||
{
|
||||
return i;
|
||||
}
|
||||
}
|
||||
|
||||
return -1;
|
||||
}
|
||||
|
||||
private void TrimCompletedOperationsNoLock()
|
||||
{
|
||||
if (_operations.Count <= MaxTrackedOperations)
|
||||
{
|
||||
return;
|
||||
}
|
||||
|
||||
for (var i = _operations.Count - 1; i >= 0 && _operations.Count > MaxTrackedOperations; i--)
|
||||
{
|
||||
if (_operations[i].IsCompleted)
|
||||
{
|
||||
_operations.RemoveAt(i);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,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: []);
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,12 @@
|
||||
// Copyright (c) Microsoft Corporation
|
||||
// The Microsoft Corporation licenses this file to you under the MIT license.
|
||||
// See the LICENSE file in the project root for more information.
|
||||
|
||||
using Microsoft.CmdPal.Common.WinGet.Models;
|
||||
|
||||
namespace Microsoft.CmdPal.Common.WinGet.Services;
|
||||
|
||||
public sealed class WinGetPackageOperationEventArgs(WinGetPackageOperation operation) : EventArgs
|
||||
{
|
||||
public WinGetPackageOperation Operation { get; } = operation;
|
||||
}
|
||||
@@ -0,0 +1,132 @@
|
||||
// Copyright (c) Microsoft Corporation
|
||||
// The Microsoft Corporation licenses this file to you under the MIT license.
|
||||
// See the LICENSE file in the project root for more information.
|
||||
|
||||
using System.Runtime.InteropServices;
|
||||
using Microsoft.CmdPal.Common.WinGet.Models;
|
||||
using Microsoft.Management.Deployment;
|
||||
|
||||
namespace Microsoft.CmdPal.Common.WinGet.Services;
|
||||
|
||||
public sealed class WinGetPackageStatusService : IWinGetPackageStatusService
|
||||
{
|
||||
private static readonly StringComparer OrdinalIgnoreCase = StringComparer.OrdinalIgnoreCase;
|
||||
private readonly IWinGetPackageManagerService _packageManagerService;
|
||||
|
||||
public WinGetPackageStatusService(IWinGetPackageManagerService packageManagerService)
|
||||
{
|
||||
_packageManagerService = packageManagerService;
|
||||
}
|
||||
|
||||
public async Task<IReadOnlyDictionary<string, WinGetPackageInfo>?> TryGetPackageInfosAsync(
|
||||
IEnumerable<string> packageIds,
|
||||
CancellationToken cancellationToken = default)
|
||||
{
|
||||
var normalizedIds = NormalizePackageIds(packageIds);
|
||||
if (normalizedIds.Count == 0)
|
||||
{
|
||||
return new Dictionary<string, WinGetPackageInfo>(OrdinalIgnoreCase);
|
||||
}
|
||||
|
||||
return await TryGetInfosViaWinGetApiAsync(normalizedIds, _packageManagerService, cancellationToken);
|
||||
}
|
||||
|
||||
public async Task<IReadOnlyDictionary<string, WinGetPackageStatus>?> TryGetPackageStatusesAsync(
|
||||
IEnumerable<string> packageIds,
|
||||
CancellationToken cancellationToken = default)
|
||||
{
|
||||
var infos = await TryGetPackageInfosAsync(packageIds, cancellationToken);
|
||||
if (infos is null)
|
||||
{
|
||||
return null;
|
||||
}
|
||||
|
||||
Dictionary<string, WinGetPackageStatus> statuses = new(OrdinalIgnoreCase);
|
||||
foreach (var pair in infos)
|
||||
{
|
||||
statuses[pair.Key] = pair.Value.Status;
|
||||
}
|
||||
|
||||
return statuses;
|
||||
}
|
||||
|
||||
private static async Task<IReadOnlyDictionary<string, WinGetPackageInfo>?> TryGetInfosViaWinGetApiAsync(
|
||||
IReadOnlyList<string> packageIds,
|
||||
IWinGetPackageManagerService packageManagerService,
|
||||
CancellationToken cancellationToken)
|
||||
{
|
||||
var packagesResult = await packageManagerService.GetPackagesByIdAsync(packageIds, includeStoreCatalog: false, cancellationToken);
|
||||
if (!packagesResult.IsSuccess || packagesResult.Value is null)
|
||||
{
|
||||
return null;
|
||||
}
|
||||
|
||||
try
|
||||
{
|
||||
Dictionary<string, WinGetPackageInfo> results = new(OrdinalIgnoreCase);
|
||||
for (var i = 0; i < packageIds.Count; i++)
|
||||
{
|
||||
var packageId = packageIds[i];
|
||||
var status = new WinGetPackageStatus(
|
||||
IsInstalled: false,
|
||||
IsInstalledStateKnown: true,
|
||||
IsUpdateAvailable: false,
|
||||
IsUpdateStateKnown: true);
|
||||
results[packageId] = new WinGetPackageInfo(status, Details: null);
|
||||
}
|
||||
|
||||
foreach (var package in packagesResult.Value.Values)
|
||||
{
|
||||
if (!results.ContainsKey(package.Id))
|
||||
{
|
||||
continue;
|
||||
}
|
||||
|
||||
results[package.Id] = await InspectPackageAsync(package);
|
||||
}
|
||||
|
||||
return results;
|
||||
}
|
||||
catch (Exception ex) when (ex is InvalidOperationException or COMException or TaskCanceledException)
|
||||
{
|
||||
CoreLogger.LogWarning($"WinGet API package info query failed: {ex.Message}");
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
private static async Task<WinGetPackageInfo> InspectPackageAsync(CatalogPackage package)
|
||||
{
|
||||
var status = await WinGetPackageMetadataHelper.InspectPackageStatusAsync(package);
|
||||
var details = WinGetPackageMetadataHelper.TryBuildPackageDetails(package);
|
||||
return new WinGetPackageInfo(status, details);
|
||||
}
|
||||
|
||||
private static List<string> NormalizePackageIds(IEnumerable<string> packageIds)
|
||||
{
|
||||
List<string> normalized = [];
|
||||
HashSet<string> seen = new(OrdinalIgnoreCase);
|
||||
|
||||
foreach (var candidate in packageIds)
|
||||
{
|
||||
var trimmed = ToNullIfWhiteSpace(candidate);
|
||||
if (trimmed is null || !seen.Add(trimmed))
|
||||
{
|
||||
continue;
|
||||
}
|
||||
|
||||
normalized.Add(trimmed);
|
||||
}
|
||||
|
||||
return normalized;
|
||||
}
|
||||
|
||||
private static string? ToNullIfWhiteSpace(string? value)
|
||||
{
|
||||
if (string.IsNullOrWhiteSpace(value))
|
||||
{
|
||||
return null;
|
||||
}
|
||||
|
||||
return value.Trim();
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,10 @@
|
||||
// Copyright (c) Microsoft Corporation
|
||||
// The Microsoft Corporation licenses this file to you under the MIT license.
|
||||
// See the LICENSE file in the project root for more information.
|
||||
|
||||
namespace Microsoft.CmdPal.Common.WinGet;
|
||||
|
||||
public static class WinGetPackageTags
|
||||
{
|
||||
public const string CommandPaletteExtension = "windows-commandpalette-extension";
|
||||
}
|
||||
@@ -13,6 +13,7 @@ namespace Microsoft.CmdPal.UI.ViewModels.BuiltinCommands;
|
||||
public sealed partial class BuiltInsCommandProvider : CommandProvider
|
||||
{
|
||||
private readonly OpenSettingsCommand openSettings = new();
|
||||
private readonly OpenGallerySettingsCommand openGallerySettings = new();
|
||||
private readonly QuitCommand quitCommand = new();
|
||||
private readonly FallbackReloadItem _fallbackReloadItem = new();
|
||||
private readonly FallbackLogItem _fallbackLogItem = new();
|
||||
@@ -23,6 +24,7 @@ public sealed partial class BuiltInsCommandProvider : CommandProvider
|
||||
public override ICommandItem[] TopLevelCommands() =>
|
||||
[
|
||||
new CommandItem(openSettings) { },
|
||||
new CommandItem(openGallerySettings) { },
|
||||
new CommandItem(_newExtension) { Title = _newExtension.Title },
|
||||
];
|
||||
|
||||
|
||||
@@ -0,0 +1,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 */
|
||||
{
|
||||
}
|
||||
}
|
||||
@@ -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();
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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));
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,41 @@
|
||||
// Copyright (c) Microsoft Corporation
|
||||
// The Microsoft Corporation licenses this file to you under the MIT license.
|
||||
// See the LICENSE file in the project root for more information.
|
||||
|
||||
using Microsoft.CmdPal.Common.ExtensionGallery.Models;
|
||||
using Microsoft.CmdPal.Common.WinGet.Services;
|
||||
using Microsoft.Extensions.Logging;
|
||||
|
||||
namespace Microsoft.CmdPal.UI.ViewModels.Gallery;
|
||||
|
||||
public sealed class ExtensionGalleryItemViewModelFactory
|
||||
{
|
||||
private readonly ILogger<ExtensionGalleryItemViewModel> _logger;
|
||||
private readonly IWinGetPackageManagerService? _winGetPackageManagerService;
|
||||
private readonly IWinGetOperationTrackerService? _winGetOperationTrackerService;
|
||||
private readonly IWinGetPackageStatusService? _winGetPackageStatusService;
|
||||
|
||||
public ExtensionGalleryItemViewModelFactory(
|
||||
ILogger<ExtensionGalleryItemViewModel> logger,
|
||||
IWinGetPackageManagerService? winGetPackageManagerService = null,
|
||||
IWinGetPackageStatusService? winGetPackageStatusService = null,
|
||||
IWinGetOperationTrackerService? winGetOperationTrackerService = null)
|
||||
{
|
||||
_logger = logger;
|
||||
_winGetPackageManagerService = winGetPackageManagerService;
|
||||
_winGetPackageStatusService = winGetPackageStatusService;
|
||||
_winGetOperationTrackerService = winGetOperationTrackerService;
|
||||
}
|
||||
|
||||
public ExtensionGalleryItemViewModel Create(GalleryExtensionEntry entry)
|
||||
{
|
||||
ArgumentNullException.ThrowIfNull(entry);
|
||||
|
||||
return new ExtensionGalleryItemViewModel(
|
||||
entry,
|
||||
_logger,
|
||||
_winGetPackageManagerService,
|
||||
_winGetPackageStatusService,
|
||||
_winGetOperationTrackerService);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,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;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,13 @@
|
||||
// Copyright (c) Microsoft Corporation
|
||||
// The Microsoft Corporation licenses this file to you under the MIT license.
|
||||
// See the LICENSE file in the project root for more information.
|
||||
|
||||
namespace Microsoft.CmdPal.UI.ViewModels.Gallery;
|
||||
|
||||
public enum ExtensionGallerySortOption
|
||||
{
|
||||
Featured = 0,
|
||||
Name = 1,
|
||||
Author = 2,
|
||||
InstallationStatus = 3,
|
||||
}
|
||||
@@ -0,0 +1,766 @@
|
||||
// Copyright (c) Microsoft Corporation
|
||||
// The Microsoft Corporation licenses this file to you under the MIT license.
|
||||
// See the LICENSE file in the project root for more information.
|
||||
|
||||
using System.Collections.ObjectModel;
|
||||
using System.Globalization;
|
||||
using System.Text;
|
||||
using CommunityToolkit.Mvvm.ComponentModel;
|
||||
using CommunityToolkit.Mvvm.Input;
|
||||
using Microsoft.CmdPal.Common.ExtensionGallery.Models;
|
||||
using Microsoft.CmdPal.Common.ExtensionGallery.Services;
|
||||
using Microsoft.CmdPal.Common.Services;
|
||||
using Microsoft.CmdPal.Common.WinGet.Models;
|
||||
using Microsoft.CmdPal.Common.WinGet.Services;
|
||||
using Microsoft.CmdPal.UI.ViewModels.Properties;
|
||||
using Microsoft.CommandPalette.Extensions.Toolkit;
|
||||
using Microsoft.Extensions.Logging;
|
||||
|
||||
namespace Microsoft.CmdPal.UI.ViewModels.Gallery;
|
||||
|
||||
public sealed partial class ExtensionGalleryViewModel : ObservableObject, IDisposable
|
||||
{
|
||||
private const string WinGetSourceType = "winget";
|
||||
private const string GenericErrorIconGlyph = "\u26A0";
|
||||
private const string RateLimitedErrorIconGlyph = "\U0001F984";
|
||||
private static readonly TimeSpan WinGetRefreshTimeout = TimeSpan.FromSeconds(5);
|
||||
private static readonly StringComparer SortStringComparer = StringComparer.CurrentCultureIgnoreCase;
|
||||
private static readonly CompositeFormat LabelGalleryExtensionsAvailable
|
||||
= CompositeFormat.Parse(Resources.gallery_n_extensions_available!);
|
||||
|
||||
private static readonly CompositeFormat LabelGalleryExtensionsFound
|
||||
= CompositeFormat.Parse(Resources.gallery_n_extensions_found!);
|
||||
|
||||
private static readonly Action<ILogger, Exception?> LogCheckInstalledExtensionsError =
|
||||
LoggerMessage.Define(
|
||||
LogLevel.Error,
|
||||
new EventId(1, nameof(LogCheckInstalledExtensionsError)),
|
||||
"Failed to check installed extensions");
|
||||
|
||||
private static readonly Action<ILogger, Exception?> LogRefreshWinGetCatalogsError =
|
||||
LoggerMessage.Define(
|
||||
LogLevel.Error,
|
||||
new EventId(2, nameof(LogRefreshWinGetCatalogsError)),
|
||||
"Failed to refresh WinGet catalogs");
|
||||
|
||||
private static readonly Action<ILogger, Exception?> LogCheckWinGetPackageStatusError =
|
||||
LoggerMessage.Define(
|
||||
LogLevel.Error,
|
||||
new EventId(3, nameof(LogCheckWinGetPackageStatusError)),
|
||||
"Failed to check WinGet package status");
|
||||
|
||||
private readonly IExtensionGalleryService _galleryService;
|
||||
private readonly IExtensionService _extensionService;
|
||||
private readonly ILogger<ExtensionGalleryViewModel> _logger;
|
||||
private readonly ExtensionGalleryItemViewModelFactory _galleryExtensionViewModelFactory;
|
||||
private readonly IWinGetPackageManagerService? _winGetPackageManagerService;
|
||||
private readonly IWinGetOperationTrackerService? _winGetOperationTrackerService;
|
||||
private readonly IWinGetPackageStatusService? _winGetPackageStatusService;
|
||||
private readonly TaskScheduler _uiScheduler;
|
||||
private readonly Lock _entriesLock = new();
|
||||
private readonly List<ExtensionGalleryItemViewModel> _allEntries = [];
|
||||
private readonly Dictionary<string, List<ExtensionGalleryItemViewModel>> _entriesByWinGetId = new(StringComparer.OrdinalIgnoreCase);
|
||||
private CancellationTokenSource _cts = new();
|
||||
private bool _disposed;
|
||||
|
||||
public ObservableCollection<ExtensionGalleryItemViewModel> FilteredEntries { get; } = [];
|
||||
|
||||
[ObservableProperty]
|
||||
public partial IReadOnlyList<Uri> CarouselIconUris { get; set; } = [];
|
||||
|
||||
private string _searchText = string.Empty;
|
||||
|
||||
public string SearchText
|
||||
{
|
||||
get => _searchText;
|
||||
set
|
||||
{
|
||||
if (_searchText != value)
|
||||
{
|
||||
_searchText = value;
|
||||
OnPropertyChanged();
|
||||
ApplyFilter();
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
public string ItemCounterText
|
||||
{
|
||||
get
|
||||
{
|
||||
var hasQuery = !string.IsNullOrWhiteSpace(_searchText);
|
||||
int count;
|
||||
if (hasQuery)
|
||||
{
|
||||
count = FilteredEntries.Count;
|
||||
}
|
||||
else
|
||||
{
|
||||
lock (_entriesLock)
|
||||
{
|
||||
count = _allEntries.Count;
|
||||
}
|
||||
}
|
||||
|
||||
var format = hasQuery ? LabelGalleryExtensionsFound : LabelGalleryExtensionsAvailable;
|
||||
return string.Format(CultureInfo.CurrentCulture, format, count);
|
||||
}
|
||||
}
|
||||
|
||||
[ObservableProperty]
|
||||
public partial bool IsLoading { get; set; }
|
||||
|
||||
[ObservableProperty]
|
||||
public partial bool HasError { get; set; }
|
||||
|
||||
[ObservableProperty]
|
||||
public partial string? ErrorMessage { get; set; }
|
||||
|
||||
[ObservableProperty]
|
||||
public partial bool FromCache { get; set; }
|
||||
|
||||
[ObservableProperty]
|
||||
public partial bool UsedFallbackCache { get; set; }
|
||||
|
||||
[ObservableProperty]
|
||||
public partial bool IsRateLimitedError { get; set; }
|
||||
|
||||
[ObservableProperty]
|
||||
public partial ExtensionGallerySortOption SelectedSortOption { get; set; } = ExtensionGallerySortOption.Featured;
|
||||
|
||||
public bool ShowNoResultsPanel => !HasError && !string.IsNullOrWhiteSpace(_searchText) && FilteredEntries.Count == 0;
|
||||
|
||||
public bool HasResults => !IsLoading && !ShowNoResultsPanel && FilteredEntries.Count > 0;
|
||||
|
||||
public bool ShowErrorSurface => HasError && FilteredEntries.Count == 0;
|
||||
|
||||
public bool ShowErrorInfoBar => HasError && !ShowErrorSurface;
|
||||
|
||||
public string ErrorDisplayIconGlyph => IsRateLimitedError ? RateLimitedErrorIconGlyph : GenericErrorIconGlyph;
|
||||
|
||||
public string ErrorDisplayTitle => IsRateLimitedError
|
||||
? Resources.gallery_error_rate_limited_title
|
||||
: Resources.gallery_error_generic_title;
|
||||
|
||||
public string ErrorDisplayMessage => IsRateLimitedError
|
||||
? Resources.gallery_error_rate_limited_message
|
||||
: !string.IsNullOrWhiteSpace(ErrorMessage)
|
||||
? ErrorMessage
|
||||
: Resources.gallery_error_generic_message;
|
||||
|
||||
public bool IsCustomFeed => _galleryService.IsCustomFeed;
|
||||
|
||||
public string CustomFeedUrl => _galleryService.GetBaseUrl();
|
||||
|
||||
public bool IsSortByFeaturedSelected => SelectedSortOption == ExtensionGallerySortOption.Featured;
|
||||
|
||||
public bool IsSortByNameSelected => SelectedSortOption == ExtensionGallerySortOption.Name;
|
||||
|
||||
public bool IsSortByAuthorSelected => SelectedSortOption == ExtensionGallerySortOption.Author;
|
||||
|
||||
public bool IsSortByInstallationStatusSelected => SelectedSortOption == ExtensionGallerySortOption.InstallationStatus;
|
||||
|
||||
public ExtensionGalleryViewModel(
|
||||
IExtensionGalleryService galleryService,
|
||||
IExtensionService extensionService,
|
||||
ILogger<ExtensionGalleryViewModel> logger,
|
||||
ExtensionGalleryItemViewModelFactory galleryExtensionViewModelFactory,
|
||||
IWinGetPackageManagerService? winGetPackageManagerService = null,
|
||||
IWinGetPackageStatusService? winGetPackageStatusService = null,
|
||||
IWinGetOperationTrackerService? winGetOperationTrackerService = null,
|
||||
TaskScheduler? uiScheduler = null)
|
||||
{
|
||||
_galleryService = galleryService;
|
||||
_extensionService = extensionService;
|
||||
_logger = logger;
|
||||
_galleryExtensionViewModelFactory = galleryExtensionViewModelFactory;
|
||||
_winGetPackageManagerService = winGetPackageManagerService;
|
||||
_winGetPackageStatusService = winGetPackageStatusService;
|
||||
_winGetOperationTrackerService = winGetOperationTrackerService;
|
||||
_uiScheduler = uiScheduler ?? TaskScheduler.Current;
|
||||
|
||||
RefreshCommand = new AsyncRelayCommand(RefreshAsync);
|
||||
|
||||
if (_winGetOperationTrackerService is not null)
|
||||
{
|
||||
_winGetOperationTrackerService.OperationStarted += OnWinGetOperationStarted;
|
||||
_winGetOperationTrackerService.OperationUpdated += OnWinGetOperationUpdated;
|
||||
_winGetOperationTrackerService.OperationCompleted += OnWinGetOperationCompleted;
|
||||
}
|
||||
}
|
||||
|
||||
public IAsyncRelayCommand RefreshCommand { get; }
|
||||
|
||||
[RelayCommand]
|
||||
private void SortByFeatured()
|
||||
{
|
||||
SelectedSortOption = ExtensionGallerySortOption.Featured;
|
||||
}
|
||||
|
||||
[RelayCommand]
|
||||
private void SortByName()
|
||||
{
|
||||
SelectedSortOption = ExtensionGallerySortOption.Name;
|
||||
}
|
||||
|
||||
[RelayCommand]
|
||||
private void SortByAuthor()
|
||||
{
|
||||
SelectedSortOption = ExtensionGallerySortOption.Author;
|
||||
}
|
||||
|
||||
[RelayCommand]
|
||||
private void SortByInstallationStatus()
|
||||
{
|
||||
SelectedSortOption = ExtensionGallerySortOption.InstallationStatus;
|
||||
}
|
||||
|
||||
public async Task LoadAsync()
|
||||
{
|
||||
await FetchCoreAsync(_galleryService.FetchExtensionsAsync, refreshInstallationStatus: false);
|
||||
}
|
||||
|
||||
private async Task RefreshAsync()
|
||||
{
|
||||
await FetchCoreAsync(_galleryService.RefreshAsync, refreshInstallationStatus: true);
|
||||
}
|
||||
|
||||
private async Task FetchCoreAsync(Func<CancellationToken, Task<GalleryFetchResult>> fetchFunc, bool refreshInstallationStatus)
|
||||
{
|
||||
var cts = ResetCancellation();
|
||||
|
||||
IsLoading = true;
|
||||
HasError = false;
|
||||
ErrorMessage = null;
|
||||
FromCache = false;
|
||||
UsedFallbackCache = false;
|
||||
IsRateLimitedError = false;
|
||||
NotifyStateChanged();
|
||||
|
||||
try
|
||||
{
|
||||
var result = await RunInBackgroundAsync(() => fetchFunc(cts.Token), cts.Token);
|
||||
cts.Token.ThrowIfCancellationRequested();
|
||||
ApplyEntries(result.Extensions);
|
||||
HasError = result.HasError;
|
||||
ErrorMessage = result.ErrorMessage;
|
||||
FromCache = result.FromCache;
|
||||
UsedFallbackCache = result.UsedFallbackCache;
|
||||
IsRateLimitedError = result.IsRateLimited;
|
||||
ApplyFilter();
|
||||
|
||||
StartBackgroundRefresh(refreshInstallationStatus, cts.Token);
|
||||
}
|
||||
catch (OperationCanceledException)
|
||||
{
|
||||
// Cancelled by navigation or dispose — not an error
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
HasError = true;
|
||||
ErrorMessage = ex.Message;
|
||||
IsRateLimitedError = false;
|
||||
}
|
||||
finally
|
||||
{
|
||||
IsLoading = false;
|
||||
NotifyStateChanged();
|
||||
}
|
||||
}
|
||||
|
||||
private void ApplyEntries(IReadOnlyList<GalleryExtensionEntry> entries)
|
||||
{
|
||||
lock (_entriesLock)
|
||||
{
|
||||
_allEntries.Clear();
|
||||
foreach (var entry in entries)
|
||||
{
|
||||
_allEntries.Add(CreateEntryViewModel(entry));
|
||||
}
|
||||
|
||||
RebuildWinGetEntryIndex();
|
||||
}
|
||||
|
||||
ApplyCurrentWinGetOperations();
|
||||
UpdateCarouselIcons();
|
||||
}
|
||||
|
||||
private void UpdateCarouselIcons()
|
||||
{
|
||||
List<Uri> candidates;
|
||||
lock (_entriesLock)
|
||||
{
|
||||
candidates = new List<Uri>(_allEntries.Count);
|
||||
foreach (var entry in _allEntries)
|
||||
{
|
||||
if (entry.IconUri.Scheme != "ms-appx")
|
||||
{
|
||||
candidates.Add(entry.IconUri);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
if (candidates.Count == 0)
|
||||
{
|
||||
CarouselIconUris = [];
|
||||
return;
|
||||
}
|
||||
|
||||
// Fisher-Yates shuffle for random selection
|
||||
var rng = Random.Shared;
|
||||
for (var i = candidates.Count - 1; i > 0; i--)
|
||||
{
|
||||
var j = rng.Next(i + 1);
|
||||
(candidates[i], candidates[j]) = (candidates[j], candidates[i]);
|
||||
}
|
||||
|
||||
// Take up to VisibleCount + buffer for smooth wrapping
|
||||
var count = Math.Min(candidates.Count, 12);
|
||||
CarouselIconUris = candidates.GetRange(0, count);
|
||||
}
|
||||
|
||||
private void StartBackgroundRefresh(
|
||||
bool refreshInstallationStatus,
|
||||
CancellationToken cancellationToken)
|
||||
{
|
||||
_ = CheckInstalledAsync(
|
||||
cancellationToken,
|
||||
refreshInstalledExtensions: refreshInstallationStatus,
|
||||
refreshWinGetCatalogs: refreshInstallationStatus);
|
||||
}
|
||||
|
||||
private CancellationTokenSource ResetCancellation()
|
||||
{
|
||||
var oldCts = _cts;
|
||||
var newCts = new CancellationTokenSource();
|
||||
_cts = newCts;
|
||||
oldCts.Cancel();
|
||||
oldCts.Dispose();
|
||||
return newCts;
|
||||
}
|
||||
|
||||
private async Task CheckInstalledAsync(
|
||||
CancellationToken cancellationToken,
|
||||
bool refreshInstalledExtensions = false,
|
||||
bool refreshWinGetCatalogs = false)
|
||||
{
|
||||
List<ExtensionGalleryItemViewModel> snapshot;
|
||||
try
|
||||
{
|
||||
var installedExtensions = refreshInstalledExtensions
|
||||
? await RunInBackgroundAsync(
|
||||
() => _extensionService.RefreshInstalledExtensionsAsync(includeDisabledExtensions: true),
|
||||
cancellationToken)
|
||||
: await RunInBackgroundAsync(
|
||||
() => _extensionService.GetInstalledExtensionsAsync(includeDisabledExtensions: true),
|
||||
cancellationToken);
|
||||
cancellationToken.ThrowIfCancellationRequested();
|
||||
|
||||
var installedPfns = new HashSet<string>(
|
||||
installedExtensions
|
||||
.Select(e => e.PackageFamilyName)
|
||||
.Where(pfn => !string.IsNullOrEmpty(pfn)),
|
||||
StringComparer.OrdinalIgnoreCase);
|
||||
|
||||
lock (_entriesLock)
|
||||
{
|
||||
snapshot = [.. _allEntries];
|
||||
}
|
||||
|
||||
foreach (var entry in snapshot)
|
||||
{
|
||||
if (!string.IsNullOrEmpty(entry.PackageFamilyName))
|
||||
{
|
||||
entry.IsInstalled = installedPfns.Contains(entry.PackageFamilyName);
|
||||
entry.IsInstalledStateKnown = true;
|
||||
}
|
||||
}
|
||||
|
||||
QueueApplyFilter();
|
||||
}
|
||||
catch (OperationCanceledException)
|
||||
{
|
||||
// Cancelled — non-critical
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
// Non-critical; leave IsInstalled as false
|
||||
LogCheckInstalledExtensionsError(_logger, ex);
|
||||
}
|
||||
|
||||
if (_winGetPackageStatusService is null)
|
||||
{
|
||||
return;
|
||||
}
|
||||
|
||||
if (refreshWinGetCatalogs && _winGetPackageManagerService is not null && _winGetPackageManagerService.State.IsAvailable)
|
||||
{
|
||||
try
|
||||
{
|
||||
using var refreshCts = CancellationTokenSource.CreateLinkedTokenSource(cancellationToken);
|
||||
refreshCts.CancelAfter(WinGetRefreshTimeout);
|
||||
await RunInBackgroundAsync(
|
||||
() => _winGetPackageManagerService.RefreshCatalogsAsync(refreshCts.Token),
|
||||
refreshCts.Token);
|
||||
refreshCts.Token.ThrowIfCancellationRequested();
|
||||
}
|
||||
catch (OperationCanceledException)
|
||||
{
|
||||
return;
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
LogRefreshWinGetCatalogsError(_logger, ex);
|
||||
return;
|
||||
}
|
||||
}
|
||||
|
||||
try
|
||||
{
|
||||
lock (_entriesLock)
|
||||
{
|
||||
snapshot = [.. _allEntries];
|
||||
}
|
||||
|
||||
var wingetIds = snapshot
|
||||
.Select(entry => entry.WinGetId)
|
||||
.Where(static id => !string.IsNullOrWhiteSpace(id))
|
||||
.Distinct(StringComparer.OrdinalIgnoreCase)
|
||||
.Cast<string>()
|
||||
.ToArray();
|
||||
if (wingetIds.Length == 0)
|
||||
{
|
||||
return;
|
||||
}
|
||||
|
||||
using var wingetCts = CancellationTokenSource.CreateLinkedTokenSource(cancellationToken);
|
||||
wingetCts.CancelAfter(WinGetRefreshTimeout);
|
||||
var wingetInfos = await RunInBackgroundAsync(
|
||||
() => _winGetPackageStatusService.TryGetPackageInfosAsync(wingetIds, wingetCts.Token),
|
||||
wingetCts.Token);
|
||||
wingetCts.Token.ThrowIfCancellationRequested();
|
||||
if (wingetInfos is null)
|
||||
{
|
||||
return;
|
||||
}
|
||||
|
||||
foreach (var entry in snapshot)
|
||||
{
|
||||
if (string.IsNullOrWhiteSpace(entry.WinGetId))
|
||||
{
|
||||
continue;
|
||||
}
|
||||
|
||||
if (!wingetInfos.TryGetValue(entry.WinGetId, out var packageInfo))
|
||||
{
|
||||
continue;
|
||||
}
|
||||
|
||||
entry.ApplyWinGetPackageInfo(packageInfo);
|
||||
}
|
||||
|
||||
QueueApplyFilter();
|
||||
}
|
||||
catch (OperationCanceledException)
|
||||
{
|
||||
// Cancelled or timed out — non-critical.
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
// Non-critical; keep the gallery visible with its existing state.
|
||||
LogCheckWinGetPackageStatusError(_logger, ex);
|
||||
}
|
||||
}
|
||||
|
||||
private ExtensionGalleryItemViewModel CreateEntryViewModel(GalleryExtensionEntry entry)
|
||||
{
|
||||
return _galleryExtensionViewModelFactory.Create(entry);
|
||||
}
|
||||
|
||||
private void ApplyFilter()
|
||||
{
|
||||
List<ExtensionGalleryItemViewModel> snapshot;
|
||||
lock (_entriesLock)
|
||||
{
|
||||
snapshot = [.. _allEntries];
|
||||
}
|
||||
|
||||
var filtered = ListHelpers.FilterList(snapshot, _searchText, Matches).ToList();
|
||||
SortEntries(filtered);
|
||||
ListHelpers.InPlaceUpdateList(FilteredEntries, filtered);
|
||||
NotifyStateChanged();
|
||||
}
|
||||
|
||||
private void SortEntries(List<ExtensionGalleryItemViewModel> entries)
|
||||
{
|
||||
switch (SelectedSortOption)
|
||||
{
|
||||
case ExtensionGallerySortOption.Name:
|
||||
entries.Sort(CompareByName);
|
||||
break;
|
||||
case ExtensionGallerySortOption.Author:
|
||||
entries.Sort(CompareByAuthor);
|
||||
break;
|
||||
case ExtensionGallerySortOption.InstallationStatus:
|
||||
entries.Sort(CompareByInstallationStatus);
|
||||
break;
|
||||
}
|
||||
}
|
||||
|
||||
private static int Matches(string query, ExtensionGalleryItemViewModel item)
|
||||
{
|
||||
if (string.IsNullOrWhiteSpace(query))
|
||||
{
|
||||
return 100;
|
||||
}
|
||||
|
||||
return Contains(item.Title, query)
|
||||
|| Contains(item.Description, query)
|
||||
|| Contains(item.AuthorName, query)
|
||||
|| Contains(item.Tags, query)
|
||||
? 100
|
||||
: 0;
|
||||
}
|
||||
|
||||
private static bool Contains(string? haystack, string needle)
|
||||
{
|
||||
return !string.IsNullOrEmpty(haystack) && haystack.Contains(needle, StringComparison.OrdinalIgnoreCase);
|
||||
}
|
||||
|
||||
private static bool Contains(IReadOnlyList<string>? values, string needle)
|
||||
{
|
||||
if (values is null || values.Count == 0)
|
||||
{
|
||||
return false;
|
||||
}
|
||||
|
||||
for (var i = 0; i < values.Count; i++)
|
||||
{
|
||||
if (!string.IsNullOrWhiteSpace(values[i]) && values[i].Contains(needle, StringComparison.OrdinalIgnoreCase))
|
||||
{
|
||||
return true;
|
||||
}
|
||||
}
|
||||
|
||||
return false;
|
||||
}
|
||||
|
||||
private void NotifyStateChanged()
|
||||
{
|
||||
OnPropertyChanged(nameof(ItemCounterText));
|
||||
OnPropertyChanged(nameof(HasResults));
|
||||
OnPropertyChanged(nameof(ShowNoResultsPanel));
|
||||
OnPropertyChanged(nameof(ShowErrorSurface));
|
||||
OnPropertyChanged(nameof(ShowErrorInfoBar));
|
||||
OnPropertyChanged(nameof(ErrorDisplayIconGlyph));
|
||||
OnPropertyChanged(nameof(ErrorDisplayTitle));
|
||||
OnPropertyChanged(nameof(ErrorDisplayMessage));
|
||||
}
|
||||
|
||||
private void RebuildWinGetEntryIndex()
|
||||
{
|
||||
_entriesByWinGetId.Clear();
|
||||
|
||||
foreach (var entry in _allEntries)
|
||||
{
|
||||
if (string.IsNullOrWhiteSpace(entry.WinGetId))
|
||||
{
|
||||
continue;
|
||||
}
|
||||
|
||||
if (!_entriesByWinGetId.TryGetValue(entry.WinGetId, out var entries))
|
||||
{
|
||||
entries = [];
|
||||
_entriesByWinGetId[entry.WinGetId] = entries;
|
||||
}
|
||||
|
||||
entries.Add(entry);
|
||||
}
|
||||
}
|
||||
|
||||
private void ApplyCurrentWinGetOperations()
|
||||
{
|
||||
if (_winGetOperationTrackerService is null)
|
||||
{
|
||||
return;
|
||||
}
|
||||
|
||||
List<ExtensionGalleryItemViewModel> snapshot;
|
||||
lock (_entriesLock)
|
||||
{
|
||||
snapshot = [.. _allEntries];
|
||||
}
|
||||
|
||||
foreach (var entry in snapshot)
|
||||
{
|
||||
if (string.IsNullOrWhiteSpace(entry.WinGetId))
|
||||
{
|
||||
continue;
|
||||
}
|
||||
|
||||
var operation = _winGetOperationTrackerService.GetLatestOperation(entry.WinGetId);
|
||||
if (operation is not null)
|
||||
{
|
||||
entry.ApplyTrackedOperation(operation);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private void OnWinGetOperationStarted(object? sender, WinGetPackageOperationEventArgs e)
|
||||
{
|
||||
QueueTrackedOperationApplication(e.Operation, refreshPackageStatus: false);
|
||||
}
|
||||
|
||||
private void OnWinGetOperationUpdated(object? sender, WinGetPackageOperationEventArgs e)
|
||||
{
|
||||
QueueTrackedOperationApplication(e.Operation, refreshPackageStatus: false);
|
||||
}
|
||||
|
||||
private void OnWinGetOperationCompleted(object? sender, WinGetPackageOperationEventArgs e)
|
||||
{
|
||||
QueueTrackedOperationApplication(e.Operation, refreshPackageStatus: true);
|
||||
}
|
||||
|
||||
private void QueueTrackedOperationApplication(WinGetPackageOperation operation, bool refreshPackageStatus)
|
||||
{
|
||||
_ = Task.Factory.StartNew(
|
||||
async () => await ApplyTrackedOperationAsync(operation, refreshPackageStatus),
|
||||
CancellationToken.None,
|
||||
TaskCreationOptions.DenyChildAttach,
|
||||
_uiScheduler).Unwrap();
|
||||
}
|
||||
|
||||
private async Task ApplyTrackedOperationAsync(WinGetPackageOperation operation, bool refreshPackageStatus)
|
||||
{
|
||||
List<ExtensionGalleryItemViewModel>? entries;
|
||||
lock (_entriesLock)
|
||||
{
|
||||
if (!_entriesByWinGetId.TryGetValue(operation.PackageId, out entries))
|
||||
{
|
||||
return;
|
||||
}
|
||||
|
||||
// Snapshot to iterate outside the lock
|
||||
entries = [.. entries];
|
||||
}
|
||||
|
||||
foreach (var entry in entries)
|
||||
{
|
||||
entry.ApplyTrackedOperation(operation);
|
||||
}
|
||||
|
||||
QueueApplyFilter();
|
||||
|
||||
if (!refreshPackageStatus || !operation.IsCompleted || operation.State != WinGetPackageOperationState.Succeeded)
|
||||
{
|
||||
return;
|
||||
}
|
||||
|
||||
foreach (var entry in entries)
|
||||
{
|
||||
await entry.RefreshWinGetPackageInfoAsync(operation.Kind);
|
||||
}
|
||||
}
|
||||
|
||||
private void QueueApplyFilter()
|
||||
{
|
||||
_ = Task.Factory.StartNew(
|
||||
ApplyFilter,
|
||||
CancellationToken.None,
|
||||
TaskCreationOptions.DenyChildAttach,
|
||||
_uiScheduler);
|
||||
}
|
||||
|
||||
private static Task<T> RunInBackgroundAsync<T>(Func<Task<T>> operation, CancellationToken cancellationToken)
|
||||
{
|
||||
return Task.Run(operation, cancellationToken);
|
||||
}
|
||||
|
||||
private static int CompareByName(ExtensionGalleryItemViewModel left, ExtensionGalleryItemViewModel right)
|
||||
{
|
||||
var result = SortStringComparer.Compare(left.DisplayTitle, right.DisplayTitle);
|
||||
if (result != 0)
|
||||
{
|
||||
return result;
|
||||
}
|
||||
|
||||
result = SortStringComparer.Compare(left.DisplayAuthorName, right.DisplayAuthorName);
|
||||
if (result != 0)
|
||||
{
|
||||
return result;
|
||||
}
|
||||
|
||||
return SortStringComparer.Compare(left.Id, right.Id);
|
||||
}
|
||||
|
||||
private static int CompareByAuthor(ExtensionGalleryItemViewModel left, ExtensionGalleryItemViewModel right)
|
||||
{
|
||||
var result = SortStringComparer.Compare(left.DisplayAuthorName, right.DisplayAuthorName);
|
||||
if (result != 0)
|
||||
{
|
||||
return result;
|
||||
}
|
||||
|
||||
return CompareByName(left, right);
|
||||
}
|
||||
|
||||
private static int CompareByInstallationStatus(ExtensionGalleryItemViewModel left, ExtensionGalleryItemViewModel right)
|
||||
{
|
||||
var result = GetInstallationStatusSortRank(left).CompareTo(GetInstallationStatusSortRank(right));
|
||||
if (result != 0)
|
||||
{
|
||||
return result;
|
||||
}
|
||||
|
||||
return CompareByName(left, right);
|
||||
}
|
||||
|
||||
private static int GetInstallationStatusSortRank(ExtensionGalleryItemViewModel entry)
|
||||
{
|
||||
if (entry.IsUpdateAvailable)
|
||||
{
|
||||
return 0;
|
||||
}
|
||||
|
||||
if (entry.IsInstalled)
|
||||
{
|
||||
return 1;
|
||||
}
|
||||
|
||||
if (entry.IsInstalledStateKnown)
|
||||
{
|
||||
return 2;
|
||||
}
|
||||
|
||||
return 3;
|
||||
}
|
||||
|
||||
partial void OnSelectedSortOptionChanged(ExtensionGallerySortOption value)
|
||||
{
|
||||
OnPropertyChanged(nameof(IsSortByFeaturedSelected));
|
||||
OnPropertyChanged(nameof(IsSortByNameSelected));
|
||||
OnPropertyChanged(nameof(IsSortByAuthorSelected));
|
||||
OnPropertyChanged(nameof(IsSortByInstallationStatusSelected));
|
||||
ApplyFilter();
|
||||
}
|
||||
|
||||
public void Dispose()
|
||||
{
|
||||
if (_disposed)
|
||||
{
|
||||
return;
|
||||
}
|
||||
|
||||
_disposed = true;
|
||||
|
||||
_cts.Cancel();
|
||||
_cts.Dispose();
|
||||
|
||||
if (_winGetOperationTrackerService is not null)
|
||||
{
|
||||
_winGetOperationTrackerService.OperationStarted -= OnWinGetOperationStarted;
|
||||
_winGetOperationTrackerService.OperationUpdated -= OnWinGetOperationUpdated;
|
||||
_winGetOperationTrackerService.OperationCompleted -= OnWinGetOperationCompleted;
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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);
|
||||
|
||||
@@ -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'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);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -91,6 +91,11 @@ public record SettingsModel
|
||||
|
||||
// </Theme settings>
|
||||
|
||||
// Extension Gallery settings
|
||||
public string? GalleryFeedUrl { get; init; }
|
||||
|
||||
// </Gallery settings>
|
||||
|
||||
// END SETTINGS
|
||||
///////////////////////////////////////////////////////////////////////////
|
||||
|
||||
|
||||
@@ -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),
|
||||
};
|
||||
}
|
||||
}
|
||||
@@ -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));
|
||||
}
|
||||
}
|
||||
@@ -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" />
|
||||
|
||||
@@ -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);
|
||||
|
||||
@@ -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>
|
||||
@@ -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);
|
||||
}
|
||||
@@ -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"
|
||||
|
||||
@@ -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="" />
|
||||
<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="" />
|
||||
</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>
|
||||
@@ -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();
|
||||
}
|
||||
}
|
||||
@@ -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;
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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";
|
||||
}
|
||||
@@ -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>
|
||||
|
||||
@@ -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="" />
|
||||
</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="" />
|
||||
</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="" />
|
||||
<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="" />
|
||||
<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="" />
|
||||
</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="" />
|
||||
<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>
|
||||
@@ -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;
|
||||
}
|
||||
}
|
||||
@@ -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="" />
|
||||
</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="" />
|
||||
</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>
|
||||
@@ -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();
|
||||
}
|
||||
}
|
||||
@@ -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=}">
|
||||
<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
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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=}"
|
||||
Tag="Appearance" />
|
||||
<NavigationViewItem
|
||||
x:Name="ExtensionPageNavItem"
|
||||
x:Uid="Settings_GeneralPage_NavigationViewItem_Extensions"
|
||||
Icon="{ui:FontIcon Glyph=}"
|
||||
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=}"
|
||||
Tag="Extensions" />
|
||||
<!-- x:Uid="Settings_GeneralPage_NavigationViewItem_Gallery" -->
|
||||
<NavigationViewItem
|
||||
x:Name="GalleryPageNavItem"
|
||||
Content="Gallery"
|
||||
Icon="{ui:FontIcon Glyph=}"
|
||||
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="" />
|
||||
</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>
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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>
|
||||
|
||||
235
src/modules/cmdpal/Microsoft.CmdPal.UI/Styles/Button.xaml
Normal file
235
src/modules/cmdpal/Microsoft.CmdPal.UI/Styles/Button.xaml
Normal 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=""
|
||||
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>
|
||||
@@ -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));
|
||||
}
|
||||
}
|
||||
@@ -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();
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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
Reference in New Issue
Block a user