diff --git a/src/MauiSherpa.Core/Interfaces.cs b/src/MauiSherpa.Core/Interfaces.cs index c8326760..130f40df 100644 --- a/src/MauiSherpa.Core/Interfaces.cs +++ b/src/MauiSherpa.Core/Interfaces.cs @@ -2384,6 +2384,11 @@ Task RunAsync( /// Whether an operation is currently running /// bool IsRunning { get; } + + /// + /// Requests cancellation of the running operation + /// + void RequestCancellation(); /// /// Event fired when the modal is shown @@ -4001,6 +4006,15 @@ List DestinationKeys public interface IPublishProfileService { Task> GetProfilesAsync(); + /// + /// Reads the profiles, reporting the merged set each time a provider finishes so the + /// UI can render the fast providers' profiles without waiting on the slow ones. + /// + Task> GetProfilesAsync( + IProgress>? progress); + /// Drops the cached profiles and re-reads them from every configured provider. + Task> RefreshProfilesAsync( + IProgress>? progress = null); Task GetProfileAsync(string id); Task SaveProfileAsync(PublishProfile profile); Task DeleteProfileAsync(string id); diff --git a/src/MauiSherpa.Core/Services/PublishProfileService.cs b/src/MauiSherpa.Core/Services/PublishProfileService.cs index 72e61e11..acdf2cbc 100644 --- a/src/MauiSherpa.Core/Services/PublishProfileService.cs +++ b/src/MauiSherpa.Core/Services/PublishProfileService.cs @@ -9,6 +9,7 @@ namespace MauiSherpa.Core.Services; public class PublishProfileService : IPublishProfileService { const string CloudKeyPrefix = "sherpa-publish-profiles/"; + const int MaxParallelSecretReads = 8; readonly ICloudSecretsService _cloudService; readonly ICertificateSyncService _certSync; @@ -66,7 +67,11 @@ public PublishProfileService( _syncCoordinator.ItemStateChanged += OnSyncItemStateChanged; } - public async Task> GetProfilesAsync() + public Task> GetProfilesAsync() => + GetProfilesAsync(progress: null); + + public async Task> GetProfilesAsync( + IProgress>? progress) { lock (_cacheLock) { @@ -84,72 +89,15 @@ public async Task> GetProfilesAsync() } var generation = Interlocked.Read(ref _cacheGeneration); - var profiles = new Dictionary(StringComparer.Ordinal); - if (_providerRegistry is not null) - { - var providerConfigs = await _providerRegistry.GetProvidersAsync(); - foreach (var config in providerConfigs - .OrderBy(provider => provider.ProviderType == CloudSecretsProviderType.Local ? 0 : 1) - .ThenBy(provider => provider.Name, StringComparer.OrdinalIgnoreCase)) - { - try - { - var provider = await _providerRegistry.GetProviderAsync(config.Id); - if (provider is null) - continue; - - var keys = await provider.ListSecretsAsync(CloudKeyPrefix); - foreach (var key in keys) - { - var bytes = await provider.GetSecretAsync(key); - if (bytes is null) - continue; - - var profile = JsonSerializer.Deserialize( - Encoding.UTF8.GetString(bytes), - JsonOptions); - if (profile is not null) - profiles.TryAdd(profile.Id, profile); - } - } - catch (Exception ex) - { - _logger.LogWarning($"Failed to load publish profiles from '{config.Name}': {ex.Message}"); - } - } - } - else if (_cloudService.ActiveProvider is not null) - { - var keys = await _cloudService.ListSecretsAsync(CloudKeyPrefix); - foreach (var key in keys) - { - try - { - var bytes = await _cloudService.GetSecretAsync(key); - if (bytes is null) - continue; - - var profile = JsonSerializer.Deserialize( - Encoding.UTF8.GetString(bytes), - JsonOptions); - if (profile is not null) - profiles.TryAdd(profile.Id, profile); - } - catch (Exception ex) - { - _logger.LogWarning($"Failed to load publish profile '{key}': {ex.Message}"); - } - } - } - - var loaded = profiles.Values.OrderBy(p => p.Name).ToList(); + var loaded = await LoadProfilesFromProvidersAsync(progress); lock (_cacheLock) { + // If the cache was invalidated while we were reading, leave it empty so the + // next call refetches — but still hand back what we just read. Returning + // nothing here made the page render an empty grid after a sync completed. if (generation == Interlocked.Read(ref _cacheGeneration)) _cache = loaded; - return _cache is not null - ? _cache - : Array.Empty(); + return loaded; } } finally @@ -158,6 +106,137 @@ public async Task> GetProfilesAsync() } } + public Task> RefreshProfilesAsync( + IProgress>? progress = null) + { + InvalidateCache(); + return GetProfilesAsync(progress); + } + + async Task> LoadProfilesFromProvidersAsync( + IProgress>? progress) + { + var profiles = new Dictionary(StringComparer.Ordinal); + + if (_providerRegistry is null) + { + if (_cloudService.ActiveProvider is null) + return []; + + var activeKeys = await _cloudService.ListSecretsAsync(CloudKeyPrefix); + foreach (var profile in await ReadProfilesAsync(activeKeys, key => _cloudService.GetSecretAsync(key))) + profiles.TryAdd(profile.Id, profile); + return Snapshot(profiles); + } + + var orderedConfigs = (await _providerRegistry.GetProvidersAsync()) + .OrderBy(provider => provider.ProviderType == CloudSecretsProviderType.Local ? 0 : 1) + .ThenBy(provider => provider.Name, StringComparer.OrdinalIgnoreCase) + .ToList(); + + // Every provider starts listing at once, but contents are read in priority order so + // a slow remote vault never holds up the profiles a fast local one already has. + var listings = orderedConfigs.Select(ListProfileKeysAsync).ToList(); + var claimedIds = new HashSet(StringComparer.Ordinal); + + foreach (var listingTask in listings) + { + var listing = await listingTask; + if (listing.Provider is null) + continue; + + // Only read the copies this provider owns outright. A key already claimed by a + // higher-priority provider would be deserialized and then dropped on merge, so + // fetching it is pure latency — and remote vaults are where that latency lives. + var keysToRead = listing.Keys + .Where(key => !TryGetComparableProfileId(key, out var id) || claimedIds.Add(id)) + .ToList(); + + var loaded = await ReadProfilesAsync(keysToRead, key => listing.Provider.GetSecretAsync(key)); + foreach (var profile in loaded) + profiles.TryAdd(profile.Id, profile); + + _logger.LogInformation( + $"Provider '{listing.Config.Name}' listed {listing.Keys.Count} publish profile key(s), " + + $"read {keysToRead.Count}, {loaded.Count} readable"); + + progress?.Report(Snapshot(profiles)); + } + + return Snapshot(profiles); + } + + static List Snapshot(Dictionary profiles) => + profiles.Values.OrderBy(profile => profile.Name).ToList(); + + static bool TryGetComparableProfileId(string key, out string id) + { + if (SecretItemAdapterHelper.TryGetRelativeKey(key, CloudKeyPrefix, out var relativeKey)) + { + id = SecretItemAdapterHelper.GetComparableStorageKey(relativeKey); + return true; + } + + id = string.Empty; + return false; + } + + async Task ListProfileKeysAsync(CloudSecretsProviderConfig config) + { + try + { + var provider = await _providerRegistry!.GetProviderAsync(config.Id); + if (provider is null) + return new ProviderListing(config, null, Array.Empty()); + + var keys = await provider.ListSecretsAsync(CloudKeyPrefix); + return new ProviderListing(config, provider, keys); + } + catch (Exception ex) + { + _logger.LogWarning($"Failed to load publish profiles from '{config.Name}': {ex.Message}"); + return new ProviderListing(config, null, Array.Empty()); + } + } + + async Task> ReadProfilesAsync( + IReadOnlyList keys, + Func> readSecretAsync) + { + var loaded = new PublishProfile?[keys.Count]; + using var throttle = new SemaphoreSlim(MaxParallelSecretReads); + + await Task.WhenAll(keys.Select(async (key, index) => + { + await throttle.WaitAsync(); + try + { + var bytes = await readSecretAsync(key); + if (bytes is null) + return; + + loaded[index] = JsonSerializer.Deserialize( + Encoding.UTF8.GetString(bytes), + JsonOptions); + } + catch (Exception ex) + { + _logger.LogWarning($"Failed to load publish profile '{key}': {ex.Message}"); + } + finally + { + throttle.Release(); + } + })); + + return loaded.Where(profile => profile is not null).Select(profile => profile!).ToList(); + } + + readonly record struct ProviderListing( + CloudSecretsProviderConfig Config, + ICloudSecretsProvider? Provider, + IReadOnlyList Keys); + public async Task GetProfileAsync(string id) { var profiles = await GetProfilesAsync(); @@ -211,10 +290,15 @@ void OnSyncItemStateChanged(SecretItemRef item) if (item.Kind != SecretItemKind.PublishProfile) return; + InvalidateCache(); + OnProfilesChanged?.Invoke(); + } + + void InvalidateCache() + { Interlocked.Increment(ref _cacheGeneration); lock (_cacheLock) _cache = null; - OnProfilesChanged?.Invoke(); } public async Task> ResolveSecretsAsync( diff --git a/src/MauiSherpa.Core/Services/SecretItemAdapterHelper.cs b/src/MauiSherpa.Core/Services/SecretItemAdapterHelper.cs index 8df1097e..be8cdad5 100644 --- a/src/MauiSherpa.Core/Services/SecretItemAdapterHelper.cs +++ b/src/MauiSherpa.Core/Services/SecretItemAdapterHelper.cs @@ -162,6 +162,13 @@ public static async Task DeleteArtifactsAsync( public static bool KeyMatchesPrefix(string key, string prefix) => NormalizeStorageKey(key).StartsWith(NormalizeStorageKey(prefix), StringComparison.Ordinal); + /// + /// Returns a form of the key that ignores the punctuation differences providers + /// introduce (Azure Key Vault cannot store '/', for example), so keys from two + /// different providers can be compared for identity. + /// + public static string GetComparableStorageKey(string key) => NormalizeStorageKey(key); + public static bool StorageKeysEqual(string left, string right) => string.Equals( NormalizeStorageKey(left), diff --git a/src/MauiSherpa.Core/Services/SecretsPublisherService.cs b/src/MauiSherpa.Core/Services/SecretsPublisherService.cs index a731e8cf..342f3efe 100644 --- a/src/MauiSherpa.Core/Services/SecretsPublisherService.cs +++ b/src/MauiSherpa.Core/Services/SecretsPublisherService.cs @@ -224,13 +224,26 @@ public async Task DeletePublisherAsync(string id) public async Task TestConnectionAsync(string publisherId, CancellationToken cancellationToken = default) { - var publisher = GetPublisherInstance(publisherId); + var publisher = await ResolvePublisherInstanceAsync(publisherId); if (publisher == null) return false; return await publisher.TestConnectionAsync(cancellationToken); } + /// + /// Resolves a publisher, loading the configured publishers first when nothing has + /// populated the cache yet. is synchronous and can + /// only see an already-loaded cache, so callers that reach it cold get nothing back. + /// + async Task ResolvePublisherInstanceAsync(string publisherId) + { + if (_cachedPublishers is null) + await GetPublishersAsync(); + + return GetPublisherInstance(publisherId); + } + public ISecretsPublisher? GetPublisherInstance(string publisherId) { if (_publisherInstances.TryGetValue(publisherId, out var cached)) @@ -255,7 +268,7 @@ public async Task TestConnectionAsync(string publisherId, CancellationToke public async Task> ListRepositoriesAsync(string publisherId, string? filter = null, CancellationToken cancellationToken = default) { - var publisher = GetPublisherInstance(publisherId); + var publisher = await ResolvePublisherInstanceAsync(publisherId); if (publisher == null) return new List(); @@ -264,7 +277,7 @@ public async Task> ListRepositoriesAsync(stri public async Task PublishSecretsAsync(string publisherId, string repositoryId, IReadOnlyDictionary secrets, IProgress? progress = null, CancellationToken cancellationToken = default) { - var publisher = GetPublisherInstance(publisherId); + var publisher = await ResolvePublisherInstanceAsync(publisherId); if (publisher == null) throw new InvalidOperationException($"Publisher not found: {publisherId}"); diff --git a/src/MauiSherpa.MacOS/BlazorContentPage.cs b/src/MauiSherpa.MacOS/BlazorContentPage.cs index 9f9546a5..67003265 100644 --- a/src/MauiSherpa.MacOS/BlazorContentPage.cs +++ b/src/MauiSherpa.MacOS/BlazorContentPage.cs @@ -1070,6 +1070,8 @@ void FullRebuildToolbar() ("create", "New Secret", "plus"), ("export", "Export", "square.and.arrow.up"), ("export-selected", "Export Selected", "square.and.arrow.up"), + ("select", "Select", "checkmark.circle"), + ("publish-selected", "Publish Selected", "square.and.arrow.up"), ("cancel-selection", "Cancel", "xmark"), ("create-folder", "New Folder", "folder.badge.plus"), ("rename-folder", "Rename Folder", "pencil"), diff --git a/src/MauiSherpa/Components/SecretProviderPicker.razor b/src/MauiSherpa/Components/SecretProviderPicker.razor index 3c5f54e4..453d9609 100644 --- a/src/MauiSherpa/Components/SecretProviderPicker.razor +++ b/src/MauiSherpa/Components/SecretProviderPicker.razor @@ -39,6 +39,7 @@ {