From 3914483a506224efc1b92b27912805ba75547174 Mon Sep 17 00:00:00 2001 From: redth Date: Wed, 4 Feb 2026 19:20:24 -0500 Subject: [PATCH] Add bundle ID capability management UI & APIs Introduce Bundle ID capability support across core and UI: add AppleBundleIdCapability and CapabilityCategories (groups, display names, icons). Extend AppleBundleId with Capabilities and update IAppleConnectService to include CreateBundleId(seedId?), GetBundleIdCapabilitiesAsync, GetAvailableCapabilityTypesAsync, EnableCapabilityAsync and DisableCapabilityAsync. Implement capability operations and logging in AppleConnectService. Update BundleIds.razor to show capability badges, add a capabilities modal for viewing/toggling grouped capabilities (with caching, loading state and toggling logic), plus related styles and a platform formatting helper. --- src/MauiSherpa.Core/Interfaces.cs | 126 ++++++- .../Services/AppleConnectService.cs | 73 +++- src/MauiSherpa/Pages/BundleIds.razor | 346 +++++++++++++++++- 3 files changed, 540 insertions(+), 5 deletions(-) diff --git a/src/MauiSherpa.Core/Interfaces.cs b/src/MauiSherpa.Core/Interfaces.cs index b4ba2794..7c539049 100644 --- a/src/MauiSherpa.Core/Interfaces.cs +++ b/src/MauiSherpa.Core/Interfaces.cs @@ -167,9 +167,125 @@ public record AppleBundleId( string Identifier, string Name, string Platform, - string? SeedId + string? SeedId, + IReadOnlyList? Capabilities = null ); +/// +/// Represents a capability enabled for a Bundle ID +/// +public record AppleBundleIdCapability( + string Id, + string CapabilityType +); + +/// +/// Category groupings for capabilities +/// +public static class CapabilityCategories +{ + public static readonly IReadOnlyDictionary Categories = new Dictionary + { + ["App Services"] = new[] { "PUSH_NOTIFICATIONS", "ICLOUD", "IN_APP_PURCHASE", "GAME_CENTER", "APPLE_PAY", "WALLET" }, + ["App Capabilities"] = new[] { "HEALTHKIT", "HOMEKIT", "SIRIKIT", "NFC_TAG_READING", "CLASSKIT", "WEATHERKIT" }, + ["App Groups & Data"] = new[] { "APP_GROUPS", "ASSOCIATED_DOMAINS", "DATA_PROTECTION", "KEYCHAIN_SHARING" }, + ["Identity & Security"] = new[] { "SIGN_IN_WITH_APPLE", "APPLE_ID_AUTH", "APP_ATTEST", "AUTOFILL_CREDENTIAL_PROVIDER" }, + ["Network & Communication"] = new[] { "NETWORK_EXTENSIONS", "PERSONAL_VPN", "MULTIPATH", "HOT_SPOT", "ACCESS_WIFI_INFORMATION" }, + ["System"] = new[] { "MAPS", "INTER_APP_AUDIO", "WIRELESS_ACCESSORY_CONFIGURATION", "FONT_INSTALLATION", "DRIVER_KIT" } + }; + + public static string GetCategory(string capabilityType) + { + foreach (var (category, types) in Categories) + { + if (types.Contains(capabilityType)) + return category; + } + return "Other"; + } + + /// + /// Human-readable names for capability types + /// + public static string GetDisplayName(string capabilityType) => capabilityType switch + { + "PUSH_NOTIFICATIONS" => "Push Notifications", + "ICLOUD" => "iCloud", + "IN_APP_PURCHASE" => "In-App Purchase", + "GAME_CENTER" => "Game Center", + "APPLE_PAY" => "Apple Pay", + "WALLET" => "Wallet", + "HEALTHKIT" => "HealthKit", + "HOMEKIT" => "HomeKit", + "SIRIKIT" => "SiriKit", + "NFC_TAG_READING" => "NFC Tag Reading", + "CLASSKIT" => "ClassKit", + "WEATHERKIT" => "WeatherKit", + "APP_GROUPS" => "App Groups", + "ASSOCIATED_DOMAINS" => "Associated Domains", + "DATA_PROTECTION" => "Data Protection", + "KEYCHAIN_SHARING" => "Keychain Sharing", + "SIGN_IN_WITH_APPLE" => "Sign in with Apple", + "APPLE_ID_AUTH" => "Apple ID Authentication", + "APP_ATTEST" => "App Attest", + "AUTOFILL_CREDENTIAL_PROVIDER" => "AutoFill Credential Provider", + "NETWORK_EXTENSIONS" => "Network Extensions", + "PERSONAL_VPN" => "Personal VPN", + "MULTIPATH" => "Multipath", + "HOT_SPOT" => "Hotspot", + "ACCESS_WIFI_INFORMATION" => "Access WiFi Information", + "MAPS" => "Maps", + "INTER_APP_AUDIO" => "Inter-App Audio", + "WIRELESS_ACCESSORY_CONFIGURATION" => "Wireless Accessory Configuration", + "FONT_INSTALLATION" => "Font Installation", + "DRIVER_KIT" => "DriverKit", + "COMMUNICATION_NOTIFICATIONS" => "Communication Notifications", + "TIME_SENSITIVE_NOTIFICATIONS" => "Time Sensitive Notifications", + "GROUP_ACTIVITIES" => "Group Activities", + "FAMILY_CONTROLS" => "Family Controls", + "EXPOSURE_NOTIFICATION" => "Exposure Notification", + "EXTENDED_VIRTUAL_ADDRESSING" => "Extended Virtual Addressing", + "INCREASED_MEMORY_LIMIT" => "Increased Memory Limit", + "COREMEDIA_HLS_LOW_LATENCY" => "Low Latency HLS", + "SYSTEM_EXTENSION_INSTALL" => "System Extension", + "USER_MANAGEMENT" => "User Management", + "MARZIPAN" => "Mac Catalyst", + "CARPLAY_PLAYABLE_CONTENT" => "CarPlay", + _ => capabilityType.Replace("_", " ").ToLowerInvariant() + .Split(' ') + .Select(w => char.ToUpperInvariant(w[0]) + w[1..]) + .Aggregate((a, b) => $"{a} {b}") + }; + + /// + /// Icon class for capability types (Font Awesome) + /// + public static string GetIcon(string capabilityType) => capabilityType switch + { + "PUSH_NOTIFICATIONS" => "fa-bell", + "ICLOUD" => "fa-cloud", + "IN_APP_PURCHASE" => "fa-credit-card", + "GAME_CENTER" => "fa-gamepad", + "APPLE_PAY" => "fa-apple-pay", + "WALLET" => "fa-wallet", + "HEALTHKIT" => "fa-heart-pulse", + "HOMEKIT" => "fa-house", + "SIRIKIT" => "fa-microphone", + "NFC_TAG_READING" => "fa-wifi", + "APP_GROUPS" => "fa-layer-group", + "ASSOCIATED_DOMAINS" => "fa-link", + "DATA_PROTECTION" => "fa-shield-halved", + "KEYCHAIN_SHARING" => "fa-key", + "SIGN_IN_WITH_APPLE" => "fa-apple", + "APP_ATTEST" => "fa-certificate", + "NETWORK_EXTENSIONS" => "fa-network-wired", + "PERSONAL_VPN" => "fa-lock", + "MAPS" => "fa-map", + "WEATHERKIT" => "fa-cloud-sun", + _ => "fa-puzzle-piece" + }; +} + public record AppleDevice( string Id, string Udid, @@ -220,9 +336,15 @@ public interface IAppleConnectService { // Bundle IDs Task> GetBundleIdsAsync(); - Task CreateBundleIdAsync(string identifier, string name, string platform); + Task CreateBundleIdAsync(string identifier, string name, string platform, string? seedId = null); Task DeleteBundleIdAsync(string id); + // Bundle ID Capabilities + Task> GetBundleIdCapabilitiesAsync(string bundleIdResourceId); + Task> GetAvailableCapabilityTypesAsync(); + Task EnableCapabilityAsync(string bundleIdResourceId, string capabilityType); + Task DisableCapabilityAsync(string capabilityId); + // Devices Task> GetDevicesAsync(); Task RegisterDeviceAsync(string udid, string name, string platform); diff --git a/src/MauiSherpa.Core/Services/AppleConnectService.cs b/src/MauiSherpa.Core/Services/AppleConnectService.cs index f372d1ed..0eda911d 100644 --- a/src/MauiSherpa.Core/Services/AppleConnectService.cs +++ b/src/MauiSherpa.Core/Services/AppleConnectService.cs @@ -106,7 +106,7 @@ public async Task> GetBundleIdsAsync() } } - public async Task CreateBundleIdAsync(string identifier, string name, string platform) + public async Task CreateBundleIdAsync(string identifier, string name, string platform, string? seedId = null) { _logger.LogInformation($"Creating bundle ID: {identifier}"); try @@ -123,7 +123,8 @@ public async Task CreateBundleIdAsync(string identifier, string n { Identifier = identifier, Name = name, - Platform = platformEnum + Platform = platformEnum, + SeedId = seedId }; var response = await client.CreateBundleIdAsync(attributes, default); @@ -157,6 +158,74 @@ public async Task DeleteBundleIdAsync(string id) } } + // Bundle ID Capabilities + public async Task> GetBundleIdCapabilitiesAsync(string bundleIdResourceId) + { + _logger.LogInformation($"Fetching capabilities for bundle ID: {bundleIdResourceId}"); + try + { + var client = await GetClientAsync(); + var response = await client.ListBundleIdCapabilitiesAsync(bundleIdResourceId, default); + + return response.Data + .Select(c => new AppleBundleIdCapability( + c.Id, + c.Attributes?.CapabilityType.ToString() ?? "Unknown")) + .ToList(); + } + catch (Exception ex) + { + _logger.LogError($"Failed to fetch capabilities: {ex.Message}", ex); + throw; + } + } + + public Task> GetAvailableCapabilityTypesAsync() + { + var types = AppStoreConnectClient.GetAvailableCapabilityTypes() + .Select(c => c.ToString()) + .ToList(); + return Task.FromResult>(types); + } + + public async Task EnableCapabilityAsync(string bundleIdResourceId, string capabilityType) + { + _logger.LogInformation($"Enabling capability {capabilityType} for bundle ID: {bundleIdResourceId}"); + try + { + var client = await GetClientAsync(); + var capType = Enum.TryParse(capabilityType, out var ct) ? ct : CapabilityType.Unknown; + if (capType == CapabilityType.Unknown) + { + throw new ArgumentException($"Unknown capability type: {capabilityType}"); + } + + await client.EnableCapabilityAsync(bundleIdResourceId, capType, null, default); + _logger.LogInformation($"Successfully enabled {capabilityType}"); + } + catch (Exception ex) + { + _logger.LogError($"Failed to enable capability: {ex.Message}", ex); + throw; + } + } + + public async Task DisableCapabilityAsync(string capabilityId) + { + _logger.LogInformation($"Disabling capability: {capabilityId}"); + try + { + var client = await GetClientAsync(); + await client.DisableCapabilityAsync(capabilityId, default); + _logger.LogInformation($"Successfully disabled capability"); + } + catch (Exception ex) + { + _logger.LogError($"Failed to disable capability: {ex.Message}", ex); + throw; + } + } + // Devices public async Task> GetDevicesAsync() { diff --git a/src/MauiSherpa/Pages/BundleIds.razor b/src/MauiSherpa/Pages/BundleIds.razor index 66168bcb..2fe462c2 100644 --- a/src/MauiSherpa/Pages/BundleIds.razor +++ b/src/MauiSherpa/Pages/BundleIds.razor @@ -82,6 +82,7 @@ { @foreach (var bundle in FilteredBundleIds) { + var capabilities = GetCapabilities(bundle.Id);
@@ -92,14 +93,32 @@
@bundle.Name
- @bundle.Platform + @FormatPlatform(bundle.Platform) @if (!string.IsNullOrEmpty(bundle.SeedId)) { @bundle.SeedId }
+ @if (capabilities.Any()) + { +
+ @foreach (var cap in capabilities.Take(5)) + { + + + + } + @if (capabilities.Count > 5) + { + +@(capabilities.Count - 5) + } +
+ }
+ @@ -149,6 +168,63 @@
} +@if (showCapabilitiesDialog && selectedBundleForCapabilities != null) +{ + +} + @code { @@ -346,6 +564,17 @@ private string newName = ""; private string newPlatform = "UNIVERSAL"; + // Capabilities state + private bool showCapabilitiesDialog = false; + private AppleBundleId? selectedBundleForCapabilities = null; + private List currentCapabilities = new(); + private List availableCapabilityTypes = new(); + private bool isLoadingCapabilities = false; + private bool isTogglingCapability = false; + + // Capabilities cache per bundle ID + private Dictionary> capabilitiesCache = new(); + private bool HasActiveFilters => !string.IsNullOrEmpty(searchQuery) || !string.IsNullOrEmpty(filterPlatform); private IEnumerable FilteredBundleIds => bundleIds @@ -477,4 +706,119 @@ await DialogService.CopyToClipboardAsync(text); await AlertService.ShowToastAsync("Copied to clipboard"); } + + // Capability-related methods + private List GetCapabilities(string bundleId) + { + return capabilitiesCache.TryGetValue(bundleId, out var caps) ? caps : new(); + } + + private async Task ShowEditCapabilitiesDialog(AppleBundleId bundle) + { + selectedBundleForCapabilities = bundle; + showCapabilitiesDialog = true; + isLoadingCapabilities = true; + currentCapabilities.Clear(); + StateHasChanged(); + + try + { + // Load available capability types if not already loaded + if (!availableCapabilityTypes.Any()) + { + availableCapabilityTypes = (await AppleService.GetAvailableCapabilityTypesAsync()).ToList(); + } + + // Load current capabilities for this bundle + var caps = await AppleService.GetBundleIdCapabilitiesAsync(bundle.Id); + currentCapabilities = caps.ToList(); + capabilitiesCache[bundle.Id] = currentCapabilities; + } + catch (Exception ex) + { + await AlertService.ShowToastAsync($"Failed to load capabilities: {ex.Message}"); + } + finally + { + isLoadingCapabilities = false; + StateHasChanged(); + } + } + + private void CloseCapabilitiesDialog() + { + showCapabilitiesDialog = false; + selectedBundleForCapabilities = null; + currentCapabilities.Clear(); + } + + private IEnumerable>> GetGroupedCapabilities() + { + var grouped = availableCapabilityTypes + .GroupBy(cap => CapabilityCategories.GetCategory(cap)) + .OrderBy(g => g.Key == "Other" ? 1 : 0) + .ThenBy(g => g.Key) + .Select(g => new KeyValuePair>(g.Key, g.ToList())); + return grouped; + } + + private bool IsCapabilityEnabled(string capabilityType) + { + return currentCapabilities.Any(c => c.CapabilityType == capabilityType); + } + + private string? GetCapabilityId(string capabilityType) + { + return currentCapabilities.FirstOrDefault(c => c.CapabilityType == capabilityType)?.Id; + } + + private async Task ToggleCapability(string capabilityType, string? capabilityId, bool enable) + { + if (selectedBundleForCapabilities == null) return; + + isTogglingCapability = true; + StateHasChanged(); + + try + { + if (enable && capabilityId == null) + { + // Enable capability + await AppleService.EnableCapabilityAsync(selectedBundleForCapabilities.Id, capabilityType); + await AlertService.ShowToastAsync($"Enabled {CapabilityCategories.GetDisplayName(capabilityType)}"); + } + else if (!enable && capabilityId != null) + { + // Disable capability + await AppleService.DisableCapabilityAsync(capabilityId); + await AlertService.ShowToastAsync($"Disabled {CapabilityCategories.GetDisplayName(capabilityType)}"); + } + + // Refresh capabilities + var caps = await AppleService.GetBundleIdCapabilitiesAsync(selectedBundleForCapabilities.Id); + currentCapabilities = caps.ToList(); + capabilitiesCache[selectedBundleForCapabilities.Id] = currentCapabilities; + } + catch (Exception ex) + { + await AlertService.ShowToastAsync($"Failed: {ex.Message}"); + } + finally + { + isTogglingCapability = false; + StateHasChanged(); + } + } + + private static string FormatPlatform(string? platform) + { + if (string.IsNullOrEmpty(platform)) return "Universal"; + return platform.ToUpperInvariant() switch + { + "IOS" => "iOS", + "MAC_OS" or "MACOS" => "macOS", + "UNIVERSAL" => "Universal", + _ => platform + }; + } }