From 3c2d18598942220e86b7cc8c6da3110f2e04a395 Mon Sep 17 00:00:00 2001 From: redth Date: Wed, 4 Feb 2026 11:52:11 -0500 Subject: [PATCH 1/3] Add CI Secrets wizard and local cert service Introduce local keychain certificate support and a CI Secrets Wizard UI. Adds new records/enums and ILocalCertificateService to Core.Interfaces for representing local signing identities and CI wizard state. Implements LocalCertificateService which queries the macOS `security` tool, caches identities, matches API certificates to local identities, and can export P12 files (platform-limited; uses macOS security CLI). Adds a large Blazor component CISecretsWizard.razor that provides a multi-step wizard to select platform, distribution, bundle id, certificates, profiles and export CI secrets (including placeholders for P12 export and profile encoding). Updates MauiProgram and ProvisioningProfiles to integrate the new wizard/service (wiring and minor changes to provisioning UI). Includes TODOs/placeholder behavior for P12 export and profile base64 encoding that should be implemented when signing/export flow is completed. --- src/MauiSherpa.Core/Interfaces.cs | 105 ++ .../Services/LocalCertificateService.cs | 241 +++ .../Components/CISecretsWizard.razor | 1447 +++++++++++++++++ src/MauiSherpa/MauiProgram.cs | 1 + .../Pages/ProvisioningProfiles.razor | 14 +- 5 files changed, 1807 insertions(+), 1 deletion(-) create mode 100644 src/MauiSherpa.Core/Services/LocalCertificateService.cs create mode 100644 src/MauiSherpa/Components/CISecretsWizard.razor diff --git a/src/MauiSherpa.Core/Interfaces.cs b/src/MauiSherpa.Core/Interfaces.cs index 4bfef9a4..1bc77520 100644 --- a/src/MauiSherpa.Core/Interfaces.cs +++ b/src/MauiSherpa.Core/Interfaces.cs @@ -322,6 +322,111 @@ public interface IAppleRootCertService IReadOnlyDictionary? GetCachedCerts(); } +// ============================================================================ +// Local Signing Identities - Keychain Certificate Management +// ============================================================================ + +/// +/// A signing identity from the local macOS keychain that includes the private key +/// +public record LocalSigningIdentity( + string Identity, // Full identity string (e.g., "Apple Development: Name (TEAM)") + string CommonName, // Certificate common name + string? TeamId, // Team ID extracted from identity + string? SerialNumber, // For matching with API certificates + DateTime? ExpirationDate, + bool IsValid // Valid according to security tool +); + +/// +/// Service for managing local signing identities in the macOS keychain +/// +public interface ILocalCertificateService +{ + /// + /// Gets all valid code signing identities from the local keychain + /// + Task> GetSigningIdentitiesAsync(); + + /// + /// Checks if a certificate with the given serial number has a private key locally + /// + Task HasPrivateKeyAsync(string serialNumber); + + /// + /// Exports a signing identity as a P12/PFX file + /// + /// The full identity string + /// Password to protect the P12 file + /// P12 file contents + Task ExportP12Async(string identity, string password); + + /// + /// Gets whether this service is supported on the current platform + /// + bool IsSupported { get; } +} + +// ============================================================================ +// CI Secrets Wizard Models +// ============================================================================ + +/// +/// Platform selection for CI secrets wizard +/// +public enum ApplePlatformType +{ + iOS, + MacCatalyst, + macOS +} + +/// +/// Distribution type for CI secrets wizard +/// +public enum AppleDistributionType +{ + Development, + AdHoc, // iOS only + AppStore, + Direct // Mac Catalyst / macOS only (Developer ID) +} + +/// +/// State for the CI secrets wizard +/// +public record CISecretsWizardState +{ + public ApplePlatformType Platform { get; init; } + public AppleDistributionType Distribution { get; init; } + public bool NeedsInstallerCert { get; init; } + + // Selected resources + public AppleBundleId? SelectedBundleId { get; init; } + public AppleCertificate? SigningCertificate { get; init; } + public AppleCertificate? InstallerCertificate { get; init; } + public AppleProfile? ProvisioningProfile { get; init; } + + // Local signing identity (with private key) + public LocalSigningIdentity? LocalSigningIdentity { get; init; } + public LocalSigningIdentity? LocalInstallerIdentity { get; init; } + + // Notarization (for Direct Distribution) + public string? NotarizationAppleId { get; init; } + public string? NotarizationPassword { get; init; } + public string? NotarizationTeamId { get; init; } +} + +/// +/// A secret to be exported for CI configuration +/// +public record CISecretExport( + string Name, // Recommended secret name (e.g., "APPLE_CERTIFICATE_P12") + string Value, // The actual secret value (base64 encoded, etc.) + string Description, // Human-readable description + bool IsSensitive // Whether to mask in UI +); + // ============================================================================ // MAUI Doctor Service - SDK/Workload Health Checking // ============================================================================ diff --git a/src/MauiSherpa.Core/Services/LocalCertificateService.cs b/src/MauiSherpa.Core/Services/LocalCertificateService.cs new file mode 100644 index 00000000..4a7e2226 --- /dev/null +++ b/src/MauiSherpa.Core/Services/LocalCertificateService.cs @@ -0,0 +1,241 @@ +using System.Diagnostics; +using System.Text.RegularExpressions; +using MauiSherpa.Core.Interfaces; + +namespace MauiSherpa.Core.Services; + +/// +/// Service for managing local signing identities in the macOS keychain. +/// Uses the 'security' command-line tool to query and export certificates. +/// +public partial class LocalCertificateService : ILocalCertificateService +{ + private readonly ILoggingService _logger; + private readonly IPlatformService _platform; + + // Cache of signing identities + private List? _cachedIdentities; + private DateTime _cacheExpiry = DateTime.MinValue; + private static readonly TimeSpan CacheDuration = TimeSpan.FromMinutes(5); + + public LocalCertificateService(ILoggingService logger, IPlatformService platform) + { + _logger = logger; + _platform = platform; + } + + public bool IsSupported => _platform.IsMacCatalyst; + + public async Task> GetSigningIdentitiesAsync() + { + if (!IsSupported) + { + _logger.LogWarning("LocalCertificateService is only supported on macOS"); + return Array.Empty(); + } + + // Return cached results if still valid + if (_cachedIdentities != null && DateTime.UtcNow < _cacheExpiry) + { + return _cachedIdentities.AsReadOnly(); + } + + _logger.LogInformation("Querying local keychain for signing identities..."); + + var identities = new List(); + + try + { + // Run: security find-identity -v -p codesigning + var result = await RunSecurityCommandAsync("find-identity", "-v", "-p", "codesigning"); + + if (result.ExitCode != 0) + { + _logger.LogError($"security find-identity failed with exit code {result.ExitCode}"); + return identities.AsReadOnly(); + } + + // Parse output - each line looks like: + // 1) HASH "Identity String" + // or with CSSMERR_TP_CERT_EXPIRED for invalid certs + var lines = result.Output.Split('\n', StringSplitOptions.RemoveEmptyEntries); + + foreach (var line in lines) + { + var identity = ParseIdentityLine(line); + if (identity != null) + { + identities.Add(identity); + _logger.LogDebug($"Found identity: {identity.CommonName} (Valid: {identity.IsValid})"); + } + } + + _logger.LogInformation($"Found {identities.Count} signing identities in keychain"); + + // Cache the results + _cachedIdentities = identities; + _cacheExpiry = DateTime.UtcNow + CacheDuration; + } + catch (Exception ex) + { + _logger.LogError($"Failed to query signing identities: {ex.Message}", ex); + } + + return identities.AsReadOnly(); + } + + public async Task HasPrivateKeyAsync(string serialNumber) + { + if (!IsSupported || string.IsNullOrEmpty(serialNumber)) + return false; + + var identities = await GetSigningIdentitiesAsync(); + + // Check if any local identity matches the serial number + return identities.Any(i => + i.SerialNumber != null && + i.SerialNumber.Equals(serialNumber, StringComparison.OrdinalIgnoreCase)); + } + + public async Task ExportP12Async(string identity, string password) + { + if (!IsSupported) + throw new PlatformNotSupportedException("P12 export is only supported on macOS"); + + if (string.IsNullOrEmpty(identity)) + throw new ArgumentException("Identity cannot be empty", nameof(identity)); + + _logger.LogInformation($"Exporting P12 for identity: {identity}"); + + var tempFile = Path.GetTempFileName(); + try + { + // Use security command to export the identity + // security export -t identities -f pkcs12 -P password -o output.p12 + var result = await RunSecurityCommandAsync( + "export", + "-t", "identities", + "-f", "pkcs12", + "-P", password, + "-o", tempFile, + "-k", "login.keychain-db" + ); + + if (result.ExitCode != 0) + { + _logger.LogError($"P12 export failed: {result.Error}"); + throw new InvalidOperationException($"Failed to export P12: {result.Error}"); + } + + // Read the exported file + var p12Data = await File.ReadAllBytesAsync(tempFile); + _logger.LogInformation($"Exported P12: {p12Data.Length} bytes"); + + return p12Data; + } + finally + { + // Clean up temp file + try { File.Delete(tempFile); } catch { } + } + } + + /// + /// Matches a local identity to an API certificate by finding common attributes + /// + public LocalSigningIdentity? FindMatchingIdentity( + IReadOnlyList localIdentities, + AppleCertificate apiCertificate) + { + // Try to match by serial number first (most reliable) + if (!string.IsNullOrEmpty(apiCertificate.SerialNumber)) + { + var bySerial = localIdentities.FirstOrDefault(i => + i.SerialNumber?.Equals(apiCertificate.SerialNumber, StringComparison.OrdinalIgnoreCase) == true); + + if (bySerial != null) + return bySerial; + } + + // Fall back to name matching (less reliable but useful) + var byName = localIdentities.FirstOrDefault(i => + i.CommonName.Contains(apiCertificate.Name, StringComparison.OrdinalIgnoreCase) || + apiCertificate.Name.Contains(i.CommonName, StringComparison.OrdinalIgnoreCase)); + + return byName; + } + + private LocalSigningIdentity? ParseIdentityLine(string line) + { + // Example lines: + // 1) ABC123... "Apple Development: John Doe (TEAMID)" + // 2) DEF456... "Developer ID Application: Company (TEAMID)" (CSSMERR_TP_CERT_EXPIRED) + + var match = IdentityLineRegex().Match(line); + if (!match.Success) + return null; + + var hash = match.Groups["hash"].Value; + var identityString = match.Groups["identity"].Value; + var isValid = !line.Contains("CSSMERR_TP_CERT_EXPIRED") && + !line.Contains("CSSMERR_TP_CERT_REVOKED") && + !line.Contains("CSSMERR_TP_NOT_TRUSTED"); + + // Extract team ID from identity string + var teamIdMatch = TeamIdRegex().Match(identityString); + var teamId = teamIdMatch.Success ? teamIdMatch.Groups[1].Value : null; + + // Extract common name (everything before the team ID part) + var commonName = identityString; + if (teamIdMatch.Success) + { + var parenIndex = identityString.LastIndexOf('('); + if (parenIndex > 0) + commonName = identityString.Substring(0, parenIndex).Trim(); + } + + return new LocalSigningIdentity( + Identity: identityString, + CommonName: commonName, + TeamId: teamId, + SerialNumber: null, // Would need additional query to get this + ExpirationDate: null, // Would need additional query to get this + IsValid: isValid + ); + } + + private async Task<(int ExitCode, string Output, string Error)> RunSecurityCommandAsync(params string[] args) + { + var psi = new ProcessStartInfo + { + FileName = "security", + RedirectStandardOutput = true, + RedirectStandardError = true, + UseShellExecute = false, + CreateNoWindow = true + }; + + foreach (var arg in args) + { + psi.ArgumentList.Add(arg); + } + + using var process = Process.Start(psi); + if (process == null) + return (-1, "", "Failed to start security process"); + + var output = await process.StandardOutput.ReadToEndAsync(); + var error = await process.StandardError.ReadToEndAsync(); + await process.WaitForExitAsync(); + + return (process.ExitCode, output, error); + } + + // Regex to parse identity lines from security find-identity output + [GeneratedRegex(@"^\s*\d+\)\s+(?[A-F0-9]+)\s+""(?[^""]+)""", RegexOptions.IgnoreCase)] + private static partial Regex IdentityLineRegex(); + + // Regex to extract team ID from identity string (usually in parentheses at the end) + [GeneratedRegex(@"\(([A-Z0-9]{10})\)\s*$")] + private static partial Regex TeamIdRegex(); +} diff --git a/src/MauiSherpa/Components/CISecretsWizard.razor b/src/MauiSherpa/Components/CISecretsWizard.razor new file mode 100644 index 00000000..0f6df27e --- /dev/null +++ b/src/MauiSherpa/Components/CISecretsWizard.razor @@ -0,0 +1,1447 @@ +@using MauiSherpa.Core.Interfaces +@inject IAppleConnectService AppleService +@inject IAppleIdentityStateService IdentityState +@inject ILocalCertificateService LocalCertService +@inject IAlertService AlertService +@inject IDialogService DialogService +@inject ILoggingService Logger +@inject NavigationManager Navigation + +@if (IsVisible) +{ +
+
+
+

