From 597dab3d73216609dbe3bb39ce3879f5cc5f9afd Mon Sep 17 00:00:00 2001 From: Redth Date: Thu, 7 May 2026 13:58:02 -0400 Subject: [PATCH] Align Xcode selection with Xcodes.app Add Xcodes.app-compatible selection modes for renaming, direct selection, and optional symbolic links. Persist the new selection preferences in settings and update the CLI to expose equivalent options. Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> --- .../Commands/Apple/XcodeCommand.cs | 179 ++++++++++++------ src/MauiSherpa.Core/Interfaces.cs | 14 +- src/MauiSherpa.Core/Services/XcodeService.cs | 175 ++++++++++++----- .../ViewModels/XcodeManagementViewModel.cs | 2 +- src/MauiSherpa/Pages/Settings.razor | 50 ++++- .../Services/XcodeServiceTests.cs | 120 ++++++++++-- 6 files changed, 419 insertions(+), 121 deletions(-) diff --git a/src/MauiSherpa.Cli/Commands/Apple/XcodeCommand.cs b/src/MauiSherpa.Cli/Commands/Apple/XcodeCommand.cs index bce39f9..11d0bb2 100644 --- a/src/MauiSherpa.Cli/Commands/Apple/XcodeCommand.cs +++ b/src/MauiSherpa.Cli/Commands/Apple/XcodeCommand.cs @@ -10,9 +10,10 @@ public static class XcodeCommand { private const string ApplicationsDirectory = "/Applications"; private const string ManagedXcodeAppPath = "/Applications/Xcode.app"; - private const string ManagedXcodeAppTempLinkPath = "/Applications/.Xcode.app.maui-sherpa-tmp"; private const string XcodesAppName = "Xcodes.app"; private const string XcodeReleasesUrl = "https://xcodereleases.com/data.json"; + private const string XcodeSelectionActionNone = "none"; + private const string XcodeSelectionActionRename = "rename"; public static Command Create() { @@ -384,19 +385,37 @@ private static bool LooksLikeManagedBundleName(string bundleName) => 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"); + var cmd = new Command("select", "Switch the selected/default Xcode using Xcodes.app-compatible selection actions (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\n maui-sherpa apple xcode select 26.1.1 --selection-action none --create-symlink"); var targetArg = new Argument("target") { Description = "Xcode.app path or version number (e.g. 26.1.1)" }; + var selectionActionOpt = new Option("--selection-action") + { + Description = "Action after selecting: 'rename' renames the selected bundle to /Applications/Xcode.app; 'none' leaves bundle names unchanged.", + DefaultValueFactory = _ => XcodeSelectionActionRename + }; + var createSymlinkOpt = new Option("--create-symlink") + { + Description = "With --selection-action none, create/update /Applications/Xcode.app as a symbolic link to the selected bundle when possible." + }; cmd.Add(targetArg); + cmd.Add(selectionActionOpt); + cmd.Add(createSymlinkOpt); cmd.SetAction(async (parseResult, ct) => { var json = parseResult.GetValue(CliOptions.Json); var target = parseResult.GetValue(targetArg)!; - await SelectAsync(json, target, ct); + var selectionAction = parseResult.GetValue(selectionActionOpt) ?? XcodeSelectionActionRename; + var createSymlink = parseResult.GetValue(createSymlinkOpt); + await SelectAsync(json, target, selectionAction, createSymlink, ct); }); return cmd; } - private static async Task SelectAsync(bool json, string target, CancellationToken ct) + private static async Task SelectAsync( + bool json, + string target, + string selectionAction, + bool createSymlinkOnSelect, + CancellationToken ct) { if (!OperatingSystem.IsMacOS()) { @@ -440,15 +459,29 @@ private static async Task SelectAsync(bool json, string target, CancellationToke var existingPaths = Directory.GetDirectories(ApplicationsDirectory, "Xcode*.app") .Where(p => !Path.GetFileName(p).Equals(XcodesAppName, StringComparison.OrdinalIgnoreCase)) .ToList(); - var selectionPlan = CreateSelectionPlan(appPath, managedDefaultState, existingPaths); + var selectionPlan = CreateSelectionPlan( + appPath, + managedDefaultState, + existingPaths, + selectionAction, + createSymlinkOnSelect); var result = await RunElevatedShellScriptAsync(CreateSelectionScript(selectionPlan), ct); if (result.exitCode == 0) { + var warning = result.error.Trim(); + if (!string.IsNullOrWhiteSpace(warning) && !json) + Output.WriteWarning(warning); + if (json) - Output.WriteJson(new { success = true, path = selectionPlan.SelectedAppPath }); + Output.WriteJson(new + { + success = true, + path = selectionPlan.XcodeSelectPath, + warning = string.IsNullOrWhiteSpace(warning) ? null : warning + }); else - Output.WriteSuccess($"Switched default Xcode to {Path.GetFileName(selectionPlan.SelectedAppPath)}"); + Output.WriteSuccess($"Switched default Xcode to {Path.GetFileName(selectionPlan.XcodeSelectPath)}"); } else { @@ -665,9 +698,14 @@ private static bool IsSelectedDeveloperDir(string? selectedDeveloperDir, string private static XcodeSelectionPlan CreateSelectionPlan( string selectedAppPath, XcodeManagedDefaultState managedDefaultState, - IEnumerable existingPaths) + IEnumerable existingPaths, + string selectionAction = XcodeSelectionActionRename, + bool createSymlinkOnSelect = false) { - var normalizedSelectedAppPath = selectedAppPath; + selectionAction = NormalizeXcodeSelectionAction(selectionAction); + var shouldRename = selectionAction == XcodeSelectionActionRename; + var shouldCreateSymlink = !shouldRename && createSymlinkOnSelect; + var normalizedSelectedAppPath = NormalizePath(selectedAppPath); if (managedDefaultState.IsSymlink && !string.IsNullOrWhiteSpace(managedDefaultState.LinkTargetPath) && PathsEqual(selectedAppPath, managedDefaultState.CanonicalAppPath)) @@ -675,32 +713,29 @@ private static XcodeSelectionPlan CreateSelectionPlan( normalizedSelectedAppPath = managedDefaultState.LinkTargetPath; } - if (!managedDefaultState.IsRealBundle) + string? defaultMoveDestinationPath = null; + if (shouldRename && + managedDefaultState.IsRealBundle && + !PathsEqual(normalizedSelectedAppPath, managedDefaultState.CanonicalAppPath)) { - return new XcodeSelectionPlan( - CanonicalAppPath: managedDefaultState.CanonicalAppPath, - SelectedAppPath: normalizedSelectedAppPath, - MigrationSourcePath: null, - MigrationDestinationPath: null); + if (string.IsNullOrWhiteSpace(managedDefaultState.Version)) + throw new InvalidOperationException("Cannot rename /Applications/Xcode.app without a detected Xcode version."); + + defaultMoveDestinationPath = ResolveManagedXcodeBundlePath( + Path.GetDirectoryName(managedDefaultState.CanonicalAppPath) ?? ApplicationsDirectory, + managedDefaultState.Version, + managedDefaultState.BuildNumber ?? "unknown", + existingPaths.Where(path => !PathsEqual(path, managedDefaultState.CanonicalAppPath)), + "-"); } - if (string.IsNullOrWhiteSpace(managedDefaultState.Version)) - throw new InvalidOperationException("Cannot migrate /Applications/Xcode.app without a detected Xcode version."); - - var migrationDestinationPath = ResolveManagedXcodeBundlePath( - Path.GetDirectoryName(managedDefaultState.CanonicalAppPath) ?? ApplicationsDirectory, - managedDefaultState.Version, - managedDefaultState.BuildNumber ?? "unknown", - existingPaths.Where(path => !PathsEqual(path, managedDefaultState.CanonicalAppPath))); - - if (PathsEqual(normalizedSelectedAppPath, managedDefaultState.CanonicalAppPath)) - normalizedSelectedAppPath = migrationDestinationPath; - return new XcodeSelectionPlan( CanonicalAppPath: managedDefaultState.CanonicalAppPath, SelectedAppPath: normalizedSelectedAppPath, - MigrationSourcePath: managedDefaultState.CanonicalAppPath, - MigrationDestinationPath: migrationDestinationPath); + XcodeSelectPath: shouldRename ? managedDefaultState.CanonicalAppPath : normalizedSelectedAppPath, + DefaultMoveDestinationPath: defaultMoveDestinationPath, + RemoveCanonicalSymlink: shouldRename && managedDefaultState.IsSymlink, + CreateCanonicalSymlink: shouldCreateSymlink); } private static string ResolveManagedXcodeBundlePath( @@ -748,54 +783,85 @@ private static string CreateSelectionScript(XcodeSelectionPlan plan) { var canonicalAppPath = EscapeShellSingleQuotedString(plan.CanonicalAppPath); var selectedAppPath = EscapeShellSingleQuotedString(plan.SelectedAppPath); - var migrationSourcePath = EscapeShellSingleQuotedString(plan.MigrationSourcePath ?? string.Empty); - var migrationDestinationPath = EscapeShellSingleQuotedString(plan.MigrationDestinationPath ?? string.Empty); - var tempLinkPath = EscapeShellSingleQuotedString(ManagedXcodeAppTempLinkPath); + var xcodeSelectPath = EscapeShellSingleQuotedString(plan.XcodeSelectPath); + var defaultMoveDestinationPath = EscapeShellSingleQuotedString(plan.DefaultMoveDestinationPath ?? string.Empty); + var removeCanonicalSymlink = plan.RemoveCanonicalSymlink ? "1" : "0"; + var createCanonicalSymlink = plan.CreateCanonicalSymlink ? "1" : "0"; return $$""" canonical_path='{{canonicalAppPath}}' selected_app='{{selectedAppPath}}' - migration_source='{{migrationSourcePath}}' - migration_destination='{{migrationDestinationPath}}' - temp_link='{{tempLinkPath}}' + xcode_select_path='{{xcodeSelectPath}}' + default_move_destination='{{defaultMoveDestinationPath}}' + remove_canonical_symlink='{{removeCanonicalSymlink}}' + create_canonical_symlink='{{createCanonicalSymlink}}' previous_symlink_target="" + selected_original_path="$selected_app" + selected_moved="" + default_moved="" + created_symlink="" cleanup() { - rm -f "$temp_link" + : } rollback() { - rm -f "$temp_link" - - if [ -L "$canonical_path" ]; then + if [ -n "$created_symlink" ] && [ -L "$canonical_path" ]; then rm "$canonical_path" fi - if [ -n "$previous_symlink_target" ]; then + if [ -n "$selected_moved" ] && [ -d "$canonical_path" ] && [ ! -L "$canonical_path" ] && [ ! -e "$selected_original_path" ]; then + mv "$canonical_path" "$selected_original_path" + fi + + if [ -n "$default_moved" ] && [ -n "$default_move_destination" ] && [ -d "$default_move_destination" ] && [ ! -e "$canonical_path" ]; then + mv "$default_move_destination" "$canonical_path" + fi + + if [ -n "$previous_symlink_target" ] && [ ! -e "$canonical_path" ]; then ln -s "$previous_symlink_target" "$canonical_path" - elif [ -n "$migration_source" ] && [ -n "$migration_destination" ] && [ -d "$migration_destination" ] && [ ! -e "$migration_source" ]; then - mv "$migration_destination" "$migration_source" fi } trap 'status=$?; cleanup; if [ $status -ne 0 ]; then rollback; fi; exit $status' EXIT - if [ -L "$canonical_path" ]; then + if [ "$remove_canonical_symlink" = "1" ] && [ -L "$canonical_path" ]; then previous_symlink_target="$(readlink "$canonical_path")" rm "$canonical_path" fi - if [ -n "$migration_source" ] && [ -n "$migration_destination" ] && [ -d "$migration_source" ] && [ ! -L "$migration_source" ]; then - mv "$migration_source" "$migration_destination" - if [ "$selected_app" = "$migration_source" ]; then - selected_app="$migration_destination" + if [ -n "$default_move_destination" ] && [ -d "$canonical_path" ] && [ ! -L "$canonical_path" ]; then + if [ -e "$default_move_destination" ]; then + echo "Destination already exists: $default_move_destination" >&2 + exit 1 + fi + mv "$canonical_path" "$default_move_destination" + default_moved="1" + fi + + if [ "$selected_app" != "$canonical_path" ]; then + if [ ! -d "$selected_app" ] || [ -L "$selected_app" ]; then + echo "Selected Xcode app not found: $selected_app" >&2 + exit 1 fi + mv "$selected_app" "$canonical_path" + selected_moved="1" fi - rm -f "$temp_link" - ln -s "$selected_app" "$temp_link" - mv "$temp_link" "$canonical_path" - xcode-select -s "$canonical_path/Contents/Developer" + /usr/bin/xcode-select -s "$xcode_select_path" + + if [ "$create_canonical_symlink" = "1" ]; then + if [ -e "$canonical_path" ] && [ ! -L "$canonical_path" ]; then + echo "Cannot create symbolic link at $canonical_path because a real file or directory already exists." >&2 + else + if [ -L "$canonical_path" ]; then + previous_symlink_target="$(readlink "$canonical_path")" + rm "$canonical_path" + fi + ln -s "$selected_app" "$canonical_path" + created_symlink="1" + fi + fi trap - EXIT cleanup @@ -881,6 +947,11 @@ private static bool PathsEqual(string left, string right) => private static string NormalizePath(string path) => Path.GetFullPath(path).TrimEnd(Path.DirectorySeparatorChar); + private static string NormalizeXcodeSelectionAction(string? value) => + string.Equals(value, XcodeSelectionActionNone, StringComparison.OrdinalIgnoreCase) + ? XcodeSelectionActionNone + : XcodeSelectionActionRename; + private static string EscapeShellSingleQuotedString(string value) => value.Replace("'", "'\"'\"'"); @@ -993,7 +1064,9 @@ private record XcodeManagedDefaultState( private record XcodeSelectionPlan( string CanonicalAppPath, string SelectedAppPath, - string? MigrationSourcePath, - string? MigrationDestinationPath + string XcodeSelectPath, + string? DefaultMoveDestinationPath, + bool RemoveCanonicalSymlink, + bool CreateCanonicalSymlink ); } diff --git a/src/MauiSherpa.Core/Interfaces.cs b/src/MauiSherpa.Core/Interfaces.cs index cbd52ef..71058b2 100644 --- a/src/MauiSherpa.Core/Interfaces.cs +++ b/src/MauiSherpa.Core/Interfaces.cs @@ -1001,7 +1001,7 @@ public interface IXcodeService Task> GetAvailableReleasesAsync(); /// - /// Switch active Xcode via sudo xcode-select -s and update /Applications/Xcode.app + /// Switch active Xcode using the configured Xcodes.app-compatible selection action and xcode-select -s /// Task SelectXcodeAsync(string xcodeAppPath); @@ -3414,6 +3414,8 @@ public record AppPreferences public bool DemoMode { get; init; } = false; public string XcodeArchiveExtractor { get; init; } = XcodeArchiveExtractorOptions.SystemXip; public string XcodeBundleSeparator { get; init; } = XcodeBundleSeparatorOptions.Underscore; + public string XcodeSelectionAction { get; init; } = XcodeSelectionActionOptions.Rename; + public bool XcodeCreateSymlinkOnSelect { get; init; } = false; } public static class XcodeArchiveExtractorOptions @@ -3433,6 +3435,16 @@ public static class XcodeBundleSeparatorOptions public const string Hyphen = "-"; } +/// +/// Xcode actions to perform after making an installed bundle active. Values match +/// Xcodes.app's onSelectActionType preference. +/// +public static class XcodeSelectionActionOptions +{ + public const string None = "none"; + public const string Rename = "rename"; +} + 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 411db50..c1c7767 100644 --- a/src/MauiSherpa.Core/Services/XcodeService.cs +++ b/src/MauiSherpa.Core/Services/XcodeService.cs @@ -22,7 +22,6 @@ public class XcodeService : IXcodeService internal const string ApplicationsDirectory = "/Applications"; internal const string ManagedXcodeAppPath = "/Applications/Xcode.app"; private const string XcodesAppName = "Xcodes.app"; - private const string ManagedXcodeAppTempLinkPath = "/Applications/.Xcode.app.maui-sherpa-tmp"; private const string XcodeReleasesUrl = "https://xcodereleases.com/data.json"; private static readonly string[] SystemUnxipExecutableCandidates = [ @@ -155,13 +154,22 @@ public async Task SelectXcodeAsync(string xcodeAppPath) .Where(p => !Path.GetFileName(p).Equals(XcodesAppName, StringComparison.OrdinalIgnoreCase)) .ToList(); - var selectionPlan = CreateSelectionPlan(xcodeAppPath, managedDefaultState, existingPaths, await GetBundleSeparatorAsync()); + var selectionPreferences = await GetSelectionPreferencesAsync(); + var selectionPlan = CreateSelectionPlan( + xcodeAppPath, + managedDefaultState, + existingPaths, + selectionPreferences.SelectionAction, + selectionPreferences.CreateSymlinkOnSelect, + selectionPreferences.BundleSeparator); var script = CreateSelectionScript(selectionPlan); var result = await RunElevatedShellScriptAsync(script); if (result.exitCode == 0) { - _logger.LogInformation($"Switched active Xcode to: {selectionPlan.SelectedAppPath}"); + if (!string.IsNullOrWhiteSpace(result.error)) + _logger.LogWarning(result.error.Trim()); + _logger.LogInformation($"Switched active Xcode to: {selectionPlan.XcodeSelectPath}"); return true; } @@ -815,9 +823,14 @@ internal static XcodeSelectionPlan CreateSelectionPlan( string selectedAppPath, XcodeManagedDefaultState managedDefaultState, IEnumerable existingPaths, - string separator = XcodeBundleSeparatorOptions.Underscore) + string selectionAction = XcodeSelectionActionOptions.Rename, + bool createSymlinkOnSelect = false, + string separator = XcodeBundleSeparatorOptions.Hyphen) { - var normalizedSelectedAppPath = selectedAppPath; + selectionAction = NormalizeXcodeSelectionAction(selectionAction); + var shouldRename = selectionAction == XcodeSelectionActionOptions.Rename; + var shouldCreateSymlink = !shouldRename && createSymlinkOnSelect; + var normalizedSelectedAppPath = NormalizePath(selectedAppPath); if (managedDefaultState.IsSymlink && !string.IsNullOrWhiteSpace(managedDefaultState.LinkTargetPath) && PathsEqual(selectedAppPath, managedDefaultState.CanonicalAppPath)) @@ -825,87 +838,114 @@ internal static XcodeSelectionPlan CreateSelectionPlan( normalizedSelectedAppPath = managedDefaultState.LinkTargetPath; } - if (!managedDefaultState.IsRealBundle) + string? defaultMoveDestinationPath = null; + if (shouldRename && + managedDefaultState.IsRealBundle && + !PathsEqual(normalizedSelectedAppPath, managedDefaultState.CanonicalAppPath)) { - return new XcodeSelectionPlan( - CanonicalAppPath: managedDefaultState.CanonicalAppPath, - SelectedAppPath: normalizedSelectedAppPath, - MigrationSourcePath: null, - MigrationDestinationPath: null); + if (string.IsNullOrWhiteSpace(managedDefaultState.Version)) + throw new InvalidOperationException("Cannot rename /Applications/Xcode.app without a detected Xcode version."); + + defaultMoveDestinationPath = ResolveManagedXcodeBundlePath( + Path.GetDirectoryName(managedDefaultState.CanonicalAppPath) ?? ApplicationsDirectory, + managedDefaultState.Version, + managedDefaultState.BuildNumber ?? "unknown", + existingPaths.Where(path => !PathsEqual(path, managedDefaultState.CanonicalAppPath)), + separator); } - if (string.IsNullOrWhiteSpace(managedDefaultState.Version)) - throw new InvalidOperationException("Cannot migrate /Applications/Xcode.app without a detected Xcode version."); - - var migrationDestinationPath = ResolveManagedXcodeBundlePath( - Path.GetDirectoryName(managedDefaultState.CanonicalAppPath) ?? ApplicationsDirectory, - managedDefaultState.Version, - managedDefaultState.BuildNumber ?? "unknown", - existingPaths.Where(path => !PathsEqual(path, managedDefaultState.CanonicalAppPath)), - separator); - - if (PathsEqual(normalizedSelectedAppPath, managedDefaultState.CanonicalAppPath)) - normalizedSelectedAppPath = migrationDestinationPath; - return new XcodeSelectionPlan( CanonicalAppPath: managedDefaultState.CanonicalAppPath, SelectedAppPath: normalizedSelectedAppPath, - MigrationSourcePath: managedDefaultState.CanonicalAppPath, - MigrationDestinationPath: migrationDestinationPath); + XcodeSelectPath: shouldRename ? managedDefaultState.CanonicalAppPath : normalizedSelectedAppPath, + DefaultMoveDestinationPath: defaultMoveDestinationPath, + RemoveCanonicalSymlink: shouldRename && managedDefaultState.IsSymlink, + CreateCanonicalSymlink: shouldCreateSymlink); } private static string CreateSelectionScript(XcodeSelectionPlan plan) { var canonicalAppPath = EscapeShellSingleQuotedString(plan.CanonicalAppPath); var selectedAppPath = EscapeShellSingleQuotedString(plan.SelectedAppPath); - var migrationSourcePath = EscapeShellSingleQuotedString(plan.MigrationSourcePath ?? string.Empty); - var migrationDestinationPath = EscapeShellSingleQuotedString(plan.MigrationDestinationPath ?? string.Empty); - var tempLinkPath = EscapeShellSingleQuotedString(ManagedXcodeAppTempLinkPath); + var xcodeSelectPath = EscapeShellSingleQuotedString(plan.XcodeSelectPath); + var defaultMoveDestinationPath = EscapeShellSingleQuotedString(plan.DefaultMoveDestinationPath ?? string.Empty); + var removeCanonicalSymlink = plan.RemoveCanonicalSymlink ? "1" : "0"; + var createCanonicalSymlink = plan.CreateCanonicalSymlink ? "1" : "0"; return $$""" canonical_path='{{canonicalAppPath}}' selected_app='{{selectedAppPath}}' - migration_source='{{migrationSourcePath}}' - migration_destination='{{migrationDestinationPath}}' - temp_link='{{tempLinkPath}}' + xcode_select_path='{{xcodeSelectPath}}' + default_move_destination='{{defaultMoveDestinationPath}}' + remove_canonical_symlink='{{removeCanonicalSymlink}}' + create_canonical_symlink='{{createCanonicalSymlink}}' previous_symlink_target="" + selected_original_path="$selected_app" + selected_moved="" + default_moved="" + created_symlink="" cleanup() { - rm -f "$temp_link" + : } rollback() { - rm -f "$temp_link" - - if [ -L "$canonical_path" ]; then + if [ -n "$created_symlink" ] && [ -L "$canonical_path" ]; then rm "$canonical_path" fi - if [ -n "$previous_symlink_target" ]; then + if [ -n "$selected_moved" ] && [ -d "$canonical_path" ] && [ ! -L "$canonical_path" ] && [ ! -e "$selected_original_path" ]; then + mv "$canonical_path" "$selected_original_path" + fi + + if [ -n "$default_moved" ] && [ -n "$default_move_destination" ] && [ -d "$default_move_destination" ] && [ ! -e "$canonical_path" ]; then + mv "$default_move_destination" "$canonical_path" + fi + + if [ -n "$previous_symlink_target" ] && [ ! -e "$canonical_path" ]; then ln -s "$previous_symlink_target" "$canonical_path" - elif [ -n "$migration_source" ] && [ -n "$migration_destination" ] && [ -d "$migration_destination" ] && [ ! -e "$migration_source" ]; then - mv "$migration_destination" "$migration_source" fi } trap 'status=$?; cleanup; if [ $status -ne 0 ]; then rollback; fi; exit $status' EXIT - if [ -L "$canonical_path" ]; then + if [ "$remove_canonical_symlink" = "1" ] && [ -L "$canonical_path" ]; then previous_symlink_target="$(readlink "$canonical_path")" rm "$canonical_path" fi - if [ -n "$migration_source" ] && [ -n "$migration_destination" ] && [ -d "$migration_source" ] && [ ! -L "$migration_source" ]; then - mv "$migration_source" "$migration_destination" - if [ "$selected_app" = "$migration_source" ]; then - selected_app="$migration_destination" + if [ -n "$default_move_destination" ] && [ -d "$canonical_path" ] && [ ! -L "$canonical_path" ]; then + if [ -e "$default_move_destination" ]; then + echo "Destination already exists: $default_move_destination" >&2 + exit 1 fi + mv "$canonical_path" "$default_move_destination" + default_moved="1" fi - rm -f "$temp_link" - ln -s "$selected_app" "$temp_link" - mv "$temp_link" "$canonical_path" - xcode-select -s "$canonical_path/Contents/Developer" + if [ "$selected_app" != "$canonical_path" ]; then + if [ ! -d "$selected_app" ] || [ -L "$selected_app" ]; then + echo "Selected Xcode app not found: $selected_app" >&2 + exit 1 + fi + mv "$selected_app" "$canonical_path" + selected_moved="1" + fi + + /usr/bin/xcode-select -s "$xcode_select_path" + + if [ "$create_canonical_symlink" = "1" ]; then + if [ -e "$canonical_path" ] && [ ! -L "$canonical_path" ]; then + echo "Cannot create symbolic link at $canonical_path because a real file or directory already exists." >&2 + else + if [ -L "$canonical_path" ]; then + previous_symlink_target="$(readlink "$canonical_path")" + rm "$canonical_path" + fi + ln -s "$selected_app" "$canonical_path" + created_symlink="1" + fi + fi trap - EXIT cleanup @@ -1012,11 +1052,38 @@ private async Task GetBundleSeparatorAsync() } } + private async Task GetSelectionPreferencesAsync() + { + try + { + var settings = await _settingsService.GetSettingsAsync(); + var selectionAction = NormalizeXcodeSelectionAction(settings.Preferences.XcodeSelectionAction); + return new XcodeSelectionPreferences( + SelectionAction: selectionAction, + CreateSymlinkOnSelect: selectionAction == XcodeSelectionActionOptions.None && + settings.Preferences.XcodeCreateSymlinkOnSelect, + BundleSeparator: NormalizeBundleSeparator(settings.Preferences.XcodeBundleSeparator)); + } + catch (Exception ex) + { + _logger.LogWarning($"Failed to read Xcode selection preferences; defaulting to rename: {ex.Message}"); + return new XcodeSelectionPreferences( + SelectionAction: XcodeSelectionActionOptions.Rename, + CreateSymlinkOnSelect: false, + BundleSeparator: XcodeBundleSeparatorOptions.Hyphen); + } + } + internal static string NormalizeBundleSeparator(string? value) => string.Equals(value, XcodeBundleSeparatorOptions.Hyphen, StringComparison.Ordinal) ? XcodeBundleSeparatorOptions.Hyphen : XcodeBundleSeparatorOptions.Underscore; + internal static string NormalizeXcodeSelectionAction(string? value) => + string.Equals(value, XcodeSelectionActionOptions.None, StringComparison.OrdinalIgnoreCase) + ? XcodeSelectionActionOptions.None + : XcodeSelectionActionOptions.Rename; + private static string EscapeShellSingleQuotedString(string value) => value.Replace("'", "'\"'\"'"); @@ -1306,6 +1373,14 @@ internal readonly record struct XcodeManagedDefaultState( internal readonly record struct XcodeSelectionPlan( string CanonicalAppPath, string SelectedAppPath, - string? MigrationSourcePath, - string? MigrationDestinationPath + string XcodeSelectPath, + string? DefaultMoveDestinationPath, + bool RemoveCanonicalSymlink, + bool CreateCanonicalSymlink +); + +internal readonly record struct XcodeSelectionPreferences( + string SelectionAction, + bool CreateSymlinkOnSelect, + string BundleSeparator ); diff --git a/src/MauiSherpa.Core/ViewModels/XcodeManagementViewModel.cs b/src/MauiSherpa.Core/ViewModels/XcodeManagementViewModel.cs index 49b60b3..e29e4e5 100644 --- a/src/MauiSherpa.Core/ViewModels/XcodeManagementViewModel.cs +++ b/src/MauiSherpa.Core/ViewModels/XcodeManagementViewModel.cs @@ -190,7 +190,7 @@ public async Task SelectXcodeAsync(XcodeInstallation xcode) var confirmed = await AlertService.ShowConfirmAsync( "Switch Default Xcode", - $"Switch the selected Xcode to {Path.GetFileName(xcode.Path)} (v{xcode.Version})?\n\nMaui Sherpa will update both xcode-select and /Applications/Xcode.app. This requires administrator privileges.", + $"Switch the selected Xcode to {Path.GetFileName(xcode.Path)} (v{xcode.Version})?\n\nMaui Sherpa will update xcode-select and apply your configured Xcode selection action. This requires administrator privileges.", "Switch", "Cancel"); diff --git a/src/MauiSherpa/Pages/Settings.razor b/src/MauiSherpa/Pages/Settings.razor index 164eb4a..89a5f8c 100644 --- a/src/MauiSherpa/Pages/Settings.razor +++ b/src/MauiSherpa/Pages/Settings.razor @@ -108,6 +108,26 @@ +
+ +
+ +
+ Matches Xcodes.app's Active/Select behavior. Rename mode makes the selected bundle the real /Applications/Xcode.app. + Do nothing mode leaves installed bundles in place and selects their full path. +
+
+ + When not renaming, create /Applications/Xcode.app as a symbolic link to the selected bundle if possible. +
+
+
@@ -1252,6 +1272,8 @@ private bool demoMode = false; private string xcodeArchiveExtractor = XcodeArchiveExtractorOptions.SystemXip; private string xcodeBundleSeparator = XcodeBundleSeparatorOptions.Underscore; + private string xcodeSelectionAction = XcodeSelectionActionOptions.Rename; + private bool xcodeCreateSymlinkOnSelect = false; private bool isNormalizingBundles = false; private string appVersion = ""; private UpdateCheckResult? updateResult; @@ -1270,6 +1292,7 @@ // Computed properties for change detection private bool HasSdkPathChanged => editableSdkPath != (SdkService.SdkPath ?? ""); private bool HasJdkPathChanged => editableJdkPath != (effectiveJdkPath ?? ""); + private bool IsXcodeCreateSymlinkDisabled => xcodeSelectionAction == XcodeSelectionActionOptions.Rename; // Cloud provider state private List cloudProviders = new(); @@ -1338,6 +1361,9 @@ demoMode = settings.Preferences.DemoMode; xcodeArchiveExtractor = NormalizeXcodeArchiveExtractor(settings.Preferences.XcodeArchiveExtractor); xcodeBundleSeparator = NormalizeXcodeBundleSeparator(settings.Preferences.XcodeBundleSeparator); + xcodeSelectionAction = NormalizeXcodeSelectionAction(settings.Preferences.XcodeSelectionAction); + xcodeCreateSymlinkOnSelect = xcodeSelectionAction == XcodeSelectionActionOptions.None && + settings.Preferences.XcodeCreateSymlinkOnSelect; } catch { } } @@ -1471,6 +1497,18 @@ xcodeArchiveExtractor = NormalizeXcodeArchiveExtractor(e.Value?.ToString()); } + private void OnXcodeSelectionActionChanged(ChangeEventArgs e) + { + xcodeSelectionAction = NormalizeXcodeSelectionAction(e.Value?.ToString()); + if (xcodeSelectionAction == XcodeSelectionActionOptions.Rename) + xcodeCreateSymlinkOnSelect = false; + } + + private void OnXcodeCreateSymlinkOnSelectChanged(ChangeEventArgs e) + { + xcodeCreateSymlinkOnSelect = xcodeSelectionAction == XcodeSelectionActionOptions.None && e.Value is true; + } + private async Task OnXcodeBundleSeparatorChanged(ChangeEventArgs e) { var newValue = NormalizeXcodeBundleSeparator(e.Value?.ToString()); @@ -1595,7 +1633,10 @@ Theme = ViewModel.Theme, FontScale = ViewModel.FontScale, XcodeArchiveExtractor = xcodeArchiveExtractor, - XcodeBundleSeparator = xcodeBundleSeparator + XcodeBundleSeparator = xcodeBundleSeparator, + XcodeSelectionAction = xcodeSelectionAction, + XcodeCreateSymlinkOnSelect = xcodeSelectionAction == XcodeSelectionActionOptions.None && + xcodeCreateSymlinkOnSelect } }); await AlertService.ShowToastAsync("Settings saved"); @@ -1617,6 +1658,8 @@ demoMode = false; xcodeArchiveExtractor = XcodeArchiveExtractorOptions.SystemXip; xcodeBundleSeparator = XcodeBundleSeparatorOptions.Underscore; + xcodeSelectionAction = XcodeSelectionActionOptions.Rename; + xcodeCreateSymlinkOnSelect = false; ThemeService.SetTheme(ViewModel.Theme); ThemeService.SetFontScale(ViewModel.FontScale); } @@ -1888,6 +1931,11 @@ ? XcodeBundleSeparatorOptions.Hyphen : XcodeBundleSeparatorOptions.Underscore; + private static string NormalizeXcodeSelectionAction(string? value) => + string.Equals(value, XcodeSelectionActionOptions.None, StringComparison.OrdinalIgnoreCase) + ? XcodeSelectionActionOptions.None + : XcodeSelectionActionOptions.Rename; + // Google Identity Methods private async Task ShowAddGoogleIdentityDialog() diff --git a/tests/MauiSherpa.Core.Tests/Services/XcodeServiceTests.cs b/tests/MauiSherpa.Core.Tests/Services/XcodeServiceTests.cs index 7ef3628..da3a449 100644 --- a/tests/MauiSherpa.Core.Tests/Services/XcodeServiceTests.cs +++ b/tests/MauiSherpa.Core.Tests/Services/XcodeServiceTests.cs @@ -166,7 +166,7 @@ public void ResolveManagedXcodeBundlePath_WhenBuildNumberedNameAlsoExists_Append } [Fact] - public void CreateSelectionPlan_WhenCanonicalBundleIsReal_MigratesSelectedBundleToVersionedPath() + public void CreateSelectionPlan_WhenSelectedBundleIsCanonical_SelectsCanonicalWithoutRename() { var managedDefaultState = new XcodeManagedDefaultState( CanonicalAppPath: "/Applications/Xcode.app", @@ -180,15 +180,46 @@ public void CreateSelectionPlan_WhenCanonicalBundleIsReal_MigratesSelectedBundle "/Applications/Xcode.app", managedDefaultState, ["/Applications/Xcode.app"], - XcodeBundleSeparatorOptions.Underscore); + selectionAction: XcodeSelectionActionOptions.Rename, + separator: XcodeBundleSeparatorOptions.Hyphen); + + plan.SelectedAppPath.Should().Be("/Applications/Xcode.app"); + plan.XcodeSelectPath.Should().Be("/Applications/Xcode.app"); + plan.DefaultMoveDestinationPath.Should().BeNull(); + plan.RemoveCanonicalSymlink.Should().BeFalse(); + plan.CreateCanonicalSymlink.Should().BeFalse(); + } + + [Fact] + public void CreateSelectionPlan_WhenSelectingDifferentBundle_MovesCanonicalToXcodesStyleName() + { + var managedDefaultState = new XcodeManagedDefaultState( + CanonicalAppPath: "/Applications/Xcode.app", + Exists: true, + IsSymlink: false, + LinkTargetPath: null, + Version: "26.3", + BuildNumber: "17A123"); - plan.SelectedAppPath.Should().Be("/Applications/Xcode_26.3.app"); - plan.MigrationSourcePath.Should().Be("/Applications/Xcode.app"); - plan.MigrationDestinationPath.Should().Be("/Applications/Xcode_26.3.app"); + var plan = XcodeService.CreateSelectionPlan( + "/Applications/Xcode_26.4.app", + managedDefaultState, + [ + "/Applications/Xcode.app", + "/Applications/Xcode_26.4.app" + ], + selectionAction: XcodeSelectionActionOptions.Rename, + separator: XcodeBundleSeparatorOptions.Hyphen); + + plan.SelectedAppPath.Should().Be("/Applications/Xcode_26.4.app"); + plan.XcodeSelectPath.Should().Be("/Applications/Xcode.app"); + plan.DefaultMoveDestinationPath.Should().Be("/Applications/Xcode-26.3.app"); + plan.RemoveCanonicalSymlink.Should().BeFalse(); + plan.CreateCanonicalSymlink.Should().BeFalse(); } [Fact] - public void CreateSelectionPlan_WhenCanonicalMigrationCollides_UsesUniqueDestinationPath() + public void CreateSelectionPlan_WhenCanonicalMoveCollides_UsesUniqueDestinationPath() { var managedDefaultState = new XcodeManagedDefaultState( CanonicalAppPath: "/Applications/Xcode.app", @@ -199,20 +230,76 @@ public void CreateSelectionPlan_WhenCanonicalMigrationCollides_UsesUniqueDestina BuildNumber: "17A123"); var plan = XcodeService.CreateSelectionPlan( - "/Applications/Xcode.app", + "/Applications/Xcode_26.4.app", managedDefaultState, [ "/Applications/Xcode.app", - "/Applications/Xcode_26.3.app" + "/Applications/Xcode_26.4.app", + "/Applications/Xcode-26.3.app" ], - XcodeBundleSeparatorOptions.Underscore); + selectionAction: XcodeSelectionActionOptions.Rename, + separator: XcodeBundleSeparatorOptions.Hyphen); - plan.SelectedAppPath.Should().Be("/Applications/Xcode_26.3_17A123.app"); - plan.MigrationDestinationPath.Should().Be("/Applications/Xcode_26.3_17A123.app"); + plan.DefaultMoveDestinationPath.Should().Be("/Applications/Xcode-26.3-17A123.app"); + } + + [Fact] + public void CreateSelectionPlan_WhenSelectionActionIsNone_SelectsOriginalBundleWithoutRenaming() + { + var managedDefaultState = new XcodeManagedDefaultState( + CanonicalAppPath: "/Applications/Xcode.app", + Exists: true, + IsSymlink: false, + LinkTargetPath: null, + Version: "26.3", + BuildNumber: "17A123"); + + var plan = XcodeService.CreateSelectionPlan( + "/Applications/Xcode_26.4.app", + managedDefaultState, + [ + "/Applications/Xcode.app", + "/Applications/Xcode_26.4.app" + ], + selectionAction: XcodeSelectionActionOptions.None); + + plan.SelectedAppPath.Should().Be("/Applications/Xcode_26.4.app"); + plan.XcodeSelectPath.Should().Be("/Applications/Xcode_26.4.app"); + plan.DefaultMoveDestinationPath.Should().BeNull(); + plan.RemoveCanonicalSymlink.Should().BeFalse(); + plan.CreateCanonicalSymlink.Should().BeFalse(); + } + + [Fact] + public void CreateSelectionPlan_WhenSelectionActionIsNoneAndSymlinkRequested_CreatesCanonicalSymlink() + { + var managedDefaultState = new XcodeManagedDefaultState( + CanonicalAppPath: "/Applications/Xcode.app", + Exists: true, + IsSymlink: false, + LinkTargetPath: null, + Version: "26.3", + BuildNumber: "17A123"); + + var plan = XcodeService.CreateSelectionPlan( + "/Applications/Xcode_26.4.app", + managedDefaultState, + [ + "/Applications/Xcode.app", + "/Applications/Xcode_26.4.app" + ], + selectionAction: XcodeSelectionActionOptions.None, + createSymlinkOnSelect: true); + + plan.SelectedAppPath.Should().Be("/Applications/Xcode_26.4.app"); + plan.XcodeSelectPath.Should().Be("/Applications/Xcode_26.4.app"); + plan.DefaultMoveDestinationPath.Should().BeNull(); + plan.RemoveCanonicalSymlink.Should().BeFalse(); + plan.CreateCanonicalSymlink.Should().BeTrue(); } [Fact] - public void CreateSelectionPlan_WhenCanonicalPathIsSymlink_UsesLinkTargetWithoutMigration() + public void CreateSelectionPlan_WhenCanonicalPathIsSymlink_UsesLinkTargetAndRemovesSymlink() { var managedDefaultState = new XcodeManagedDefaultState( CanonicalAppPath: "/Applications/Xcode.app", @@ -229,11 +316,14 @@ public void CreateSelectionPlan_WhenCanonicalPathIsSymlink_UsesLinkTargetWithout "/Applications/Xcode.app", "/Applications/Xcode_26.3_17A123.app" ], - XcodeBundleSeparatorOptions.Underscore); + selectionAction: XcodeSelectionActionOptions.Rename, + separator: XcodeBundleSeparatorOptions.Underscore); plan.SelectedAppPath.Should().Be("/Applications/Xcode_26.3_17A123.app"); - plan.MigrationSourcePath.Should().BeNull(); - plan.MigrationDestinationPath.Should().BeNull(); + plan.XcodeSelectPath.Should().Be("/Applications/Xcode.app"); + plan.DefaultMoveDestinationPath.Should().BeNull(); + plan.RemoveCanonicalSymlink.Should().BeTrue(); + plan.CreateCanonicalSymlink.Should().BeFalse(); } [Fact]