diff --git a/src/MauiSherpa.Cli/Commands/Apple/XcodeCommand.cs b/src/MauiSherpa.Cli/Commands/Apple/XcodeCommand.cs index f0a712d..bce39f9 100644 --- a/src/MauiSherpa.Cli/Commands/Apple/XcodeCommand.cs +++ b/src/MauiSherpa.Cli/Commands/Apple/XcodeCommand.cs @@ -29,6 +29,7 @@ public static Command Create() cmd.Add(CreateAvailableCommand()); cmd.Add(CreateSelectCommand()); cmd.Add(CreateDownloadCommand()); + cmd.Add(CreateNormalizeNamesCommand()); return cmd; } @@ -237,6 +238,150 @@ private static async Task ListAvailableAsync(bool json, bool showBetas, int limi // ── maui-sherpa apple xcode select ── + private static Command CreateNormalizeNamesCommand() + { + var cmd = new Command("normalize-names", "Rename Sherpa-managed Xcode bundles in /Applications to match the chosen separator (requires admin privileges).\n\nExamples:\n maui-sherpa apple xcode normalize-names\n maui-sherpa apple xcode normalize-names --separator -\n maui-sherpa apple xcode normalize-names --dry-run"); + var separatorOpt = new Option("--separator") { Description = "Separator to use in bundle names: '_' (GitHub runner-images style) or '-' (xcodes style). Default: '_'.", DefaultValueFactory = _ => "_" }; + var dryRunOpt = new Option("--dry-run") { Description = "Print the rename plan without making any changes." }; + cmd.Add(separatorOpt); + cmd.Add(dryRunOpt); + cmd.SetAction(async (parseResult, ct) => + { + var json = parseResult.GetValue(CliOptions.Json); + var separator = parseResult.GetValue(separatorOpt) ?? "_"; + var dryRun = parseResult.GetValue(dryRunOpt); + await NormalizeNamesAsync(json, separator, dryRun, ct); + }); + return cmd; + } + + private static async Task NormalizeNamesAsync(bool json, string separator, bool dryRun, CancellationToken ct) + { + if (!OperatingSystem.IsMacOS()) + { + Output.WriteError("Xcode is only available on macOS."); + return; + } + + if (separator != "_" && separator != "-") + { + Output.WriteError($"Invalid --separator '{separator}'. Must be '_' or '-'."); + return; + } + + var selected = await GetSelectedDeveloperDirAsync(); + var installs = await DiscoverInstallationsAsync(selected); + var bundles = installs + .Where(i => i.Version != null && LooksLikeManagedBundleName(Path.GetFileName(i.Path))) + .Where(i => !string.Equals(Path.GetFileName(i.Path), "Xcode.app", StringComparison.OrdinalIgnoreCase)) + .Where(i => TryResolveDirectoryLinkTarget(i.Path) is null) + .Select(i => (i.Path, Version: i.Version!, BuildNumber: i.Build ?? "unknown")) + .ToList(); + + var plan = ComputeNormalizationPlanCli(bundles, separator, TryResolveDirectoryLinkTarget(ManagedXcodeAppPath)); + + if (plan.Renames.Count == 0 && plan.SymlinkRetargetPath is null) + { + if (json) Output.WriteJson(new { success = true, renames = Array.Empty(), message = "Already up to date." }); + else Output.WriteSuccess("Xcode bundle names already match the selected separator."); + return; + } + + if (json) + { + Output.WriteJson(new + { + success = true, + dryRun, + separator, + renames = plan.Renames.Select(r => new { from = r.FromPath, to = r.ToPath, version = r.Version, build = r.BuildNumber }).ToArray(), + symlinkRetarget = plan.SymlinkRetargetPath + }); + } + else + { + Output.WriteInfo($"Planned renames (separator '{separator}'):"); + foreach (var r in plan.Renames) + Output.WriteInfo($" {Path.GetFileName(r.FromPath)} → {Path.GetFileName(r.ToPath)}"); + if (plan.SymlinkRetargetPath is not null) + Output.WriteInfo($" /Applications/Xcode.app will be retargeted to {Path.GetFileName(plan.SymlinkRetargetPath)}"); + } + + if (dryRun) return; + + var sb = new System.Text.StringBuilder(); + foreach (var r in plan.Renames) + { + var from = EscapeShellSingleQuotedString(r.FromPath); + var to = EscapeShellSingleQuotedString(r.ToPath); + sb.AppendLine("if [ -d '" + from + "' ] && [ ! -e '" + to + "' ]; then"); + sb.AppendLine(" mv '" + from + "' '" + to + "'"); + sb.AppendLine("fi"); + } + if (plan.SymlinkRetargetPath is not null) + { + var canonical = EscapeShellSingleQuotedString(ManagedXcodeAppPath); + var newTarget = EscapeShellSingleQuotedString(plan.SymlinkRetargetPath); + sb.AppendLine("if [ -L '" + canonical + "' ]; then"); + sb.AppendLine(" rm '" + canonical + "'"); + sb.AppendLine(" ln -s '" + newTarget + "' '" + canonical + "'"); + sb.AppendLine(" xcode-select -s '" + newTarget + "/Contents/Developer' || true"); + sb.AppendLine("fi"); + } + + var result = await RunElevatedShellScriptAsync(sb.ToString(), ct); + if (result.exitCode == 0) + { + if (json) Output.WriteJson(new { success = true, applied = true }); + else Output.WriteSuccess($"Renamed {plan.Renames.Count} bundle(s)."); + } + else + { + var err = result.error.Trim(); + if (json) Output.WriteJson(new { success = false, error = err }); + else Output.WriteError($"Normalize failed: {err}"); + } + } + + private sealed record CliBundleRename(string FromPath, string ToPath, string? Version, string? BuildNumber); + private sealed record CliNormalizationPlan(string Separator, IReadOnlyList Renames, string? SymlinkRetargetPath); + + private static CliNormalizationPlan ComputeNormalizationPlanCli( + IReadOnlyList<(string Path, string Version, string BuildNumber)> bundles, + string separator, + string? currentSymlinkTarget) + { + var renames = new List(); + var reserved = new HashSet(bundles.Select(b => NormalizePath(b.Path)), StringComparer.OrdinalIgnoreCase); + + foreach (var b in bundles.OrderBy(x => x.Path, StringComparer.OrdinalIgnoreCase)) + { + var dir = Path.GetDirectoryName(b.Path) ?? ApplicationsDirectory; + reserved.Remove(NormalizePath(b.Path)); + var desired = ResolveManagedXcodeBundlePath(dir, b.Version, b.BuildNumber, reserved.ToList(), separator); + if (PathsEqual(desired, b.Path)) + { + reserved.Add(NormalizePath(b.Path)); + continue; + } + renames.Add(new CliBundleRename(b.Path, desired, b.Version, b.BuildNumber)); + reserved.Add(NormalizePath(desired)); + } + + string? symlinkRetarget = null; + if (!string.IsNullOrWhiteSpace(currentSymlinkTarget)) + { + var match = renames.FirstOrDefault(r => PathsEqual(r.FromPath, currentSymlinkTarget!)); + if (match is not null) symlinkRetarget = match.ToPath; + } + return new CliNormalizationPlan(separator, renames, symlinkRetarget); + } + + private static bool LooksLikeManagedBundleName(string bundleName) => + (bundleName.StartsWith("Xcode_", StringComparison.OrdinalIgnoreCase) || + bundleName.StartsWith("Xcode-", StringComparison.OrdinalIgnoreCase)) && + bundleName.EndsWith(".app", StringComparison.OrdinalIgnoreCase); + private static Command CreateSelectCommand() { var cmd = new Command("select", "Switch the selected/default Xcode and update /Applications/Xcode.app (requires admin privileges).\n\nExamples:\n maui-sherpa apple xcode select /Applications/Xcode_26.1.1_17B100.app\n maui-sherpa apple xcode select 26.1.1"); @@ -562,32 +707,40 @@ private static string ResolveManagedXcodeBundlePath( string targetDirectory, string version, string buildNumber, - IEnumerable existingPaths) + IEnumerable existingPaths, + string separator = "_") { - var preferredPath = Path.Combine(targetDirectory, GetManagedXcodeBundleName(version, buildNumber)); var normalizedExistingPaths = new HashSet( existingPaths.Select(NormalizePath), StringComparer.OrdinalIgnoreCase); + var preferredPath = Path.Combine(targetDirectory, GetManagedXcodeBundleName(version, separator)); if (!normalizedExistingPaths.Contains(NormalizePath(preferredPath))) return preferredPath; - var baseName = Path.GetFileNameWithoutExtension(preferredPath); + var withBuildPath = Path.Combine(targetDirectory, GetManagedXcodeBundleNameWithBuild(version, buildNumber, separator)); + if (!normalizedExistingPaths.Contains(NormalizePath(withBuildPath))) + return withBuildPath; + + var baseName = Path.GetFileNameWithoutExtension(withBuildPath); for (var suffix = 2; ; suffix++) { - var candidatePath = Path.Combine(targetDirectory, $"{baseName}_{suffix}.app"); + var candidatePath = Path.Combine(targetDirectory, $"{baseName}{separator}{suffix}.app"); if (!normalizedExistingPaths.Contains(NormalizePath(candidatePath))) return candidatePath; } } - private static string GetManagedXcodeBundleName(string version, string buildNumber) => - $"Xcode_{SanitizeXcodeBundleSegment(version)}_{SanitizeXcodeBundleSegment(buildNumber)}.app"; + private static string GetManagedXcodeBundleName(string version, string separator = "_") => + $"Xcode{separator}{SanitizeXcodeBundleSegment(version, separator)}.app"; + + private static string GetManagedXcodeBundleNameWithBuild(string version, string buildNumber, string separator = "_") => + $"Xcode{separator}{SanitizeXcodeBundleSegment(version, separator)}{separator}{SanitizeXcodeBundleSegment(buildNumber, separator)}.app"; - private static string SanitizeXcodeBundleSegment(string value) + private static string SanitizeXcodeBundleSegment(string value, string separator = "_") { - var sanitized = Regex.Replace(value.Trim(), @"[^A-Za-z0-9.\-]+", "_"); - sanitized = Regex.Replace(sanitized, @"_+", "_").Trim('_'); + var sanitized = Regex.Replace(value.Trim(), @"[^A-Za-z0-9.\-]+", separator); + sanitized = Regex.Replace(sanitized, Regex.Escape(separator) + "+", separator).Trim(separator[0]); return string.IsNullOrWhiteSpace(sanitized) ? "unknown" : sanitized; } diff --git a/src/MauiSherpa.Core/Interfaces.cs b/src/MauiSherpa.Core/Interfaces.cs index 5646727..14e21be 100644 --- a/src/MauiSherpa.Core/Interfaces.cs +++ b/src/MauiSherpa.Core/Interfaces.cs @@ -1029,6 +1029,44 @@ public interface IXcodeService /// Install a downloaded Xcode .xip archive (unxip, move to /Applications, run first-launch) /// Task InstallXcodeAsync(string xipPath, string? targetDirectory = null, IProgress? progress = null, CancellationToken ct = default); + + /// + /// Compute the set of Sherpa-managed Xcode bundles whose names don't match the + /// currently selected separator/format. Empty plan means no action needed. + /// + Task GetNormalizationPlanAsync(); + + /// + /// Execute a bundle-name normalization plan (requires admin privileges). + /// Atomically renames each bundle and, if necessary, retargets /Applications/Xcode.app + /// and re-runs xcode-select when the currently active developer dir was renamed. + /// + Task NormalizeBundleNamesAsync(XcodeNormalizationPlan plan, IProgress? progress = null, CancellationToken ct = default); +} + +/// +/// A planned rename of a single Sherpa-managed Xcode bundle. +/// +public record XcodeBundleRename( + string FromPath, + string ToPath, + string? Version, + string? BuildNumber +); + +/// +/// Aggregate plan for normalizing Sherpa-managed Xcode bundle names to match the +/// currently selected separator. is the new +/// target for /Applications/Xcode.app when the active symlink points at one +/// of the bundles being renamed. +/// +public record XcodeNormalizationPlan( + string Separator, + IReadOnlyList Renames, + string? SymlinkRetargetPath +) +{ + public bool HasWork => Renames.Count > 0 || SymlinkRetargetPath is not null; } /// @@ -3375,6 +3413,7 @@ public record AppPreferences public bool AutoBackupEnabled { get; init; } = true; public bool DemoMode { get; init; } = false; public string XcodeArchiveExtractor { get; init; } = XcodeArchiveExtractorOptions.SystemXip; + public string XcodeBundleSeparator { get; init; } = XcodeBundleSeparatorOptions.Underscore; } public static class XcodeArchiveExtractorOptions @@ -3383,6 +3422,17 @@ public static class XcodeArchiveExtractorOptions public const string Unxip = "unxip"; } +/// +/// Separator characters used in Sherpa-managed Xcode bundle names. "_" matches the +/// GitHub runner-images convention (Xcode_26.3.app); "-" matches the xcodes / +/// Xcodes.app convention (Xcode-26.3.app). +/// +public static class XcodeBundleSeparatorOptions +{ + public const string Underscore = "_"; + public const string Hyphen = "-"; +} + public record PushTestingSettings { public string? AuthMode { get; init; } = "identity"; // "identity" or "p8file" diff --git a/src/MauiSherpa.Core/Services/XcodeService.cs b/src/MauiSherpa.Core/Services/XcodeService.cs index 07040d0..411db50 100644 --- a/src/MauiSherpa.Core/Services/XcodeService.cs +++ b/src/MauiSherpa.Core/Services/XcodeService.cs @@ -155,7 +155,7 @@ public async Task SelectXcodeAsync(string xcodeAppPath) .Where(p => !Path.GetFileName(p).Equals(XcodesAppName, StringComparison.OrdinalIgnoreCase)) .ToList(); - var selectionPlan = CreateSelectionPlan(xcodeAppPath, managedDefaultState, existingPaths); + var selectionPlan = CreateSelectionPlan(xcodeAppPath, managedDefaultState, existingPaths, await GetBundleSeparatorAsync()); var script = CreateSelectionScript(selectionPlan); var result = await RunElevatedShellScriptAsync(script); @@ -502,7 +502,8 @@ public async Task InstallXcodeAsync( .Where(path => !PathsEqual(path, xcodeApp)) .Where(path => !Path.GetFileName(path).Equals(XcodesAppName, StringComparison.OrdinalIgnoreCase)) .ToList(); - var destinationPath = ResolveManagedXcodeBundlePath(targetDirectory, version, buildNumber ?? "unknown", existingPaths); + var separator = await GetBundleSeparatorAsync(); + var destinationPath = ResolveManagedXcodeBundlePath(targetDirectory, version, buildNumber ?? "unknown", existingPaths, separator); // Step 3: Move to target directory with admin privileges progress?.Report($"Moving Xcode to {targetDirectory}..."); @@ -563,6 +564,169 @@ exit 1 } } + // ── Bundle name normalization ─────────────────────────────────────── + + public async Task GetNormalizationPlanAsync() + { + var separator = await GetBundleSeparatorAsync(); + + if (!IsSupported || !Directory.Exists(ApplicationsDirectory)) + return new XcodeNormalizationPlan(separator, [], null); + + var candidateBundles = new List<(string Path, string Version, string BuildNumber)>(); + + var xcodeApps = Directory.GetDirectories(ApplicationsDirectory, "Xcode*.app") + .Where(p => + { + var name = Path.GetFileName(p); + if (name.Equals(XcodesAppName, StringComparison.OrdinalIgnoreCase)) return false; + // Exclude the canonical /Applications/Xcode.app slot — managed by the selection flow. + if (PathsEqual(p, ManagedXcodeAppPath)) return false; + // Exclude symlinks (resolved separately). + if (TryResolveDirectoryLinkTarget(p) != null) return false; + return true; + }) + .ToList(); + + foreach (var appPath in xcodeApps) + { + var (version, buildNumber) = await GetXcodeVersionAsync(appPath); + if (string.IsNullOrWhiteSpace(version)) continue; + candidateBundles.Add((appPath, version!, buildNumber ?? "unknown")); + } + + return ComputeNormalizationPlan( + candidateBundles, + separator, + currentSymlinkTarget: TryResolveDirectoryLinkTarget(ManagedXcodeAppPath)); + } + + /// + /// Pure planning helper: given the set of discovered Xcode bundles and the + /// desired separator, return the renames required to bring them into line. + /// Visible for unit tests. + /// + internal static XcodeNormalizationPlan ComputeNormalizationPlan( + IReadOnlyList<(string Path, string Version, string BuildNumber)> bundles, + string separator, + string? currentSymlinkTarget) + { + separator = NormalizeBundleSeparator(separator); + var renames = new List(); + + // Snapshot of paths we're reserving — starts as every existing bundle path. + // As renames resolve, source paths are released and destination paths claimed. + var reservedPaths = new HashSet( + bundles.Select(b => NormalizePath(b.Path)), + StringComparer.OrdinalIgnoreCase); + + foreach (var bundle in bundles.OrderBy(b => b.Path, StringComparer.OrdinalIgnoreCase)) + { + var directory = Path.GetDirectoryName(bundle.Path) ?? ApplicationsDirectory; + var currentName = Path.GetFileName(bundle.Path); + + // Don't touch bundles that don't look managed. Our heuristic: starts with + // "Xcode_" or "Xcode-". A plain "Xcode.app" is already excluded above but + // skipped here too as a safety net. + if (!LooksLikeManagedBundleName(currentName)) continue; + + // Release this bundle's path from the reserved set while we plan its move. + reservedPaths.Remove(NormalizePath(bundle.Path)); + + var existingForResolve = reservedPaths.ToList(); + var desiredPath = ResolveManagedXcodeBundlePath( + directory, bundle.Version, bundle.BuildNumber, existingForResolve, separator); + + if (PathsEqual(desiredPath, bundle.Path)) + { + // Already in the right shape — just re-reserve and continue. + reservedPaths.Add(NormalizePath(bundle.Path)); + continue; + } + + renames.Add(new XcodeBundleRename( + FromPath: bundle.Path, + ToPath: desiredPath, + Version: bundle.Version, + BuildNumber: bundle.BuildNumber)); + + reservedPaths.Add(NormalizePath(desiredPath)); + } + + string? symlinkRetargetPath = null; + if (!string.IsNullOrWhiteSpace(currentSymlinkTarget)) + { + var rename = renames.FirstOrDefault(r => PathsEqual(r.FromPath, currentSymlinkTarget!)); + if (rename is not null) + symlinkRetargetPath = rename.ToPath; + } + + return new XcodeNormalizationPlan(separator, renames, symlinkRetargetPath); + } + + public async Task NormalizeBundleNamesAsync( + XcodeNormalizationPlan plan, + IProgress? progress = null, + CancellationToken ct = default) + { + if (!IsSupported) return false; + if (plan is null || !plan.HasWork) + { + progress?.Report("No bundles to normalize."); + return true; + } + + progress?.Report($"Normalizing {plan.Renames.Count} Xcode bundle name(s)..."); + + var sb = new System.Text.StringBuilder(); + foreach (var rename in plan.Renames) + { + var from = EscapeShellSingleQuotedString(rename.FromPath); + var to = EscapeShellSingleQuotedString(rename.ToPath); + sb.AppendLine("if [ -d '" + from + "' ] && [ ! -e '" + to + "' ]; then"); + sb.AppendLine(" mv '" + from + "' '" + to + "'"); + sb.AppendLine("fi"); + } + + if (plan.SymlinkRetargetPath is not null) + { + var canonical = EscapeShellSingleQuotedString(ManagedXcodeAppPath); + var newTarget = EscapeShellSingleQuotedString(plan.SymlinkRetargetPath); + sb.AppendLine("if [ -L '" + canonical + "' ]; then"); + sb.AppendLine(" rm '" + canonical + "'"); + sb.AppendLine(" ln -s '" + newTarget + "' '" + canonical + "'"); + // Re-run xcode-select so the active developer dir points at the new bundle path. + sb.AppendLine(" xcode-select -s '" + newTarget + "/Contents/Developer' || true"); + sb.AppendLine("fi"); + } + + try + { + var result = await RunElevatedShellScriptAsync(sb.ToString(), ct); + if (result.exitCode != 0) + { + _logger.LogError($"Failed to normalize Xcode bundle names: {result.error}"); + progress?.Report($"Failed to normalize bundle names: {result.error}"); + return false; + } + + progress?.Report("Xcode bundle names normalized."); + _logger.LogInformation($"Normalized {plan.Renames.Count} Xcode bundle name(s) with separator '{plan.Separator}'."); + return true; + } + catch (Exception ex) + { + _logger.LogError($"Exception normalizing Xcode bundle names: {ex.Message}", ex); + progress?.Report($"Normalize failed: {ex.Message}"); + return false; + } + } + + private static bool LooksLikeManagedBundleName(string bundleName) => + (bundleName.StartsWith("Xcode_", StringComparison.OrdinalIgnoreCase) || + bundleName.StartsWith("Xcode-", StringComparison.OrdinalIgnoreCase)) && + bundleName.EndsWith(".app", StringComparison.OrdinalIgnoreCase); + // ── Private helpers ───────────────────────────────────────────────── private async Task GetManagedDefaultStateAsync() @@ -608,31 +772,40 @@ private static bool IsSelectedXcodePath(string? selectedDeveloperDir, string app return PathStartsWith(selectedDeveloperDir, canonicalDeveloperDir); } - internal static string GetManagedXcodeBundleName(string version, string buildNumber) - { - var sanitizedVersion = SanitizeXcodeBundleSegment(version); - var sanitizedBuildNumber = SanitizeXcodeBundleSegment(buildNumber); - return $"Xcode_{sanitizedVersion}_{sanitizedBuildNumber}.app"; - } + internal static string GetManagedXcodeBundleName(string version, string separator) => + $"Xcode{separator}{SanitizeXcodeBundleSegment(version, separator)}.app"; + + // Retained for tests/compat: produces the collision-disambiguated name with a + // build-number suffix appended. Not used for the default install path. + internal static string GetManagedXcodeBundleNameWithBuild(string version, string buildNumber, string separator) => + $"Xcode{separator}{SanitizeXcodeBundleSegment(version, separator)}{separator}{SanitizeXcodeBundleSegment(buildNumber, separator)}.app"; internal static string ResolveManagedXcodeBundlePath( string targetDirectory, string version, string buildNumber, - IEnumerable existingPaths) + IEnumerable existingPaths, + string separator) { - var preferredPath = Path.Combine(targetDirectory, GetManagedXcodeBundleName(version, buildNumber)); var normalizedExistingPaths = new HashSet( existingPaths.Select(NormalizePath), StringComparer.OrdinalIgnoreCase); + // 1. Prefer the plain `Xcode.app` form. + var preferredPath = Path.Combine(targetDirectory, GetManagedXcodeBundleName(version, separator)); if (!normalizedExistingPaths.Contains(NormalizePath(preferredPath))) return preferredPath; - var baseName = Path.GetFileNameWithoutExtension(preferredPath); + // 2. Disambiguate with the build number: `Xcode.app`. + var withBuildPath = Path.Combine(targetDirectory, GetManagedXcodeBundleNameWithBuild(version, buildNumber, separator)); + if (!normalizedExistingPaths.Contains(NormalizePath(withBuildPath))) + return withBuildPath; + + // 3. Final fallback: numeric suffix. + var baseName = Path.GetFileNameWithoutExtension(withBuildPath); for (var suffix = 2; ; suffix++) { - var candidatePath = Path.Combine(targetDirectory, $"{baseName}_{suffix}.app"); + var candidatePath = Path.Combine(targetDirectory, $"{baseName}{separator}{suffix}.app"); if (!normalizedExistingPaths.Contains(NormalizePath(candidatePath))) return candidatePath; } @@ -641,7 +814,8 @@ internal static string ResolveManagedXcodeBundlePath( internal static XcodeSelectionPlan CreateSelectionPlan( string selectedAppPath, XcodeManagedDefaultState managedDefaultState, - IEnumerable existingPaths) + IEnumerable existingPaths, + string separator = XcodeBundleSeparatorOptions.Underscore) { var normalizedSelectedAppPath = selectedAppPath; if (managedDefaultState.IsSymlink && @@ -667,7 +841,8 @@ internal static XcodeSelectionPlan CreateSelectionPlan( Path.GetDirectoryName(managedDefaultState.CanonicalAppPath) ?? ApplicationsDirectory, managedDefaultState.Version, managedDefaultState.BuildNumber ?? "unknown", - existingPaths.Where(path => !PathsEqual(path, managedDefaultState.CanonicalAppPath))); + existingPaths.Where(path => !PathsEqual(path, managedDefaultState.CanonicalAppPath)), + separator); if (PathsEqual(normalizedSelectedAppPath, managedDefaultState.CanonicalAppPath)) normalizedSelectedAppPath = migrationDestinationPath; @@ -814,14 +989,34 @@ private static bool PathsEqual(string left, string right) => private static string NormalizePath(string path) => Path.GetFullPath(path).TrimEnd(Path.DirectorySeparatorChar); - private static string SanitizeXcodeBundleSegment(string value) + private static string SanitizeXcodeBundleSegment(string value, string separator) { - var sanitized = Regex.Replace(value.Trim(), @"[^A-Za-z0-9.\-]+", "_"); - sanitized = Regex.Replace(sanitized, @"_+", "_"); - sanitized = sanitized.Trim('_'); + var sanitized = Regex.Replace(value.Trim(), @"[^A-Za-z0-9.\-]+", separator); + // Collapse runs of the separator. + sanitized = Regex.Replace(sanitized, Regex.Escape(separator) + "+", separator); + sanitized = sanitized.Trim(separator[0]); return string.IsNullOrWhiteSpace(sanitized) ? "unknown" : sanitized; } + private async Task GetBundleSeparatorAsync() + { + try + { + var settings = await _settingsService.GetSettingsAsync(); + return NormalizeBundleSeparator(settings.Preferences.XcodeBundleSeparator); + } + catch (Exception ex) + { + _logger.LogWarning($"Failed to read XcodeBundleSeparator preference; defaulting to '_': {ex.Message}"); + return XcodeBundleSeparatorOptions.Underscore; + } + } + + internal static string NormalizeBundleSeparator(string? value) => + string.Equals(value, XcodeBundleSeparatorOptions.Hyphen, StringComparison.Ordinal) + ? XcodeBundleSeparatorOptions.Hyphen + : XcodeBundleSeparatorOptions.Underscore; + private static string EscapeShellSingleQuotedString(string value) => value.Replace("'", "'\"'\"'"); diff --git a/src/MauiSherpa/Pages/Settings.razor b/src/MauiSherpa/Pages/Settings.razor index 3bf069d..164eb4a 100644 --- a/src/MauiSherpa/Pages/Settings.razor +++ b/src/MauiSherpa/Pages/Settings.razor @@ -30,6 +30,7 @@ @inject ILoggingService Logger @inject MauiSherpa.Pages.Forms.ModalParameterService ModalParams @inject MauiSherpa.Pages.Forms.HybridFormBridgeHolder BridgeHolder +@inject IXcodeService XcodeService @if (!PlatformInfo.HasNativeToolbar) { @@ -93,6 +94,20 @@ +
+ +
+ +
+ Separator used when Sherpa renames managed Xcode installs in /Applications. + Changing this will scan for existing bundles using the other format and offer to rename them. + The build number is only appended when two installs share the same version. +
+
+
@@ -1236,6 +1251,8 @@ private bool demoMode = false; private string xcodeArchiveExtractor = XcodeArchiveExtractorOptions.SystemXip; + private string xcodeBundleSeparator = XcodeBundleSeparatorOptions.Underscore; + private bool isNormalizingBundles = false; private string appVersion = ""; private UpdateCheckResult? updateResult; @@ -1320,6 +1337,7 @@ var settings = await EncryptedSettings.GetSettingsAsync(); demoMode = settings.Preferences.DemoMode; xcodeArchiveExtractor = NormalizeXcodeArchiveExtractor(settings.Preferences.XcodeArchiveExtractor); + xcodeBundleSeparator = NormalizeXcodeBundleSeparator(settings.Preferences.XcodeBundleSeparator); } catch { } } @@ -1453,6 +1471,64 @@ xcodeArchiveExtractor = NormalizeXcodeArchiveExtractor(e.Value?.ToString()); } + private async Task OnXcodeBundleSeparatorChanged(ChangeEventArgs e) + { + var newValue = NormalizeXcodeBundleSeparator(e.Value?.ToString()); + if (newValue == xcodeBundleSeparator) return; + xcodeBundleSeparator = newValue; + + // Persist the new preference first so the service picks it up when planning. + try + { + await EncryptedSettings.UpdateSettingsAsync(s => s with + { + Preferences = s.Preferences with { XcodeBundleSeparator = newValue } + }); + } + catch (Exception ex) + { + await AlertService.ShowAlertAsync("Error", $"Failed to save setting: {ex.Message}"); + return; + } + + if (!XcodeService.IsSupported) return; + + isNormalizingBundles = true; + StateHasChanged(); + try + { + var plan = await XcodeService.GetNormalizationPlanAsync(); + if (!plan.HasWork) + { + await AlertService.ShowToastAsync("Xcode bundle names already match this format."); + return; + } + + var list = string.Join("\n", plan.Renames.Select(r => + $"• {System.IO.Path.GetFileName(r.FromPath)} → {System.IO.Path.GetFileName(r.ToPath)}")); + if (plan.SymlinkRetargetPath is not null) + list += $"\n• /Applications/Xcode.app will point to {System.IO.Path.GetFileName(plan.SymlinkRetargetPath)}"; + + var confirmed = await AlertService.ShowConfirmAsync( + "Rename managed Xcode bundles?", + $"The following {plan.Renames.Count} bundle(s) will be renamed to match the new separator. You'll be prompted for your admin password.\n\n{list}"); + + if (!confirmed) return; + + var ok = await XcodeService.NormalizeBundleNamesAsync(plan); + await AlertService.ShowToastAsync(ok ? "Xcode bundle names updated." : "Failed to rename Xcode bundles."); + } + catch (Exception ex) + { + await AlertService.ShowAlertAsync("Error", $"Failed to normalize Xcode bundle names: {ex.Message}"); + } + finally + { + isNormalizingBundles = false; + StateHasChanged(); + } + } + private void OnFontScaleInput(ChangeEventArgs e) { if (double.TryParse(e.Value?.ToString(), out var scale)) @@ -1518,7 +1594,8 @@ { Theme = ViewModel.Theme, FontScale = ViewModel.FontScale, - XcodeArchiveExtractor = xcodeArchiveExtractor + XcodeArchiveExtractor = xcodeArchiveExtractor, + XcodeBundleSeparator = xcodeBundleSeparator } }); await AlertService.ShowToastAsync("Settings saved"); @@ -1539,6 +1616,7 @@ await ViewModel.ResetSettingsAsync(); demoMode = false; xcodeArchiveExtractor = XcodeArchiveExtractorOptions.SystemXip; + xcodeBundleSeparator = XcodeBundleSeparatorOptions.Underscore; ThemeService.SetTheme(ViewModel.Theme); ThemeService.SetFontScale(ViewModel.FontScale); } @@ -1805,6 +1883,11 @@ ? XcodeArchiveExtractorOptions.Unxip : XcodeArchiveExtractorOptions.SystemXip; + private static string NormalizeXcodeBundleSeparator(string? value) => + string.Equals(value, XcodeBundleSeparatorOptions.Hyphen, StringComparison.Ordinal) + ? XcodeBundleSeparatorOptions.Hyphen + : XcodeBundleSeparatorOptions.Underscore; + // Google Identity Methods private async Task ShowAddGoogleIdentityDialog() @@ -2203,6 +2286,7 @@ ViewModel.FontScale = settings.Preferences.FontScale; demoMode = settings.Preferences.DemoMode; xcodeArchiveExtractor = NormalizeXcodeArchiveExtractor(settings.Preferences.XcodeArchiveExtractor); + xcodeBundleSeparator = NormalizeXcodeBundleSeparator(settings.Preferences.XcodeBundleSeparator); ThemeService.SetTheme(ViewModel.Theme); ThemeService.SetFontScale(ViewModel.FontScale); } diff --git a/tests/MauiSherpa.Core.Tests/Services/XcodeServiceTests.cs b/tests/MauiSherpa.Core.Tests/Services/XcodeServiceTests.cs index e91b683..7ef3628 100644 --- a/tests/MauiSherpa.Core.Tests/Services/XcodeServiceTests.cs +++ b/tests/MauiSherpa.Core.Tests/Services/XcodeServiceTests.cs @@ -85,29 +85,82 @@ public void CreateArchiveExtractionCommand_WithUnxipPreferenceButNoExecutable_Fa } [Fact] - public void GetManagedXcodeBundleName_UsesVersionAndBuild() + public void GetManagedXcodeBundleName_Underscore_UsesVersionOnly() { - var bundleName = XcodeService.GetManagedXcodeBundleName("26.3", "17A123"); + var bundleName = XcodeService.GetManagedXcodeBundleName("26.3", XcodeBundleSeparatorOptions.Underscore); - bundleName.Should().Be("Xcode_26.3_17A123.app"); + bundleName.Should().Be("Xcode_26.3.app"); } [Fact] - public void GetManagedXcodeBundleName_SanitizesBetaVersions() + public void GetManagedXcodeBundleName_Hyphen_UsesVersionOnly() + { + var bundleName = XcodeService.GetManagedXcodeBundleName("26.3", XcodeBundleSeparatorOptions.Hyphen); + + bundleName.Should().Be("Xcode-26.3.app"); + } + + [Theory] + [InlineData("_", "Xcode_26.3_Beta_2.app")] + [InlineData("-", "Xcode-26.3-Beta-2.app")] + public void GetManagedXcodeBundleName_SanitizesBetaVersions(string separator, string expected) { - var bundleName = XcodeService.GetManagedXcodeBundleName("26.3 Beta 2", "17A123"); + var bundleName = XcodeService.GetManagedXcodeBundleName("26.3 Beta 2", separator); - bundleName.Should().Be("Xcode_26.3_Beta_2_17A123.app"); + bundleName.Should().Be(expected); } [Fact] - public void ResolveManagedXcodeBundlePath_WhenPreferredNameExists_AppendsNumericSuffix() + public void GetManagedXcodeBundleNameWithBuild_AppendsBuildNumber() + { + var bundleName = XcodeService.GetManagedXcodeBundleNameWithBuild("26.3", "17A123", XcodeBundleSeparatorOptions.Underscore); + + bundleName.Should().Be("Xcode_26.3_17A123.app"); + } + + [Theory] + [InlineData("_", "/Applications/Xcode_26.3.app")] + [InlineData("-", "/Applications/Xcode-26.3.app")] + public void ResolveManagedXcodeBundlePath_WhenNoCollision_UsesPlainVersionName(string separator, string expected) { var bundlePath = XcodeService.ResolveManagedXcodeBundlePath( "/Applications", "26.3", "17A123", - ["/Applications/Xcode_26.3_17A123.app"]); + [], + separator); + + bundlePath.Should().Be(expected); + } + + [Theory] + [InlineData("_", "/Applications/Xcode_26.3.app", "/Applications/Xcode_26.3_17A123.app")] + [InlineData("-", "/Applications/Xcode-26.3.app", "/Applications/Xcode-26.3-17A123.app")] + public void ResolveManagedXcodeBundlePath_WhenPlainNameExists_AppendsBuildNumber( + string separator, string existing, string expected) + { + var bundlePath = XcodeService.ResolveManagedXcodeBundlePath( + "/Applications", + "26.3", + "17A123", + [existing], + separator); + + bundlePath.Should().Be(expected); + } + + [Fact] + public void ResolveManagedXcodeBundlePath_WhenBuildNumberedNameAlsoExists_AppendsNumericSuffix() + { + var bundlePath = XcodeService.ResolveManagedXcodeBundlePath( + "/Applications", + "26.3", + "17A123", + [ + "/Applications/Xcode_26.3.app", + "/Applications/Xcode_26.3_17A123.app" + ], + XcodeBundleSeparatorOptions.Underscore); bundlePath.Should().Be("/Applications/Xcode_26.3_17A123_2.app"); } @@ -126,11 +179,12 @@ public void CreateSelectionPlan_WhenCanonicalBundleIsReal_MigratesSelectedBundle var plan = XcodeService.CreateSelectionPlan( "/Applications/Xcode.app", managedDefaultState, - ["/Applications/Xcode.app"]); + ["/Applications/Xcode.app"], + XcodeBundleSeparatorOptions.Underscore); - plan.SelectedAppPath.Should().Be("/Applications/Xcode_26.3_17A123.app"); + plan.SelectedAppPath.Should().Be("/Applications/Xcode_26.3.app"); plan.MigrationSourcePath.Should().Be("/Applications/Xcode.app"); - plan.MigrationDestinationPath.Should().Be("/Applications/Xcode_26.3_17A123.app"); + plan.MigrationDestinationPath.Should().Be("/Applications/Xcode_26.3.app"); } [Fact] @@ -149,11 +203,12 @@ public void CreateSelectionPlan_WhenCanonicalMigrationCollides_UsesUniqueDestina managedDefaultState, [ "/Applications/Xcode.app", - "/Applications/Xcode_26.3_17A123.app" - ]); + "/Applications/Xcode_26.3.app" + ], + XcodeBundleSeparatorOptions.Underscore); - plan.SelectedAppPath.Should().Be("/Applications/Xcode_26.3_17A123_2.app"); - plan.MigrationDestinationPath.Should().Be("/Applications/Xcode_26.3_17A123_2.app"); + plan.SelectedAppPath.Should().Be("/Applications/Xcode_26.3_17A123.app"); + plan.MigrationDestinationPath.Should().Be("/Applications/Xcode_26.3_17A123.app"); } [Fact] @@ -173,13 +228,84 @@ public void CreateSelectionPlan_WhenCanonicalPathIsSymlink_UsesLinkTargetWithout [ "/Applications/Xcode.app", "/Applications/Xcode_26.3_17A123.app" - ]); + ], + XcodeBundleSeparatorOptions.Underscore); plan.SelectedAppPath.Should().Be("/Applications/Xcode_26.3_17A123.app"); plan.MigrationSourcePath.Should().BeNull(); plan.MigrationDestinationPath.Should().BeNull(); } + [Fact] + public void ComputeNormalizationPlan_WhenAllBundlesMatch_ReturnsEmptyPlan() + { + var plan = XcodeService.ComputeNormalizationPlan( + [ + ("/Applications/Xcode_26.3.app", "26.3", "17A123"), + ("/Applications/Xcode_26.2.app", "26.2", "17A500") + ], + XcodeBundleSeparatorOptions.Underscore, + currentSymlinkTarget: null); + + plan.Renames.Should().BeEmpty(); + plan.SymlinkRetargetPath.Should().BeNull(); + plan.HasWork.Should().BeFalse(); + } + + [Fact] + public void ComputeNormalizationPlan_SwitchUnderscoreToHyphen_RenamesBundles() + { + var plan = XcodeService.ComputeNormalizationPlan( + [ + ("/Applications/Xcode_26.3_17A123.app", "26.3", "17A123"), + ("/Applications/Xcode_26.2.app", "26.2", "17A500") + ], + XcodeBundleSeparatorOptions.Hyphen, + currentSymlinkTarget: null); + + plan.Renames.Should().HaveCount(2); + plan.Renames.Should().Contain(r => + r.FromPath == "/Applications/Xcode_26.3_17A123.app" && + r.ToPath == "/Applications/Xcode-26.3.app"); + plan.Renames.Should().Contain(r => + r.FromPath == "/Applications/Xcode_26.2.app" && + r.ToPath == "/Applications/Xcode-26.2.app"); + } + + [Fact] + public void ComputeNormalizationPlan_WhenSymlinkTargetIsRenamed_SetsRetargetPath() + { + var plan = XcodeService.ComputeNormalizationPlan( + [ + ("/Applications/Xcode_26.3_17A123.app", "26.3", "17A123") + ], + XcodeBundleSeparatorOptions.Underscore, + currentSymlinkTarget: "/Applications/Xcode_26.3_17A123.app"); + + plan.Renames.Should().ContainSingle() + .Which.ToPath.Should().Be("/Applications/Xcode_26.3.app"); + plan.SymlinkRetargetPath.Should().Be("/Applications/Xcode_26.3.app"); + } + + [Fact] + public void ComputeNormalizationPlan_SwappingSeparators_HandlesCollisionViaBuildNumber() + { + // Two installs of the same version exist, one in each naming style. After + // switching to hyphen, both want the plain `Xcode-26.3.app` slot — one must + // keep the build number suffix to disambiguate. + var plan = XcodeService.ComputeNormalizationPlan( + [ + ("/Applications/Xcode_26.3.app", "26.3", "17A123"), + ("/Applications/Xcode_26.3_17A400.app", "26.3", "17A400") + ], + XcodeBundleSeparatorOptions.Hyphen, + currentSymlinkTarget: null); + + plan.Renames.Should().HaveCount(2); + plan.Renames.Select(r => r.ToPath).Should().BeEquivalentTo( + new[] { "/Applications/Xcode-26.3.app", "/Applications/Xcode-26.3-17A400.app" }); + } + private static string CreateTempDirectory() { var tempDir = Path.Combine(Path.GetTempPath(), "MauiSherpa-XcodeServiceTests", Guid.NewGuid().ToString("N"));