CI Secrets Wizard

+ +
+ + +
+ @for (int i = 1; i <= TotalSteps; i++) + { + var step = i; +
step ? "completed" : "")"> +
@(currentStep > step ? "✓" : step.ToString())
+
@GetStepLabel(step)
+
+ @if (i < TotalSteps) + { +
+ } + } +
+ +
+ @if (isLoading) + { +
+ + @loadingMessage +
+ } + else + { + @switch (currentStep) + { + case 1: + RenderPlatformStep(); + break; + case 2: + RenderDistributionStep(); + break; + case 3: + RenderInstallerStep(); + break; + case 4: + RenderResourcesStep(); + break; + case 5: + RenderExportStep(); + break; + } + } +
+ + +
+
+} + +@* Step 1: Platform Selection *@ +@{ void RenderPlatformStep() { +
+

Select Platform

+

Which platform are you building for?

+ +
+ + + + + +
+
+}} + +@* Step 2: Distribution Type *@ +@{ void RenderDistributionStep() { +
+

Select Distribution Type

+

How will you distribute your app?

+ +
+ @foreach (var dist in GetAvailableDistributionTypes()) + { + + } +
+
+}} + +@* Step 3: Installer Certificate (Mac only) *@ +@{ void RenderInstallerStep() { +
+

Installer Package

+

Do you need to create a signed installer package (.pkg)?

+ +
+ + + +
+
+}} + +@* Step 4: Resource Validation *@ +@{ void RenderResourcesStep() { +
+

Required Resources

+

Select the resources for your CI pipeline.

+ + +
+
+ + Bundle ID + @if (wizardState.SelectedBundleId != null) + { + + } +
+ @if (bundleIds.Any()) + { + +
+ @foreach (var bundle in FilteredBundleIds) + { + + } + @if (!FilteredBundleIds.Any()) + { +
+ + No matching bundle IDs +
+ } +
+ } + else + { +
+ + No bundle IDs found. + +
+ } +
+ + +
+
+ + @GetSigningCertLabel() + @if (wizardState.SigningCertificate != null && HasPrivateKey(wizardState.SigningCertificate)) + { + + } + else if (wizardState.SigningCertificate != null) + { + + Missing Key + + } +
+ @if (FilteredSigningCerts.Any()) + { +
+ @foreach (var cert in FilteredSigningCerts) + { + var hasKey = HasPrivateKey(cert); + + } +
+ } + else + { +
+ + No @GetSigningCertLabel().ToLower() found. + +
+ } +
+ + + @if (wizardState.NeedsInstallerCert) + { +
+
+ + Developer ID Installer Certificate + @if (wizardState.InstallerCertificate != null && HasPrivateKey(wizardState.InstallerCertificate)) + { + + } +
+ @if (InstallerCerts.Any()) + { +
+ @foreach (var cert in InstallerCerts) + { + var hasKey = HasPrivateKey(cert); + + } +
+ } + else + { +
+ + No Developer ID Installer certificate found. + +
+ } +
+ } + + + @if (NeedsProvisioningProfile) + { +
+
+ + Provisioning Profile + @if (wizardState.ProvisioningProfile != null) + { + + } +
+ @if (FilteredProfiles.Any()) + { + + } + else + { +
+ + No matching provisioning profile found. + +
+ } +
+ } + + + @if (NeedsNotarization) + { +
+
+ + Notarization Credentials +
+
+ + +
+
+ + + + Generate at appleid.apple.com + +
+
+ } +
+}} + +@* Step 5: Export Secrets *@ +@{ void RenderExportStep() { +
+

Export CI Secrets

+

Copy these secrets to your CI pipeline configuration (e.g., GitHub Actions).

+ + @if (!string.IsNullOrEmpty(exportError)) + { +
+ + @exportError +
+ } + +
+ @foreach (var secret in exportedSecrets) + { +
+
+ @secret.Name + +
+
@secret.Description
+ @if (!secret.IsSensitive) + { +
+ @TruncateValue(secret.Value) +
+ } + else + { +
+ •••••••••••••••• + (sensitive) +
+ } +
+ } +
+ + @if (exportedSecrets.Any()) + { +
+ +
+ } +
+}} + + + +@code { + [Parameter] public bool IsVisible { get; set; } + [Parameter] public EventCallback IsVisibleChanged { get; set; } + + private int currentStep = 1; + private bool isLoading = false; + private string loadingMessage = "Loading..."; + private string exportError = ""; + + private CISecretsWizardState wizardState = new() + { + Platform = ApplePlatformType.iOS, + Distribution = AppleDistributionType.Development + }; + + // Data + private List bundleIds = new(); + private List certificates = new(); + private List profiles = new(); + private List localIdentities = new(); + private List exportedSecrets = new(); + + // Search/filter + private string bundleIdSearch = ""; + + private int TotalSteps => ShowInstallerStep ? 5 : 4; + + private bool ShowInstallerStep => + wizardState.Distribution == AppleDistributionType.Direct && + (wizardState.Platform == ApplePlatformType.MacCatalyst || wizardState.Platform == ApplePlatformType.macOS); + + private bool NeedsProvisioningProfile => + !(wizardState.Platform == ApplePlatformType.macOS && wizardState.Distribution == AppleDistributionType.Direct); + + private bool NeedsNotarization => + wizardState.Distribution == AppleDistributionType.Direct; + + private bool CanProceed => currentStep switch + { + 1 => true, // Platform always selected + 2 => true, // Distribution always selected + 3 => true, // Installer choice always made + 4 => ValidateResourcesStep(), + _ => true + }; + + private bool ValidateResourcesStep() + { + if (wizardState.SelectedBundleId == null) return false; + if (wizardState.SigningCertificate == null || !HasPrivateKey(wizardState.SigningCertificate)) return false; + if (wizardState.NeedsInstallerCert && (wizardState.InstallerCertificate == null || !HasPrivateKey(wizardState.InstallerCertificate))) return false; + if (NeedsProvisioningProfile && wizardState.ProvisioningProfile == null) return false; + return true; + } + + private IEnumerable FilteredBundleIds => bundleIds + .Where(b => string.IsNullOrEmpty(bundleIdSearch) || + b.Name.Contains(bundleIdSearch, StringComparison.OrdinalIgnoreCase) || + b.Identifier.Contains(bundleIdSearch, StringComparison.OrdinalIgnoreCase)) + .OrderBy(b => b.Name); + + private string GetStepLabel(int step) + { + if (!ShowInstallerStep && step >= 3) + step++; // Skip installer step numbering + + return step switch + { + 1 => "Platform", + 2 => "Distribution", + 3 => "Installer", + 4 => "Resources", + 5 => "Export", + _ => "" + }; + } + + protected override async Task OnParametersSetAsync() + { + if (IsVisible && bundleIds.Count == 0) + { + await LoadDataAsync(); + } + } + + private async Task LoadDataAsync() + { + if (IdentityState.SelectedIdentity == null) return; + + isLoading = true; + loadingMessage = "Loading resources..."; + + try + { + var bundleTask = AppleService.GetBundleIdsAsync(); + var certTask = AppleService.GetCertificatesAsync(); + var profileTask = AppleService.GetProfilesAsync(); + var identityTask = LocalCertService.GetSigningIdentitiesAsync(); + + await Task.WhenAll(bundleTask, certTask, profileTask, identityTask); + + bundleIds = (await bundleTask).ToList(); + certificates = (await certTask).ToList(); + profiles = (await profileTask).ToList(); + localIdentities = (await identityTask).ToList(); + + Logger.LogInformation($"Loaded {bundleIds.Count} bundle IDs, {certificates.Count} certs, {profiles.Count} profiles, {localIdentities.Count} local identities"); + } + catch (Exception ex) + { + Logger.LogError($"Failed to load data: {ex.Message}", ex); + await AlertService.ShowAlertAsync("Error", $"Failed to load data: {ex.Message}"); + } + finally + { + isLoading = false; + } + } + + private IEnumerable FilteredSigningCerts => certificates + .Where(c => IsSigningCertCompatible(c.CertificateType)) + .Where(c => c.ExpirationDate > DateTime.UtcNow) + .OrderByDescending(c => HasPrivateKey(c)) + .ThenBy(c => c.Name); + + private IEnumerable InstallerCerts => certificates + .Where(c => c.CertificateType.Contains("INSTALLER", StringComparison.OrdinalIgnoreCase)) + .Where(c => c.ExpirationDate > DateTime.UtcNow) + .OrderByDescending(c => HasPrivateKey(c)) + .ThenBy(c => c.Name); + + private IEnumerable FilteredProfiles => profiles + .Where(p => IsProfileCompatible(p.ProfileType)) + .Where(p => p.State == "ACTIVE") + .OrderBy(p => p.Name); + + private bool IsSigningCertCompatible(string certType) + { + var ct = certType.ToUpperInvariant(); + + return wizardState.Distribution switch + { + AppleDistributionType.Development => ct.Contains("DEVELOPMENT"), + AppleDistributionType.AdHoc => ct.Contains("DISTRIBUTION") && !ct.Contains("DEVELOPER_ID"), + AppleDistributionType.AppStore => ct.Contains("DISTRIBUTION") && !ct.Contains("DEVELOPER_ID"), + AppleDistributionType.Direct => ct.Contains("DEVELOPER_ID_APPLICATION"), + _ => false + }; + } + + private bool IsProfileCompatible(string profileType) + { + var pt = profileType.ToUpperInvariant(); + + // Check platform + var platformMatch = wizardState.Platform switch + { + ApplePlatformType.iOS => pt.StartsWith("IOS_"), + ApplePlatformType.MacCatalyst => pt.StartsWith("MAC_CATALYST_"), + ApplePlatformType.macOS => pt.StartsWith("MAC_APP_"), + _ => false + }; + + if (!platformMatch) return false; + + // Check distribution type + return wizardState.Distribution switch + { + AppleDistributionType.Development => pt.Contains("DEVELOPMENT"), + AppleDistributionType.AdHoc => pt.Contains("ADHOC"), + AppleDistributionType.AppStore => pt.Contains("STORE"), + AppleDistributionType.Direct => pt.Contains("DIRECT"), + _ => false + }; + } + + private bool HasPrivateKey(AppleCertificate cert) + { + // Check if any local identity matches this certificate + return localIdentities.Any(li => + (li.SerialNumber != null && li.SerialNumber.Equals(cert.SerialNumber, StringComparison.OrdinalIgnoreCase)) || + li.CommonName.Contains(cert.Name, StringComparison.OrdinalIgnoreCase) || + cert.Name.Contains(li.CommonName, StringComparison.OrdinalIgnoreCase)); + } + + private string GetSigningCertLabel() => wizardState.Distribution switch + { + AppleDistributionType.Development => "Development Certificate", + AppleDistributionType.AdHoc => "Distribution Certificate", + AppleDistributionType.AppStore => "Distribution Certificate", + AppleDistributionType.Direct => "Developer ID Application Certificate", + _ => "Signing Certificate" + }; + + private IEnumerable<(AppleDistributionType Type, string Name, string Description)> GetAvailableDistributionTypes() + { + yield return (AppleDistributionType.Development, "Development", "Build and test on registered devices"); + + if (wizardState.Platform == ApplePlatformType.iOS) + { + yield return (AppleDistributionType.AdHoc, "Ad Hoc", "Distribute to specific registered devices"); + } + + yield return (AppleDistributionType.AppStore, "App Store / TestFlight", "Submit to the App Store or TestFlight"); + + if (wizardState.Platform != ApplePlatformType.iOS) + { + yield return (AppleDistributionType.Direct, "Direct Distribution", "Distribute outside the App Store (notarized)"); + } + } + + // Event handlers + private void SelectPlatform(ApplePlatformType platform) + { + wizardState = wizardState with + { + Platform = platform, + Distribution = AppleDistributionType.Development, // Reset + SigningCertificate = null, + ProvisioningProfile = null + }; + } + + private void SelectDistribution(AppleDistributionType dist) + { + wizardState = wizardState with + { + Distribution = dist, + SigningCertificate = null, + ProvisioningProfile = null, + NeedsInstallerCert = false + }; + } + + private void SetNeedsInstaller(bool needs) + { + wizardState = wizardState with { NeedsInstallerCert = needs }; + } + + private void SelectBundleId(AppleBundleId bundle) + { + wizardState = wizardState with { SelectedBundleId = bundle }; + } + + private void SelectSigningCert(AppleCertificate cert) + { + wizardState = wizardState with { SigningCertificate = cert }; + } + + private void SelectInstallerCert(AppleCertificate cert) + { + wizardState = wizardState with { InstallerCertificate = cert }; + } + + private void OnProfileChanged(ChangeEventArgs e) + { + var id = e.Value?.ToString(); + wizardState = wizardState with + { + ProvisioningProfile = profiles.FirstOrDefault(p => p.Id == id) + }; + } + + private void UpdateNotarization(string? appleId = null, string? password = null) + { + wizardState = wizardState with + { + NotarizationAppleId = appleId ?? wizardState.NotarizationAppleId, + NotarizationPassword = password ?? wizardState.NotarizationPassword + }; + } + + private async Task NextStep() + { + if (!ShowInstallerStep && currentStep == 2) + { + currentStep = 4; // Skip installer step + } + else + { + currentStep++; + } + + if (currentStep == TotalSteps) + { + await GenerateSecrets(); + } + } + + private void PreviousStep() + { + if (!ShowInstallerStep && currentStep == 4) + { + currentStep = 2; // Skip back over installer step + } + else + { + currentStep--; + } + } + + private async Task GenerateSecrets() + { + isLoading = true; + loadingMessage = "Generating secrets..."; + exportError = ""; + exportedSecrets.Clear(); + + try + { + // TODO: Implement P12 export + // For now, show placeholder secrets + + var teamId = IdentityState.SelectedIdentity?.IssuerId ?? "TEAM_ID"; + + exportedSecrets.Add(new CISecretExport( + "APPLE_CERTIFICATE_P12", + "[Export P12 to get this value]", + $"Base64-encoded signing certificate ({wizardState.SigningCertificate?.Name})", + true + )); + + exportedSecrets.Add(new CISecretExport( + "APPLE_CERTIFICATE_PASSWORD", + "", + "Password for the P12 certificate", + true + )); + + if (wizardState.SigningCertificate != null) + { + var identity = FindMatchingIdentity(wizardState.SigningCertificate); + exportedSecrets.Add(new CISecretExport( + "APPLE_CODESIGN_IDENTITY", + identity?.Identity ?? wizardState.SigningCertificate.Name, + "Code signing identity name", + false + )); + } + + if (wizardState.NeedsInstallerCert && wizardState.InstallerCertificate != null) + { + exportedSecrets.Add(new CISecretExport( + "APPLE_INSTALLER_CERTIFICATE_P12", + "[Export P12 to get this value]", + $"Base64-encoded installer certificate ({wizardState.InstallerCertificate.Name})", + true + )); + + exportedSecrets.Add(new CISecretExport( + "APPLE_INSTALLER_CERTIFICATE_PASSWORD", + "", + "Password for the installer P12 certificate", + true + )); + } + + if (NeedsProvisioningProfile && wizardState.ProvisioningProfile != null) + { + // Download and base64 encode the profile + var profileData = await AppleService.DownloadProfileAsync(wizardState.ProvisioningProfile.Id); + var profileBase64 = Convert.ToBase64String(profileData); + + exportedSecrets.Add(new CISecretExport( + "APPLE_PROVISIONING_PROFILE", + profileBase64, + $"Base64-encoded provisioning profile ({wizardState.ProvisioningProfile.Name})", + true + )); + + exportedSecrets.Add(new CISecretExport( + "APPLE_PROVISIONING_PROFILE_NAME", + wizardState.ProvisioningProfile.Name, + "Provisioning profile name", + false + )); + } + + if (NeedsNotarization) + { + exportedSecrets.Add(new CISecretExport( + "APPLE_NOTARIZATION_APPLE_ID", + wizardState.NotarizationAppleId ?? "", + "Apple ID for notarization", + false + )); + + exportedSecrets.Add(new CISecretExport( + "APPLE_NOTARIZATION_PASSWORD", + wizardState.NotarizationPassword ?? "", + "App-specific password for notarization", + true + )); + + exportedSecrets.Add(new CISecretExport( + "APPLE_NOTARIZATION_TEAM_ID", + teamId, + "Team ID for notarization", + false + )); + } + + // API Key (optional but useful) + if (IdentityState.SelectedIdentity != null) + { + exportedSecrets.Add(new CISecretExport( + "APPLE_API_KEY_ID", + IdentityState.SelectedIdentity.KeyId, + "App Store Connect API Key ID", + false + )); + + exportedSecrets.Add(new CISecretExport( + "APPLE_API_ISSUER_ID", + IdentityState.SelectedIdentity.IssuerId, + "App Store Connect API Issuer ID", + false + )); + } + } + catch (Exception ex) + { + exportError = $"Failed to generate secrets: {ex.Message}"; + Logger.LogError(exportError, ex); + } + finally + { + isLoading = false; + } + } + + private LocalSigningIdentity? FindMatchingIdentity(AppleCertificate cert) + { + return localIdentities.FirstOrDefault(li => + (li.SerialNumber != null && li.SerialNumber.Equals(cert.SerialNumber, StringComparison.OrdinalIgnoreCase)) || + li.CommonName.Contains(cert.Name, StringComparison.OrdinalIgnoreCase) || + cert.Name.Contains(li.CommonName, StringComparison.OrdinalIgnoreCase)); + } + + private async Task CopySecret(CISecretExport secret) + { + await DialogService.CopyToClipboardAsync(secret.Value); + await AlertService.ShowToastAsync($"Copied {secret.Name} to clipboard"); + } + + private async Task CopyAllSecrets() + { + var allSecrets = string.Join("\n", exportedSecrets.Select(s => $"{s.Name}={s.Value}")); + await DialogService.CopyToClipboardAsync(allSecrets); + await AlertService.ShowToastAsync("Copied all secrets to clipboard"); + } + + private async Task DownloadEnvFile() + { + try + { + var content = string.Join("\n", exportedSecrets.Select(s => $"{s.Name}=\"{s.Value}\"")); + var downloadsFolder = Path.Combine(Environment.GetFolderPath(Environment.SpecialFolder.UserProfile), "Downloads"); + var filePath = Path.Combine(downloadsFolder, "apple-ci-secrets.env"); + + await File.WriteAllTextAsync(filePath, content); + await AlertService.ShowToastAsync($"Saved to {Path.GetFileName(filePath)}"); + } + catch (Exception ex) + { + await AlertService.ShowAlertAsync("Error", $"Failed to save file: {ex.Message}"); + } + } + + private string TruncateValue(string value) + { + if (value.Length <= 60) return value; + return value.Substring(0, 57) + "..."; + } + + private void NavigateTo(string path) + { + Navigation.NavigateTo(path); + _ = Close(); + } + + private void HandleOverlayClick() + { + // Don't close on overlay click during loading + if (!isLoading) + { + _ = Close(); + } + } + + private async Task Close() + { + IsVisible = false; + await IsVisibleChanged.InvokeAsync(false); + + // Reset state + currentStep = 1; + exportedSecrets.Clear(); + exportError = ""; + } +} diff --git a/src/MauiSherpa/MauiProgram.cs b/src/MauiSherpa/MauiProgram.cs index d3d94090..13a37858 100644 --- a/src/MauiSherpa/MauiProgram.cs +++ b/src/MauiSherpa/MauiProgram.cs @@ -65,6 +65,7 @@ public static MauiApp CreateMauiApp() builder.Services.AddSingleton(); builder.Services.AddSingleton(); builder.Services.AddSingleton(); + builder.Services.AddSingleton(); // ViewModels builder.Services.AddSingleton(); diff --git a/src/MauiSherpa/Pages/ProvisioningProfiles.razor b/src/MauiSherpa/Pages/ProvisioningProfiles.razor index 19c4b1bc..186f8a02 100644 --- a/src/MauiSherpa/Pages/ProvisioningProfiles.razor +++ b/src/MauiSherpa/Pages/ProvisioningProfiles.razor @@ -31,6 +31,10 @@ Install All (@GetInstallableProfiles().Count()) } +
+ @@ -187,11 +191,13 @@ + @code { @@ -469,6 +812,29 @@ private string newCertType = "IOS_DEVELOPMENT"; private string newCertCommonName = ""; private string newCertPassphrase = ""; + + // Export dialog state + private bool showExportDialog = false; + private bool isExporting = false; + private AppleCertificate? exportCertificate = null; + private string exportType = "p12"; + private bool exportWithPassword = false; + private string exportPassword = ""; + private string exportPasswordConfirm = ""; + private IReadOnlyList? localIdentities = null; + + // Sync state + private Dictionary syncStatuses = new(); + private string? openSyncMenuCertId = null; + private bool syncHintDismissed = false; + private const string SyncHintDismissedKey = "CertificatesSyncHintDismissed"; + + private bool CanExport => exportCertificate != null && + !isExporting && + (exportType == "cer" || + (exportType == "p12" && + localIdentities?.Any(i => i.SerialNumber?.Equals(exportCertificate.SerialNumber, StringComparison.OrdinalIgnoreCase) == true) == true && + (!exportWithPassword || (exportPassword == exportPasswordConfirm && !string.IsNullOrEmpty(exportPassword))))); private bool HasActiveFilters => !string.IsNullOrEmpty(searchQuery) || !string.IsNullOrEmpty(filterType) || @@ -485,6 +851,10 @@ .OrderByDescending(c => c.ExpirationDate > DateTime.UtcNow) // Valid first .ThenBy(c => c.ExpirationDate); + // Computed properties for bulk sync buttons + private bool HasCloudOnlyCerts => syncStatuses.Values.Any(s => s.Location == SecretLocation.CloudOnly); + private bool HasLocalOnlyCerts => syncStatuses.Values.Any(s => s.Location == SecretLocation.LocalOnly); + private string GetCertStatus(AppleCertificate cert) { if (cert.ExpirationDate < DateTime.UtcNow) return "expired"; @@ -503,10 +873,27 @@ protected override async Task OnInitializedAsync() { IdentityState.OnSelectionChanged += OnIdentityChanged; + + // Load sync hint dismissed preference + syncHintDismissed = Preferences.Default.Get(SyncHintDismissedKey, false); + + // Initialize cloud secrets service to load active provider + if (CloudSecretsService is MauiSherpa.Core.Services.CloudSecretsService svc) + { + await svc.InitializeAsync(); + } + if (IdentityState.SelectedIdentity != null) await RefreshData(); } + private void DismissSyncHint() + { + syncHintDismissed = true; + Preferences.Default.Set(SyncHintDismissedKey, true); + StateHasChanged(); + } + private void OnIdentityChanged() { InvokeAsync(async () => @@ -529,11 +916,20 @@ errorMessage = ""; try { + // Invalidate local certificate cache on force refresh + if (forceRefresh) + { + LocalCertificateService.InvalidateCache(); + } + var request = new GetCertificatesRequest(IdentityState.SelectedIdentity.Id); var (_, result) = forceRefresh ? await Mediator.Request(request, CancellationToken.None, ctx => ctx.ClearCache()) : await Mediator.Request(request); certificates = result.ToList(); + + // Load sync statuses if cloud provider is configured + await LoadSyncStatuses(); } catch (Exception ex) { @@ -545,6 +941,26 @@ await InvokeAsync(StateHasChanged); } } + + private async Task LoadSyncStatuses() + { + if (CloudSecretsService.ActiveProvider == null || certificates.Count == 0) + { + syncStatuses.Clear(); + return; + } + + try + { + var statuses = await CertificateSyncService.GetCertificateStatusesAsync(certificates); + syncStatuses = statuses.ToDictionary(s => s.CertificateId, s => s); + } + catch (Exception ex) + { + Logger.LogError($"Failed to load sync statuses: {ex.Message}", ex); + syncStatuses.Clear(); + } + } private void ShowCreateDialog() { @@ -558,6 +974,91 @@ { showCreateDialog = false; } + + private async Task ShowExportDialog(AppleCertificate cert) + { + exportCertificate = cert; + exportType = "p12"; + exportWithPassword = false; + exportPassword = ""; + exportPasswordConfirm = ""; + + // Load local identities to check if we have the private key + if (LocalCertificateService.IsSupported) + { + localIdentities = await LocalCertificateService.GetSigningIdentitiesAsync(); + } + + showExportDialog = true; + } + + private void CloseExportDialog() + { + showExportDialog = false; + exportCertificate = null; + } + + private async Task ExportCertificate() + { + if (exportCertificate == null) return; + + isExporting = true; + StateHasChanged(); + + try + { + var cert = exportCertificate; + var fileName = $"{cert.Name?.Replace(" ", "_") ?? "certificate"}_{DateTime.Now:yyyyMMdd}"; + byte[] data; + string extension; + + if (exportType == "p12") + { + extension = ".p12"; + var matchingIdentity = localIdentities?.FirstOrDefault(i => + i.SerialNumber?.Equals(cert.SerialNumber, StringComparison.OrdinalIgnoreCase) == true); + + if (matchingIdentity == null) + { + await AlertService.ShowAlertAsync("Error", "Private key not found in local keychain."); + return; + } + + var password = exportWithPassword ? exportPassword : ""; + data = await LocalCertificateService.ExportP12Async(matchingIdentity.Identity, password); + } + else + { + extension = ".cer"; + data = await LocalCertificateService.ExportCertificateAsync(cert.SerialNumber ?? ""); + } + + // Get save location + var savePath = await DialogService.ShowFileDialogAsync( + "Save Certificate", + isSave: true, + defaultFileName: fileName + extension); + + if (string.IsNullOrEmpty(savePath)) + { + return; // User cancelled + } + + await File.WriteAllBytesAsync(savePath, data); + await AlertService.ShowToastAsync($"Certificate exported to {Path.GetFileName(savePath)}"); + CloseExportDialog(); + } + catch (Exception ex) + { + Logger.LogError($"Export failed: {ex}"); + await AlertService.ShowAlertAsync("Export Failed", ex.Message); + } + finally + { + isExporting = false; + StateHasChanged(); + } + } private async Task CreateCertificate() { @@ -623,21 +1124,490 @@ await RefreshData(forceRefresh: true); } } + + private async Task DeleteLocalCertificate(AppleCertificate cert) + { + openSyncMenuCertId = null; + + if (!LocalCertificateService.IsSupported) + { + await AlertService.ShowAlertAsync("Not Supported", "Certificate deletion is only supported on macOS."); + return; + } + + // Confirm deletion + var confirmed = await AlertService.ShowConfirmAsync( + "Delete from Keychain", + $"Are you sure you want to delete '{cert.Name}' from your local keychain?\n\nThis will remove both the certificate and its private key. This action cannot be undone."); + + if (!confirmed) + return; + + try + { + // Find the matching local identity + var identities = await LocalCertificateService.GetSigningIdentitiesAsync(); + var matchingIdentity = identities.FirstOrDefault(i => + i.SerialNumber?.Equals(cert.SerialNumber, StringComparison.OrdinalIgnoreCase) == true); + + if (matchingIdentity == null) + { + await AlertService.ShowAlertAsync("Not Found", "Certificate not found in local keychain."); + return; + } + + // Extract the common name from the identity for deletion + // Identity looks like: "Apple Development: John Doe (TEAMID)" + var commonName = matchingIdentity.Identity; + + await LocalCertificateService.DeleteCertificateAsync(commonName); + await AlertService.ShowToastAsync("Certificate deleted from keychain"); + + // Invalidate cache and refresh sync statuses + LocalCertificateService.InvalidateCache(); + await LoadSyncStatuses(); + StateHasChanged(); + } + catch (Exception ex) + { + Logger.LogError($"Failed to delete certificate: {ex}"); + await AlertService.ShowAlertAsync("Delete Failed", ex.Message); + } + } - private string FormatCertType(string certType) + private async Task DeleteFromCloud(AppleCertificate cert) { + openSyncMenuCertId = null; + + if (string.IsNullOrEmpty(cert.SerialNumber)) + { + await AlertService.ShowAlertAsync("Error", "Certificate serial number is not available."); + return; + } + + // Confirm deletion + var confirmed = await AlertService.ShowConfirmAsync( + "Delete from Cloud", + $"Are you sure you want to delete '{cert.Name}' from cloud storage?\n\nThis will remove the private key from your cloud secrets provider. This action cannot be undone."); + + if (!confirmed) + return; + + try + { + var success = await CertificateSyncService.DeleteFromCloudAsync(cert.SerialNumber); + + if (success) + { + await AlertService.ShowToastAsync("Certificate deleted from cloud"); + + // Refresh sync statuses + await LoadSyncStatuses(); + StateHasChanged(); + } + else + { + await AlertService.ShowAlertAsync("Delete Failed", "Failed to delete certificate from cloud storage."); + } + } + catch (Exception ex) + { + Logger.LogError($"Failed to delete certificate from cloud: {ex}"); + await AlertService.ShowAlertAsync("Delete Failed", ex.Message); + } + } + + private async Task SyncAllFromCloud() + { + // Get all cloud-only certificates + var cloudOnlyCerts = certificates + .Where(c => syncStatuses.TryGetValue(c.Id, out var status) && status.Location == SecretLocation.CloudOnly) + .ToList(); + + if (cloudOnlyCerts.Count == 0) + { + await AlertService.ShowToastAsync("No certificates to install from cloud"); + return; + } + + var operations = cloudOnlyCerts.Select(cert => new OperationItem( + Id: cert.Id, + Name: cert.Name, + Description: $"{FormatCertType(cert.CertificateType ?? "Unknown")} - {cert.SerialNumber}", + Execute: async ctx => + { + ctx.LogInfo($"Installing {cert.Name} from cloud..."); + + try + { + var success = await CertificateSyncService.DownloadAndInstallAsync(cert.Id); + + if (success) + { + ctx.LogSuccess($"Installed {cert.Name} to local keychain"); + return true; + } + else + { + ctx.LogError($"Failed to install {cert.Name}"); + return false; + } + } + catch (Exception ex) + { + ctx.LogError($"Error installing {cert.Name}: {ex.Message}"); + return false; + } + }, + IsEnabled: true, + CanDisable: true + )).ToList(); + + var result = await MultiOpModal.RunAsync( + "Install Certificates from Cloud", + $"The following {cloudOnlyCerts.Count} certificate(s) will be installed to your local keychain. Uncheck any you want to skip.", + operations); + + if (result.Completed > 0) + { + // Refresh local identities cache and sync statuses + LocalCertificateService.InvalidateCache(); + await LoadSyncStatuses(); + StateHasChanged(); + } + } + + private async Task SyncAllToCloud() + { + // Get all local-only certificates + var localOnlyCerts = certificates + .Where(c => syncStatuses.TryGetValue(c.Id, out var status) && status.Location == SecretLocation.LocalOnly) + .ToList(); + + if (localOnlyCerts.Count == 0) + { + await AlertService.ShowToastAsync("No certificates to upload to cloud"); + return; + } + + // Get local identities for P12 export + var identities = await LocalCertificateService.GetSigningIdentitiesAsync(); + + var operations = localOnlyCerts.Select(cert => new OperationItem( + Id: cert.Id, + Name: cert.Name, + Description: $"{FormatCertType(cert.CertificateType ?? "Unknown")} - {cert.SerialNumber}", + Execute: async ctx => + { + ctx.LogInfo($"Uploading {cert.Name} to cloud..."); + + try + { + // Find matching local identity + var identity = identities.FirstOrDefault(i => + i.SerialNumber?.Equals(cert.SerialNumber, StringComparison.OrdinalIgnoreCase) == true); + + if (identity == null) + { + ctx.LogError($"Could not find local identity for {cert.Name}"); + return false; + } + + // Generate a random password for the P12 + var password = GenerateRandomPassword(); + + // Export P12 from keychain + ctx.LogInfo("Exporting certificate from keychain..."); + var p12Data = await LocalCertificateService.ExportP12Async(identity.Identity, password); + + // Upload to cloud + ctx.LogInfo("Uploading to cloud storage..."); + var metadata = new CertificateSecretMetadata( + CertificateId: cert.Id, + SerialNumber: cert.SerialNumber ?? "", + CommonName: cert.Name, + CertificateType: cert.CertificateType ?? "Unknown", + ExpirationDate: cert.ExpirationDate, + CreatedByMachine: Environment.MachineName, + CreatedAt: DateTime.UtcNow + ); + + var success = await CertificateSyncService.UploadToCloudAsync(cert, p12Data, password, metadata); + + if (success) + { + ctx.LogSuccess($"Uploaded {cert.Name} to cloud"); + return true; + } + else + { + ctx.LogError($"Failed to upload {cert.Name}"); + return false; + } + } + catch (Exception ex) + { + ctx.LogError($"Error uploading {cert.Name}: {ex.Message}"); + return false; + } + }, + IsEnabled: true, + CanDisable: true + )).ToList(); + + var result = await MultiOpModal.RunAsync( + "Upload Certificates to Cloud", + $"The following {localOnlyCerts.Count} certificate(s) will be uploaded to cloud storage. Uncheck any you want to skip.", + operations); + + if (result.Completed > 0) + { + await LoadSyncStatuses(); + StateHasChanged(); + } + } + + private string FormatCertType(string? certType) + { + if (string.IsNullOrEmpty(certType)) + return "Unknown"; + return certType.Replace("_", " ").ToLowerInvariant() switch { var t when t.Contains("development") => "Development", var t when t.Contains("distribution") => "Distribution", var t when t.Contains("push") => "Push Notification", + var t when t.Contains("developer id application") => "Developer ID App", + var t when t.Contains("developer id installer") => "Developer ID Installer", + var t when t.Contains("developer id") => "Developer ID", _ => certType.Replace("_", " ") }; } + private static string FormatPlatform(string? platform) + { + if (string.IsNullOrEmpty(platform)) + return "Unknown"; + + return platform.ToUpperInvariant() switch + { + "IOS" => "iOS", + "MAC_OS" => "macOS", + "MACOS" => "macOS", + "UNIVERSAL" => "Universal", + _ => platform.Replace("_", " ") + }; + } + + private static string GenerateRandomPassword() + { + return Convert.ToBase64String(Guid.NewGuid().ToByteArray())[..16]; + } + private async Task CopyToClipboard(string text) { await DialogService.CopyToClipboardAsync(text); await AlertService.ShowToastAsync("Copied to clipboard"); } + + // Sync helper methods + + private SecretLocation GetSyncStatus(AppleCertificate cert) + { + if (syncStatuses.TryGetValue(cert.Id ?? "", out var status)) + return status.Location; + return SecretLocation.None; + } + + private string GetSyncBadgeClass(SecretLocation location) => location switch + { + SecretLocation.Both => "badge-sync-both", + SecretLocation.LocalOnly => "badge-sync-local", + SecretLocation.CloudOnly => "badge-sync-cloud", + _ => "badge-sync-none" + }; + + private string GetSyncStatusIcon(SecretLocation location) => location switch + { + SecretLocation.Both => "🟢", + SecretLocation.LocalOnly => "🔵", + SecretLocation.CloudOnly => "🟡", + _ => "⚫" + }; + + private string GetSyncStatusText(SecretLocation location) => location switch + { + SecretLocation.Both => "Synced", + SecretLocation.LocalOnly => "Local", + SecretLocation.CloudOnly => "Cloud", + _ => "No Key" + }; + + private string GetSyncStatusTooltip(SecretLocation location) => location switch + { + SecretLocation.Both => "Private key exists locally and in cloud storage", + SecretLocation.LocalOnly => "Private key exists locally only - can upload to cloud", + SecretLocation.CloudOnly => "Private key in cloud only - can install locally", + _ => "No private key available - cannot sign with this certificate" + }; + + private void ToggleSyncMenu(string? certId) + { + openSyncMenuCertId = openSyncMenuCertId == certId ? null : certId; + } + + private async void HandleUploadClick(string certId) + { + try + { + Logger.LogInformation($"HandleUploadClick called for cert ID: {certId}"); + var cert = certificates?.FirstOrDefault(c => c.Id == certId); + if (cert != null) + { + await UploadToCloud(cert); + } + else + { + await AlertService.ShowAlertAsync("Error", $"Could not find cert with ID: {certId}"); + } + } + catch (Exception ex) + { + Logger.LogError($"HandleUploadClick error: {ex}"); + await AlertService.ShowAlertAsync("Error", $"Upload failed: {ex.Message}"); + } + } + + private void HandleInstallClick(string certId) + { + Logger.LogInformation($"HandleInstallClick called for cert ID: {certId}"); + var cert = certificates?.FirstOrDefault(c => c.Id == certId); + if (cert != null) + { + _ = InstallLocally(cert); + } + } + + private async Task UploadToCloud(AppleCertificate cert) + { + try + { + Logger.LogInformation($"UploadToCloud called for cert: {cert.Id} ({cert.Name})"); + openSyncMenuCertId = null; + StateHasChanged(); + + // We need to get the P12 from the local keychain + if (!LocalCertificateService.IsSupported) + { + await AlertService.ShowAlertAsync("Not Supported", "Certificate export is only supported on macOS."); + return; + } + + // Get the local signing identities to find the matching one + Logger.LogInformation($"Looking for identity with serial: {cert.SerialNumber}"); + var identities = await LocalCertificateService.GetSigningIdentitiesAsync(); + var matchingIdentity = identities.FirstOrDefault(i => + i.SerialNumber?.Equals(cert.SerialNumber, StringComparison.OrdinalIgnoreCase) == true); + + if (matchingIdentity == null) + { + Logger.LogWarning($"No matching identity found for serial {cert.SerialNumber}"); + await AlertService.ShowAlertAsync("Not Found", "Could not find the private key for this certificate in your local keychain."); + return; + } + + Logger.LogInformation($"Found matching identity: {matchingIdentity.Identity}"); + + // Generate a random password for the P12 - user doesn't need to know it since we store it in cloud + var password = Convert.ToBase64String(Guid.NewGuid().ToByteArray())[..16]; + + var result = await OperationModal.RunAsync( + "Upload to Cloud", + $"Exporting and uploading certificate...", + async ctx => + { + ctx.LogInfo("Exporting certificate from keychain..."); + var p12Data = await LocalCertificateService.ExportP12Async(matchingIdentity.Identity, password); + + ctx.LogInfo("Uploading to cloud storage..."); + var metadata = new CertificateSecretMetadata( + cert.Id ?? "", + cert.SerialNumber ?? "", + cert.Name ?? "", + cert.CertificateType ?? "", + cert.ExpirationDate, + Environment.MachineName, + DateTime.UtcNow + ); + + var success = await CertificateSyncService.UploadToCloudAsync(cert, p12Data, password, metadata); + if (!success) + throw new Exception("Failed to upload certificate to cloud storage"); + + return true; + }, + canCancel: false); + + if (result.Success) + { + await AlertService.ShowToastAsync("Certificate uploaded to cloud"); + await LoadSyncStatuses(); + StateHasChanged(); + } + } + catch (Exception ex) + { + Logger.LogError($"Error in UploadToCloud: {ex}"); + await AlertService.ShowAlertAsync("Error", $"Upload failed: {ex.Message}"); + } + } + + private async Task InstallLocally(AppleCertificate cert) + { + openSyncMenuCertId = null; + + if (!LocalCertificateService.IsSupported) + { + await AlertService.ShowAlertAsync("Not Supported", "Certificate installation is only supported on macOS."); + return; + } + + Logger.LogInformation($"InstallLocally starting for cert: {cert.Id}, serial: {cert.SerialNumber}"); + + var result = await OperationModal.RunAsync( + "Install Certificate", + $"Downloading and installing certificate...", + async ctx => + { + ctx.LogInfo($"Certificate: {cert.Name}"); + ctx.LogInfo($"Serial: {cert.SerialNumber}"); + ctx.LogInfo("Downloading from cloud storage..."); + + // Use the CertificateSyncService to download and install + if (CertificateSyncService is MauiSherpa.Core.Services.CertificateSyncService syncService) + { + var success = await syncService.DownloadAndInstallBySerialAsync(cert.SerialNumber ?? ""); + if (!success) + { + ctx.LogError("Failed to download and install certificate"); + throw new Exception("Failed to download and install certificate - check logs for details"); + } + ctx.LogSuccess("Certificate installed successfully"); + return true; + } + else + { + throw new Exception("Sync service not available"); + } + }, + canCancel: false); + + if (result.Success) + { + await AlertService.ShowToastAsync("Certificate installed to local keychain"); + LocalCertificateService.InvalidateCache(); + await LoadSyncStatuses(); + StateHasChanged(); + } + } } diff --git a/src/MauiSherpa/Pages/Settings.razor b/src/MauiSherpa/Pages/Settings.razor index 831ba8f3..24cf775b 100644 --- a/src/MauiSherpa/Pages/Settings.razor +++ b/src/MauiSherpa/Pages/Settings.razor @@ -13,6 +13,8 @@ @inject IDialogService DialogService @inject IFileSystemService FileSystem @inject IThemeService ThemeService +@inject ICloudSecretsService CloudSecretsService +@inject ICloudSecretsProviderFactory CloudSecretsProviderFactory @implements IDisposable

@ViewModel.Title

@@ -43,7 +45,7 @@
-

Android SDK

+

Android SDK

Configure the Android SDK location used for managing packages, emulators, and devices.

@@ -97,7 +99,7 @@
-

Apple Identities

+

Apple Identities

Configure App Store Connect API credentials for managing Apple Developer resources. Get API keys @@ -117,7 +119,10 @@ {

-
@identity.Name
+
+ + @identity.Name +
+
+

Cloud Secrets Storage

+

+ Store certificate private keys securely in the cloud to sync across machines. + This enables sharing signing certificates between development machines and CI/CD pipelines. +

+ +
+ @if (cloudProviders.Count == 0) + { +
+

No cloud providers configured

+
+ } + else + { +
+ @foreach (var provider in cloudProviders) + { + var isActive = CloudSecretsService.ActiveProvider?.Id == provider.Id; + var providerUrl = GetProviderUrl(provider); +
+
+
+ + @provider.Name + @if (!string.IsNullOrEmpty(providerUrl)) + { + + + + } + @if (isActive) + { + Active + } +
+
+ @if (!isActive) + { + + } + + + +
+
+
+
+ TYPE + @CloudSecretsProviderFactory.GetProviderDisplayName(provider.ProviderType) +
+ @if (provider.ProviderType == CloudSecretsProviderType.Infisical) + { +
+ SITE + @provider.Settings.GetValueOrDefault("SiteUrl", "https://app.infisical.com") +
+
+ PROJECT + @provider.Settings.GetValueOrDefault("ProjectId", "") +
+ } + @if (provider.ProviderType == CloudSecretsProviderType.AzureKeyVault) + { +
+ VAULT + @provider.Settings.GetValueOrDefault("VaultUrl", "") +
+ } + @if (provider.ProviderType == CloudSecretsProviderType.AwsSecretsManager) + { +
+ REGION + @provider.Settings.GetValueOrDefault("Region", "us-east-1") +
+ } + @if (provider.ProviderType == CloudSecretsProviderType.GoogleSecretManager) + { +
+ PROJECT + @provider.Settings.GetValueOrDefault("ProjectId", "") +
+ } +
+
+ } +
+ } + +
+ +
+
+
+ @if (showIdentityDialog) { } +@if (showProviderDialog) +{ + +} + @code { @@ -501,12 +849,30 @@ private string p8InputMode = "file"; // "file" or "text" private bool isLoadingSdk = true; + + // Cloud provider state + private List cloudProviders = new(); + private bool showProviderDialog = false; + private CloudSecretsProviderConfig? editingProvider = null; + private string providerName = ""; + private CloudSecretsProviderType providerType = CloudSecretsProviderType.Infisical; + private Dictionary providerSettings = new(); private bool CanSaveIdentity => !string.IsNullOrWhiteSpace(identityName) && !string.IsNullOrWhiteSpace(identityKeyId) && !string.IsNullOrWhiteSpace(identityIssuerId) && !string.IsNullOrWhiteSpace(identityP8Content); + + private bool CanSaveProvider + { + get + { + if (string.IsNullOrWhiteSpace(providerName)) return false; + var requiredSettings = GetCurrentProviderSettings().Where(s => s.IsRequired); + return requiredSettings.All(s => !string.IsNullOrWhiteSpace(providerSettings.GetValueOrDefault(s.Key))); + } + } protected override async Task OnInitializedAsync() { @@ -516,7 +882,11 @@ // Subscribe to SDK path changes SdkSettings.SdkPathChanged += OnSdkPathChanged; + // Subscribe to cloud provider changes + CloudSecretsService.OnActiveProviderChanged += OnCloudProviderChanged; + await LoadAppleIdentities(); + await LoadCloudProviders(); await InitializeSdk(); } @@ -543,16 +913,33 @@ { InvokeAsync(StateHasChanged); } + + private void OnCloudProviderChanged() + { + InvokeAsync(StateHasChanged); + } public void Dispose() { SdkSettings.SdkPathChanged -= OnSdkPathChanged; + CloudSecretsService.OnActiveProviderChanged -= OnCloudProviderChanged; } private async Task LoadAppleIdentities() { appleIdentities = (await AppleIdentityService.GetIdentitiesAsync()).ToList(); } + + private async Task LoadCloudProviders() + { + cloudProviders = (await CloudSecretsService.GetProvidersAsync()).ToList(); + + // Initialize service if needed + if (CloudSecretsService is CloudSecretsService svc) + { + await svc.InitializeAsync(); + } + } private void OnThemeChanged(ChangeEventArgs e) { @@ -718,4 +1105,159 @@ await DialogService.CopyToClipboardAsync(text); await AlertService.ShowToastAsync("Copied to clipboard"); } + + // Cloud Provider Methods + + private string GetProviderIcon(CloudSecretsProviderType type) => type switch + { + CloudSecretsProviderType.Infisical => "fas fa-shield-alt", + CloudSecretsProviderType.AzureKeyVault => "fas fa-key", + CloudSecretsProviderType.AwsSecretsManager => "fab fa-aws", + CloudSecretsProviderType.GoogleSecretManager => "fab fa-google", + _ => "fas fa-lock" + }; + + private string? GetProviderUrl(CloudSecretsProviderConfig provider) + { + return provider.ProviderType switch + { + CloudSecretsProviderType.Infisical => GetInfisicalUrl(provider), + CloudSecretsProviderType.AzureKeyVault => GetAzureKeyVaultUrl(provider), + CloudSecretsProviderType.AwsSecretsManager => GetAwsSecretsManagerUrl(provider), + CloudSecretsProviderType.GoogleSecretManager => GetGoogleSecretManagerUrl(provider), + _ => null + }; + } + + private string? GetInfisicalUrl(CloudSecretsProviderConfig provider) + { + var siteUrl = provider.Settings.GetValueOrDefault("SiteUrl", "https://app.infisical.com").TrimEnd('/'); + var projectId = provider.Settings.GetValueOrDefault("ProjectId", ""); + var environment = provider.Settings.GetValueOrDefault("Environment", "prod"); + + if (string.IsNullOrEmpty(projectId)) + return siteUrl; + + return $"{siteUrl}/projects/secret-management/{projectId}/overview"; + } + + private string? GetAzureKeyVaultUrl(CloudSecretsProviderConfig provider) + { + var vaultUrl = provider.Settings.GetValueOrDefault("VaultUrl", "").TrimEnd('/'); + + if (string.IsNullOrEmpty(vaultUrl)) + return null; + + // Extract vault name from URL like https://my-vault.vault.azure.net + try + { + var uri = new Uri(vaultUrl); + var vaultName = uri.Host.Split('.')[0]; + // Link to Azure Portal for the Key Vault + return $"https://portal.azure.com/#view/Microsoft_Azure_KeyVault/VaultBlade/vaultName/{vaultName}"; + } + catch + { + return vaultUrl; + } + } + + private string? GetAwsSecretsManagerUrl(CloudSecretsProviderConfig provider) + { + var region = provider.Settings.GetValueOrDefault("Region", "us-east-1"); + // Link to AWS Secrets Manager console for the region + return $"https://{region}.console.aws.amazon.com/secretsmanager/listsecrets?region={region}"; + } + + private string? GetGoogleSecretManagerUrl(CloudSecretsProviderConfig provider) + { + var projectId = provider.Settings.GetValueOrDefault("ProjectId", ""); + if (string.IsNullOrEmpty(projectId)) + return null; + // Link to Google Cloud Console Secret Manager for the project + return $"https://console.cloud.google.com/security/secret-manager?project={projectId}"; + } + + private IReadOnlyList GetCurrentProviderSettings() + { + return CloudSecretsProviderFactory.GetProviderSettings(providerType); + } + + private void ShowAddProviderDialog() + { + editingProvider = null; + providerName = ""; + providerType = CloudSecretsProviderFactory.SupportedProviders.FirstOrDefault(); + providerSettings = new Dictionary(); + + // Initialize with default values + foreach (var setting in GetCurrentProviderSettings()) + { + if (!string.IsNullOrEmpty(setting.DefaultValue)) + { + providerSettings[setting.Key] = setting.DefaultValue; + } + } + + showProviderDialog = true; + } + + private void EditProvider(CloudSecretsProviderConfig provider) + { + editingProvider = provider; + providerName = provider.Name; + providerType = provider.ProviderType; + providerSettings = new Dictionary(provider.Settings); + showProviderDialog = true; + } + + private void CloseProviderDialog() + { + showProviderDialog = false; + editingProvider = null; + } + + private async Task SaveProvider() + { + var config = new CloudSecretsProviderConfig( + editingProvider?.Id ?? Guid.NewGuid().ToString("N"), + providerName, + providerType, + new Dictionary(providerSettings) + ); + + await CloudSecretsService.SaveProviderAsync(config); + await AlertService.ShowToastAsync($"Provider '{providerName}' saved"); + CloseProviderDialog(); + await LoadCloudProviders(); + } + + private async Task DeleteProvider(CloudSecretsProviderConfig provider) + { + var confirm = await AlertService.ShowConfirmAsync( + "Delete Provider", + $"Are you sure you want to delete '{provider.Name}'?"); + + if (!confirm) return; + + await CloudSecretsService.DeleteProviderAsync(provider.Id); + await AlertService.ShowToastAsync("Provider deleted"); + await LoadCloudProviders(); + } + + private async Task TestProvider(CloudSecretsProviderConfig provider) + { + var success = await CloudSecretsService.TestProviderConnectionAsync(provider.Id); + if (success) + await AlertService.ShowToastAsync("Connection successful!"); + else + await AlertService.ShowAlertAsync("Connection Failed", "Could not connect to the cloud provider. Check your credentials and settings."); + } + + private async Task SetActiveProvider(CloudSecretsProviderConfig provider) + { + await CloudSecretsService.SetActiveProviderAsync(provider.Id); + await AlertService.ShowToastAsync($"'{provider.Name}' is now the active provider"); + StateHasChanged(); + } } diff --git a/src/MauiSherpa/Services/DialogService.cs b/src/MauiSherpa/Services/DialogService.cs index 8af5336f..6a1ab709 100644 --- a/src/MauiSherpa/Services/DialogService.cs +++ b/src/MauiSherpa/Services/DialogService.cs @@ -19,14 +19,64 @@ public Task HideLoadingAsync() return Task.CompletedTask; } - public Task ShowInputDialogAsync(string title, string message, string placeholder = "") + public async Task ShowInputDialogAsync(string title, string message, string placeholder = "") { - return Task.FromResult(null); +#if MACCATALYST + var tcs = new TaskCompletionSource(); + + await MainThread.InvokeOnMainThreadAsync(() => + { + var alertController = UIAlertController.Create(title, message, UIAlertControllerStyle.Alert); + + alertController.AddTextField(textField => + { + textField.Placeholder = placeholder; + textField.SecureTextEntry = title.Contains("Password", StringComparison.OrdinalIgnoreCase); + }); + + alertController.AddAction(UIAlertAction.Create("Cancel", UIAlertActionStyle.Cancel, _ => + { + tcs.TrySetResult(null); + })); + + alertController.AddAction(UIAlertAction.Create("OK", UIAlertActionStyle.Default, _ => + { + var text = alertController.TextFields?.FirstOrDefault()?.Text; + tcs.TrySetResult(text); + })); + + var viewController = Microsoft.Maui.ApplicationModel.Platform.GetCurrentUIViewController(); + viewController?.PresentViewController(alertController, true, null); + }); + + return await tcs.Task; +#else + // Windows implementation using ContentDialog would go here + return await Task.FromResult(null); +#endif } - public Task ShowFileDialogAsync(string title, bool isSave = false, string[]? filters = null) + public async Task ShowFileDialogAsync(string title, bool isSave = false, string[]? filters = null, string? defaultFileName = null) { - return Task.FromResult(null); +#if MACCATALYST + if (isSave) + { + // For save, we use a folder picker and then append the filename + var folder = await PickFolderAsync(title); + if (folder != null && !string.IsNullOrEmpty(defaultFileName)) + { + return Path.Combine(folder, defaultFileName); + } + return folder; + } + else + { + // TODO: Implement file open picker + return null; + } +#else + return await Task.FromResult(null); +#endif } public async Task PickFolderAsync(string title) From 077e96c9ce523af1071708ed4b2754ed9975b89e Mon Sep 17 00:00:00 2001 From: redth Date: Wed, 4 Feb 2026 18:18:59 -0500 Subject: [PATCH 3/3] Add build command UI and P12 export options Updates CI secrets wizard and certificate pages; minor AppleConnect logging tweak. - AppleConnectService: default certificate platform is now empty string when missing and log line includes platform for easier debugging. - CISecretsWizard.razor: major enhancements: - Conditional installer step support (ShowInstallerStep) and step validation adjustments. - Added P12 export password input and state (p12ExportPassword). - New Build Command section with shell tabs (bash, PowerShell, GitHub Actions), copy button, and generated commands for each shell. - CSS/layout tweaks for search box and new build command styles. - Improved secret generation: attempts to export signing/installer P12s via LocalCertService.ExportP12Async using the provided export password, includes error handling and adds corresponding password and identity secrets; shows loading messages when downloading/exporting and downloads provisioning profile for export. - Helpers added: GetShellLabel, GetBuildCommand, GetTargetFramework, GetMSBuildProperties, GenerateBashCommand, GeneratePowerShellCommand, GenerateGitHubActionsCommand, and CopyBuildCommand. - Reset logic now clears selected shell and P12 password. - Certificates.razor: platform formatting improved (FormatPlatform made instance method), better handling of unknown/empty platforms, added mappings for TV and Vision OS, and adjusted badge ordering/visibility. These changes add CI-friendly outputs, allow exporting protected P12 artifacts for CI use, and improve platform display and logging for certificates. --- .../Services/AppleConnectService.cs | 4 +- .../Components/CISecretsWizard.razor | 608 ++++++++++++++++-- src/MauiSherpa/Pages/Certificates.razor | 23 +- 3 files changed, 564 insertions(+), 71 deletions(-) diff --git a/src/MauiSherpa.Core/Services/AppleConnectService.cs b/src/MauiSherpa.Core/Services/AppleConnectService.cs index 12bfc059..f372d1ed 100644 --- a/src/MauiSherpa.Core/Services/AppleConnectService.cs +++ b/src/MauiSherpa.Core/Services/AppleConnectService.cs @@ -266,14 +266,14 @@ public async Task> GetCertificatesAsync() c.Id, c.Attributes?.DisplayName ?? c.Attributes?.Name ?? "", c.Attributes?.CertificateType.ToString() ?? "DEVELOPMENT", - c.Attributes?.Platform.ToString() ?? "IOS", + c.Attributes?.Platform.ToString() ?? "", DateTime.UtcNow.AddYears(1), // CertificateAttributes doesn't have ExpirationDate directly c.Attributes?.SerialNumber ?? "")) .ToList(); foreach (var cert in certs) { - _logger.LogInformation($" Cert: {cert.Id} - {cert.Name} - Type: {cert.CertificateType}"); + _logger.LogInformation($" Cert: {cert.Id} - {cert.Name} - Type: {cert.CertificateType} - Platform: '{cert.Platform}'"); } return certs; diff --git a/src/MauiSherpa/Components/CISecretsWizard.razor b/src/MauiSherpa/Components/CISecretsWizard.razor index 0f6df27e..3dda4f21 100644 --- a/src/MauiSherpa/Components/CISecretsWizard.razor +++ b/src/MauiSherpa/Components/CISecretsWizard.razor @@ -51,10 +51,24 @@ RenderDistributionStep(); break; case 3: - RenderInstallerStep(); + @if (ShowInstallerStep) + { + RenderInstallerStep(); + } + else + { + RenderResourcesStep(); + } break; case 4: - RenderResourcesStep(); + @if (ShowInstallerStep) + { + RenderResourcesStep(); + } + else + { + RenderExportStep(); + } break; case 5: RenderExportStep(); @@ -410,6 +424,23 @@
} + + +
+
+ + Certificate Export Password +
+
+ + + + This password will protect your exported certificate. You'll need it in your CI pipeline. + +
+
}} @@ -462,6 +493,42 @@ Download .env file + + +
+

Build Command

+

Use these commands to build and sign your app in CI. Set the environment variables above, then run:

+ +
+ + + +
+ +
+
+ @GetShellLabel() + +
+
@GetBuildCommand()
+
+ + @if (selectedShell == "github") + { +
+ + Add these as GitHub Secrets in your repository settings. +
+ } +
} }} @@ -681,8 +748,8 @@ .wizard-dialog .search-box { display: flex; align-items: center; - gap: 8px; - padding: 8px 12px; + position: relative; + padding: 8px 12px 8px 36px; background: white; border: 1px solid var(--border-color, #e2e8f0); border-radius: 6px; @@ -694,22 +761,33 @@ box-shadow: 0 0 0 3px rgba(139, 92, 246, 0.15); } - .wizard-dialog .search-box i { color: var(--text-muted, #718096); font-size: 14px; flex-shrink: 0; } + .wizard-dialog .search-box i.fa-search { + position: absolute; + left: 12px; + color: var(--text-muted, #718096); + font-size: 14px; + } .wizard-dialog .search-box input { flex: 1; border: none; - outline: none; + outline: none !important; + -webkit-appearance: none; font-size: 14px; background: transparent; padding: 0; margin: 0; line-height: 1.4; min-width: 0; + width: 100%; } .wizard-dialog .search-box input:focus { - outline: none; - box-shadow: none; + outline: none !important; + box-shadow: none !important; + } + + .wizard-dialog .search-box input::placeholder { + color: var(--text-muted, #a0aec0); } .wizard-dialog .clear-search { @@ -891,6 +969,131 @@ border-top: 1px solid var(--border-color, #e2e8f0); } + /* Build Command Section */ + .build-command-section { + margin-top: 24px; + padding-top: 20px; + border-top: 1px solid var(--border-color, #e2e8f0); + } + + .build-command-section h4 { + margin: 0 0 8px 0; + font-size: 16px; + display: flex; + align-items: center; + gap: 8px; + color: var(--text-primary); + } + + .build-command-section .section-description { + color: var(--text-muted); + font-size: 13px; + margin-bottom: 16px; + } + + .shell-tabs { + display: flex; + gap: 4px; + margin-bottom: 12px; + background: var(--bg-tertiary, #e2e8f0); + padding: 4px; + border-radius: 8px; + width: fit-content; + } + + .shell-tab { + padding: 8px 14px; + border: none; + background: transparent; + border-radius: 6px; + cursor: pointer; + font-size: 13px; + font-weight: 500; + color: var(--text-muted); + display: flex; + align-items: center; + gap: 6px; + transition: all 0.15s; + } + + .shell-tab:hover { + color: var(--text-primary); + } + + .shell-tab.active { + background: var(--bg-primary, white); + color: var(--text-primary); + box-shadow: 0 1px 3px rgba(0,0,0,0.1); + } + + .command-block { + background: #1e1e2e; + border-radius: 8px; + overflow: hidden; + } + + .command-header { + display: flex; + justify-content: space-between; + align-items: center; + padding: 10px 14px; + background: rgba(255,255,255,0.05); + border-bottom: 1px solid rgba(255,255,255,0.1); + } + + .command-label { + color: #a6adc8; + font-size: 12px; + font-weight: 500; + } + + .command-header .btn { + background: rgba(255,255,255,0.1); + color: #cdd6f4; + border: none; + font-size: 12px; + padding: 4px 10px; + } + + .command-header .btn:hover { + background: rgba(255,255,255,0.2); + } + + .command-code { + margin: 0; + padding: 14px 16px; + font-family: 'SF Mono', 'Fira Code', 'Consolas', monospace; + font-size: 12px; + line-height: 1.6; + color: #cdd6f4; + white-space: pre-wrap; + word-break: break-word; + overflow-x: auto; + max-height: 300px; + overflow-y: auto; + } + + .command-code code { + font-family: inherit; + } + + .github-secrets-hint { + margin-top: 12px; + padding: 10px 14px; + background: #eff6ff; + border-radius: 6px; + font-size: 13px; + color: #1e40af; + display: flex; + align-items: center; + gap: 8px; + } + + .github-secrets-hint a { + color: #2563eb; + text-decoration: underline; + } + .error-message { background: #fef2f2; color: #dc2626; @@ -966,6 +1169,12 @@ private List localIdentities = new(); private List exportedSecrets = new(); + // Build command shell selection + private string selectedShell = "bash"; + + // P12 export password + private string p12ExportPassword = ""; + // Search/filter private string bundleIdSearch = ""; @@ -985,8 +1194,8 @@ { 1 => true, // Platform always selected 2 => true, // Distribution always selected - 3 => true, // Installer choice always made - 4 => ValidateResourcesStep(), + 3 => ShowInstallerStep ? true : ValidateResourcesStep(), // Either installer choice or resources + 4 => ShowInstallerStep ? ValidateResourcesStep() : true, // Resources or Export _ => true }; @@ -1007,18 +1216,29 @@ private string GetStepLabel(int step) { - if (!ShowInstallerStep && step >= 3) - step++; // Skip installer step numbering - - return step switch + if (ShowInstallerStep) { - 1 => "Platform", - 2 => "Distribution", - 3 => "Installer", - 4 => "Resources", - 5 => "Export", - _ => "" - }; + return step switch + { + 1 => "Platform", + 2 => "Distribution", + 3 => "Installer", + 4 => "Resources", + 5 => "Export", + _ => "" + }; + } + else + { + return step switch + { + 1 => "Platform", + 2 => "Distribution", + 3 => "Resources", + 4 => "Export", + _ => "" + }; + } } protected override async Task OnParametersSetAsync() @@ -1218,14 +1438,7 @@ private async Task NextStep() { - if (!ShowInstallerStep && currentStep == 2) - { - currentStep = 4; // Skip installer step - } - else - { - currentStep++; - } + currentStep++; if (currentStep == TotalSteps) { @@ -1235,14 +1448,7 @@ private void PreviousStep() { - if (!ShowInstallerStep && currentStep == 4) - { - currentStep = 2; // Skip back over installer step - } - else - { - currentStep--; - } + currentStep--; } private async Task GenerateSecrets() @@ -1258,44 +1464,101 @@ // For now, show placeholder secrets var teamId = IdentityState.SelectedIdentity?.IssuerId ?? "TEAM_ID"; + var exportPassword = string.IsNullOrEmpty(p12ExportPassword) ? "changeit" : p12ExportPassword; - exportedSecrets.Add(new CISecretExport( - "APPLE_CERTIFICATE_P12", - "[Export P12 to get this value]", - $"Base64-encoded signing certificate ({wizardState.SigningCertificate?.Name})", - true - )); - - exportedSecrets.Add(new CISecretExport( - "APPLE_CERTIFICATE_PASSWORD", - "", - "Password for the P12 certificate", - true - )); - + // Export signing certificate P12 if (wizardState.SigningCertificate != null) { - var identity = FindMatchingIdentity(wizardState.SigningCertificate); + var signingIdentity = FindMatchingIdentity(wizardState.SigningCertificate); + if (signingIdentity != null) + { + try + { + loadingMessage = "Exporting signing certificate..."; + StateHasChanged(); + + var p12Data = await LocalCertService.ExportP12Async(signingIdentity.Identity, exportPassword); + var p12Base64 = Convert.ToBase64String(p12Data); + + exportedSecrets.Add(new CISecretExport( + "APPLE_CERTIFICATE_P12", + p12Base64, + $"Base64-encoded signing certificate ({wizardState.SigningCertificate.Name})", + true + )); + } + catch (Exception ex) + { + Logger.LogError($"Failed to export signing certificate: {ex.Message}", ex); + exportedSecrets.Add(new CISecretExport( + "APPLE_CERTIFICATE_P12", + $"[Export failed: {ex.Message}]", + $"Base64-encoded signing certificate ({wizardState.SigningCertificate.Name})", + true + )); + } + } + else + { + exportedSecrets.Add(new CISecretExport( + "APPLE_CERTIFICATE_P12", + "[No matching local identity found]", + $"Base64-encoded signing certificate ({wizardState.SigningCertificate.Name})", + true + )); + } + + exportedSecrets.Add(new CISecretExport( + "APPLE_CERTIFICATE_PASSWORD", + exportPassword, + "Password for the P12 certificate", + true + )); + exportedSecrets.Add(new CISecretExport( "APPLE_CODESIGN_IDENTITY", - identity?.Identity ?? wizardState.SigningCertificate.Name, + signingIdentity?.Identity ?? wizardState.SigningCertificate.Name, "Code signing identity name", false )); } + // Export installer certificate P12 if (wizardState.NeedsInstallerCert && wizardState.InstallerCertificate != null) { - exportedSecrets.Add(new CISecretExport( - "APPLE_INSTALLER_CERTIFICATE_P12", - "[Export P12 to get this value]", - $"Base64-encoded installer certificate ({wizardState.InstallerCertificate.Name})", - true - )); + var installerIdentity = FindMatchingIdentity(wizardState.InstallerCertificate); + if (installerIdentity != null) + { + try + { + loadingMessage = "Exporting installer certificate..."; + StateHasChanged(); + + var p12Data = await LocalCertService.ExportP12Async(installerIdentity.Identity, exportPassword); + var p12Base64 = Convert.ToBase64String(p12Data); + + exportedSecrets.Add(new CISecretExport( + "APPLE_INSTALLER_CERTIFICATE_P12", + p12Base64, + $"Base64-encoded installer certificate ({wizardState.InstallerCertificate.Name})", + true + )); + } + catch (Exception ex) + { + Logger.LogError($"Failed to export installer certificate: {ex.Message}", ex); + exportedSecrets.Add(new CISecretExport( + "APPLE_INSTALLER_CERTIFICATE_P12", + $"[Export failed: {ex.Message}]", + $"Base64-encoded installer certificate ({wizardState.InstallerCertificate.Name})", + true + )); + } + } exportedSecrets.Add(new CISecretExport( "APPLE_INSTALLER_CERTIFICATE_PASSWORD", - "", + exportPassword, "Password for the installer P12 certificate", true )); @@ -1303,6 +1566,9 @@ if (NeedsProvisioningProfile && wizardState.ProvisioningProfile != null) { + loadingMessage = "Downloading provisioning profile..."; + StateHasChanged(); + // Download and base64 encode the profile var profileData = await AppleService.DownloadProfileAsync(wizardState.ProvisioningProfile.Id); var profileBase64 = Convert.ToBase64String(profileData); @@ -1443,5 +1709,227 @@ currentStep = 1; exportedSecrets.Clear(); exportError = ""; + selectedShell = "bash"; + p12ExportPassword = ""; + } + + private string GetShellLabel() => selectedShell switch + { + "bash" => "Bash / macOS Terminal", + "powershell" => "PowerShell", + "github" => "GitHub Actions Workflow", + _ => "Shell" + }; + + private string GetBuildCommand() + { + var tfm = GetTargetFramework(); + var props = GetMSBuildProperties(); + + return selectedShell switch + { + "bash" => GenerateBashCommand(tfm, props), + "powershell" => GeneratePowerShellCommand(tfm, props), + "github" => GenerateGitHubActionsCommand(tfm, props), + _ => "" + }; + } + + private string GetTargetFramework() => wizardState.Platform switch + { + ApplePlatformType.iOS => "net9.0-ios", + ApplePlatformType.MacCatalyst => "net9.0-maccatalyst", + ApplePlatformType.macOS => "net9.0-macos", + _ => "net9.0-ios" + }; + + private List<(string Property, string EnvVar, string Description)> GetMSBuildProperties() + { + var props = new List<(string Property, string EnvVar, string Description)>(); + + // Code signing identity + if (exportedSecrets.Any(s => s.Name == "APPLE_CODESIGN_IDENTITY")) + { + props.Add(("CodesignKey", "APPLE_CODESIGN_IDENTITY", "Signing identity")); + } + + // Provisioning profile + if (exportedSecrets.Any(s => s.Name == "APPLE_PROVISIONING_PROFILE_NAME")) + { + props.Add(("CodesignProvision", "APPLE_PROVISIONING_PROFILE_NAME", "Provisioning profile")); + } + + // Archive on build for iOS/Mac Catalyst + if (wizardState.Platform is ApplePlatformType.iOS or ApplePlatformType.MacCatalyst) + { + props.Add(("ArchiveOnBuild", "true", "Create archive")); + } + + // Mac Catalyst specific + if (wizardState.Platform == ApplePlatformType.MacCatalyst) + { + if (wizardState.Distribution == AppleDistributionType.AppStore) + { + props.Add(("CreatePackage", "true", "Create .pkg installer")); + } + else if (wizardState.Distribution == AppleDistributionType.Direct && wizardState.NeedsInstallerCert) + { + props.Add(("CreatePackage", "true", "Create .pkg installer")); + if (exportedSecrets.Any(s => s.Name == "APPLE_INSTALLER_CERTIFICATE_P12")) + { + props.Add(("PackageSigningKey", "APPLE_INSTALLER_IDENTITY", "Installer signing identity")); + } + } + } + + // Notarization + if (NeedsNotarization) + { + props.Add(("NotarizeAcUsername", "APPLE_NOTARIZATION_APPLE_ID", "Notarization Apple ID")); + props.Add(("NotarizeAcPassword", "APPLE_NOTARIZATION_PASSWORD", "Notarization password")); + } + + return props; + } + + private string GenerateBashCommand(string tfm, List<(string Property, string EnvVar, string Description)> props) + { + var sb = new System.Text.StringBuilder(); + sb.AppendLine("# Set environment variables first"); + sb.AppendLine("# export APPLE_CODESIGN_IDENTITY=\"Apple Distribution: ...\""); + sb.AppendLine("# export APPLE_PROVISIONING_PROFILE_NAME=\"My Profile\""); + sb.AppendLine(); + sb.AppendLine("dotnet publish -f " + tfm + " -c Release \\"); + + for (int i = 0; i < props.Count; i++) + { + var (property, envVar, _) = props[i]; + var isLast = i == props.Count - 1; + var value = envVar == "true" ? "true" : $"\"${envVar}\""; + sb.AppendLine($" -p:{property}={value}" + (isLast ? "" : " \\")); + } + + return sb.ToString(); + } + + private string GeneratePowerShellCommand(string tfm, List<(string Property, string EnvVar, string Description)> props) + { + var sb = new System.Text.StringBuilder(); + sb.AppendLine("# Set environment variables first"); + sb.AppendLine("# $env:APPLE_CODESIGN_IDENTITY = \"Apple Distribution: ...\""); + sb.AppendLine("# $env:APPLE_PROVISIONING_PROFILE_NAME = \"My Profile\""); + sb.AppendLine(); + sb.AppendLine("dotnet publish -f " + tfm + " -c Release `"); + + for (int i = 0; i < props.Count; i++) + { + var (property, envVar, _) = props[i]; + var isLast = i == props.Count - 1; + var value = envVar == "true" ? "true" : $"\"$env:{envVar}\""; + sb.AppendLine($" -p:{property}={value}" + (isLast ? "" : " `")); + } + + return sb.ToString(); + } + + private string GenerateGitHubActionsCommand(string tfm, List<(string Property, string EnvVar, string Description)> props) + { + var sb = new System.Text.StringBuilder(); + var hasP12 = exportedSecrets.Any(s => s.Name == "APPLE_CERTIFICATE_P12"); + var hasProfile = exportedSecrets.Any(s => s.Name == "APPLE_PROVISIONING_PROFILE"); + var hasInstallerP12 = exportedSecrets.Any(s => s.Name == "APPLE_INSTALLER_CERTIFICATE_P12"); + + sb.AppendLine("# Add these steps to your workflow (runs-on: macos-latest)"); + sb.AppendLine(); + + // Certificate installation step + if (hasP12) + { + sb.AppendLine("- name: Install Apple Certificate"); + sb.AppendLine(" env:"); + sb.AppendLine(" P12_BASE64: ${{ secrets.APPLE_CERTIFICATE_P12 }}"); + sb.AppendLine(" P12_PASSWORD: ${{ secrets.APPLE_CERTIFICATE_PASSWORD }}"); + sb.AppendLine(" run: |"); + sb.AppendLine(" # Create temporary keychain"); + sb.AppendLine(" KEYCHAIN_PATH=$RUNNER_TEMP/app-signing.keychain-db"); + sb.AppendLine(" KEYCHAIN_PASSWORD=$(openssl rand -base64 32)"); + sb.AppendLine(" security create-keychain -p \"$KEYCHAIN_PASSWORD\" $KEYCHAIN_PATH"); + sb.AppendLine(" security set-keychain-settings -lut 21600 $KEYCHAIN_PATH"); + sb.AppendLine(" security unlock-keychain -p \"$KEYCHAIN_PASSWORD\" $KEYCHAIN_PATH"); + sb.AppendLine(" "); + sb.AppendLine(" # Import certificate"); + sb.AppendLine(" echo \"$P12_BASE64\" | base64 --decode > $RUNNER_TEMP/certificate.p12"); + sb.AppendLine(" security import $RUNNER_TEMP/certificate.p12 -P \"$P12_PASSWORD\" -A -t cert -f pkcs12 -k $KEYCHAIN_PATH"); + sb.AppendLine(" security set-key-partition-list -S apple-tool:,apple: -k \"$KEYCHAIN_PASSWORD\" $KEYCHAIN_PATH"); + sb.AppendLine(" security list-keychain -d user -s $KEYCHAIN_PATH"); + sb.AppendLine(); + } + + // Installer certificate if needed + if (hasInstallerP12) + { + sb.AppendLine("- name: Install Installer Certificate"); + sb.AppendLine(" env:"); + sb.AppendLine(" P12_BASE64: ${{ secrets.APPLE_INSTALLER_CERTIFICATE_P12 }}"); + sb.AppendLine(" P12_PASSWORD: ${{ secrets.APPLE_INSTALLER_CERTIFICATE_PASSWORD }}"); + sb.AppendLine(" run: |"); + sb.AppendLine(" KEYCHAIN_PATH=$RUNNER_TEMP/app-signing.keychain-db"); + sb.AppendLine(" KEYCHAIN_PASSWORD=$(cat $RUNNER_TEMP/.keychain_password 2>/dev/null || echo \"\")"); + sb.AppendLine(" echo \"$P12_BASE64\" | base64 --decode > $RUNNER_TEMP/installer.p12"); + sb.AppendLine(" security import $RUNNER_TEMP/installer.p12 -P \"$P12_PASSWORD\" -A -t cert -f pkcs12 -k $KEYCHAIN_PATH"); + sb.AppendLine(); + } + + // Provisioning profile installation step + if (hasProfile) + { + sb.AppendLine("- name: Install Provisioning Profile"); + sb.AppendLine(" env:"); + sb.AppendLine(" PROFILE_BASE64: ${{ secrets.APPLE_PROVISIONING_PROFILE }}"); + sb.AppendLine(" run: |"); + sb.AppendLine(" mkdir -p ~/Library/MobileDevice/Provisioning\\ Profiles"); + sb.AppendLine(" echo \"$PROFILE_BASE64\" | base64 --decode > ~/Library/MobileDevice/Provisioning\\ Profiles/profile.mobileprovision"); + sb.AppendLine(); + } + + // Build step + sb.AppendLine("- name: Publish App"); + sb.AppendLine(" run: |"); + sb.Append(" dotnet publish -f " + tfm + " -c Release"); + + foreach (var (property, envVar, _) in props) + { + var value = envVar == "true" ? "true" : $"\"${{{{ secrets.{envVar} }}}}\""; + sb.Append($" \\\n -p:{property}={value}"); + } + + sb.AppendLine(); + + // Cleanup step + if (hasP12) + { + sb.AppendLine(); + sb.AppendLine("- name: Cleanup Keychain"); + sb.AppendLine(" if: always()"); + sb.AppendLine(" run: |"); + sb.AppendLine(" security delete-keychain $RUNNER_TEMP/app-signing.keychain-db || true"); + sb.AppendLine(" rm -f $RUNNER_TEMP/certificate.p12 $RUNNER_TEMP/installer.p12 || true"); + } + + sb.AppendLine(); + sb.AppendLine("# Required GitHub Secrets:"); + foreach (var secret in exportedSecrets.Where(s => s.IsSensitive)) + { + sb.AppendLine($"# - {secret.Name}: {secret.Description}"); + } + + return sb.ToString(); + } + + private async Task CopyBuildCommand() + { + var command = GetBuildCommand(); + await DialogService.CopyToClipboardAsync(command); + await AlertService.ShowToastAsync("Build command copied to clipboard"); } } diff --git a/src/MauiSherpa/Pages/Certificates.razor b/src/MauiSherpa/Pages/Certificates.razor index 6175084e..04137b58 100644 --- a/src/MauiSherpa/Pages/Certificates.razor +++ b/src/MauiSherpa/Pages/Certificates.razor @@ -137,10 +137,14 @@
- @FormatCertType(cert.CertificateType) - @if (!string.IsNullOrEmpty(cert.Platform)) + @if (!string.IsNullOrEmpty(cert.CertificateType)) { - @FormatPlatform(cert.Platform) + @FormatCertType(cert.CertificateType) + } + @{ var formattedPlatform = FormatPlatform(cert.Platform); } + @if (!string.IsNullOrEmpty(formattedPlatform)) + { + @formattedPlatform } @if (isExpired) { @@ -1384,18 +1388,19 @@ }; } - private static string FormatPlatform(string? platform) + private string FormatPlatform(string? platform) { - if (string.IsNullOrEmpty(platform)) - return "Unknown"; + if (string.IsNullOrEmpty(platform) || platform.Equals("Unknown", StringComparison.OrdinalIgnoreCase)) + return ""; return platform.ToUpperInvariant() switch { "IOS" => "iOS", - "MAC_OS" => "macOS", - "MACOS" => "macOS", + "MAC_OS" or "MACOS" => "macOS", + "TV_OS" or "TVOS" => "tvOS", + "VISION_OS" or "VISIONOS" => "visionOS", "UNIVERSAL" => "Universal", - _ => platform.Replace("_", " ") + _ => platform.Replace("_", " ") // Fall back to cleaned-up original value }; }