-
Notifications
You must be signed in to change notification settings - Fork 28
Add Xcode management APIs #157
New issue
Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.
By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.
Already on GitHub? Sign in to your account
Merged
Merged
Changes from 4 commits
Commits
Show all changes
7 commits
Select commit
Hold shift + click to select a range
7b1b4e8
Add Xcode management APIs
rmarinho 448ca9f
Add XcodeManager.GetBest() convenience method
rmarinho 964f775
Fix Select to normalize path to Developer directory and use HashSet f…
rmarinho a9e1daf
Address PR review feedback
rmarinho e218135
Add smoke tests for List, GetSelected, GetBest
rmarinho c559bdc
Adopt file-scoped namespaces and flat usings
rmarinho 1e0f7f4
Auto-format source code
File filter
Filter by extension
Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
There are no files selected for viewing
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -0,0 +1,298 @@ | ||
| // Copyright (c) Microsoft Corporation. | ||
| // Licensed under the MIT License. | ||
|
|
||
| using System; | ||
| using System.Collections.Generic; | ||
| using System.IO; | ||
|
|
||
| using Xamarin.MacDev.Models; | ||
|
|
||
| #nullable enable | ||
|
|
||
| namespace Xamarin.MacDev { | ||
|
|
||
| /// <summary> | ||
| /// Lists Xcode installations, reads their metadata, and supports selecting | ||
| /// the active Xcode. Reuses existing <see cref="XcodeLocator"/> for path | ||
| /// validation and plist reading, and <see cref="ProcessUtils"/> for shell commands. | ||
| /// </summary> | ||
| public class XcodeManager { | ||
|
|
||
| static readonly string XcodeSelectPath = "/usr/bin/xcode-select"; | ||
| static readonly string MdfindPath = "/usr/bin/mdfind"; | ||
| static readonly string ApplicationsDir = "/Applications"; | ||
|
|
||
| readonly ICustomLogger log; | ||
|
|
||
| public XcodeManager (ICustomLogger log) | ||
| { | ||
| this.log = log ?? throw new ArgumentNullException (nameof (log)); | ||
| } | ||
|
|
||
| /// <summary> | ||
| /// Lists all Xcode installations found on the system. | ||
| /// Searches via Spotlight (mdfind) and the /Applications directory, | ||
| /// deduplicates by resolved path, then reads version metadata from each. | ||
| /// </summary> | ||
| public List<XcodeInfo> List () | ||
| { | ||
| var selectedPath = GetSelectedPath (); | ||
| var candidates = FindXcodeApps (); | ||
|
|
||
| // Ensure the selected Xcode always appears even if not found | ||
| // by mdfind or /Applications scan (e.g. non-standard location). | ||
| if (selectedPath is not null && !candidates.Contains (selectedPath)) | ||
| candidates.Add (selectedPath); | ||
|
|
||
| var results = new List<XcodeInfo> (); | ||
| var seen = new HashSet<string> (StringComparer.Ordinal); | ||
|
|
||
| foreach (var appPath in candidates) { | ||
| if (!seen.Add (appPath)) | ||
| continue; | ||
|
|
||
| var info = ReadXcodeInfo (appPath); | ||
| if (info is null) | ||
| continue; | ||
|
|
||
| if (selectedPath is not null && appPath.Equals (selectedPath, StringComparison.Ordinal)) | ||
| info.IsSelected = true; | ||
|
|
||
| results.Add (info); | ||
| } | ||
|
|
||
| log.LogInfo ("Found {0} Xcode installation(s).", results.Count); | ||
| return results; | ||
| } | ||
|
|
||
| /// <summary> | ||
| /// Returns information about the currently selected Xcode, or null if none is selected. | ||
| /// </summary> | ||
| public XcodeInfo? GetSelected () | ||
| { | ||
| var selectedPath = GetSelectedPath (); | ||
| if (selectedPath is null) | ||
| return null; | ||
|
|
||
| var info = ReadXcodeInfo (selectedPath); | ||
| if (info is not null) | ||
| info.IsSelected = true; | ||
|
|
||
| return info; | ||
| } | ||
|
|
||
| /// <summary> | ||
| /// Returns the best available Xcode: the currently selected one, or | ||
| /// the highest-versioned installation if none is selected. | ||
| /// </summary> | ||
| public XcodeInfo? GetBest () | ||
| { | ||
| var all = List (); | ||
| if (all.Count == 0) | ||
| return null; | ||
|
|
||
| var selected = all.Find (x => x.IsSelected); | ||
| if (selected is not null) | ||
| return selected; | ||
|
|
||
| // Sort by version descending, then by build string as tiebreaker | ||
| // for beta Xcodes that share the same version number. | ||
| all.Sort ((a, b) => { | ||
| int cmp = b.Version.CompareTo (a.Version); | ||
| return cmp != 0 ? cmp : string.Compare (b.Build, a.Build, StringComparison.Ordinal); | ||
| }); | ||
| return all [0]; | ||
| } | ||
|
|
||
| /// <summary> | ||
| /// Selects the active Xcode by calling <c>xcode-select -s</c>. | ||
| /// Accepts either an Xcode.app bundle path or its Developer directory. | ||
| /// Returns true if the command succeeded. | ||
| /// Note: this typically requires root privileges (sudo). | ||
| /// </summary> | ||
| public bool Select (string path) | ||
| { | ||
| if (string.IsNullOrEmpty (path)) | ||
| throw new ArgumentException ("Path must not be null or empty.", nameof (path)); | ||
|
|
||
| if (!Directory.Exists (path)) { | ||
| log.LogInfo ("Cannot select Xcode: path '{0}' does not exist.", path); | ||
| return false; | ||
| } | ||
|
|
||
|
rmarinho marked this conversation as resolved.
Outdated
|
||
| if (!File.Exists (XcodeSelectPath)) { | ||
| log.LogInfo ("Cannot select Xcode: xcode-select not found."); | ||
| return false; | ||
| } | ||
|
|
||
| try { | ||
| var (exitCode, _, stderr) = ProcessUtils.Exec (XcodeSelectPath, "-s", path); | ||
| if (exitCode != 0) { | ||
| log.LogInfo ("xcode-select -s returned exit code {0}: {1}", exitCode, stderr.Trim ()); | ||
| return false; | ||
| } | ||
|
|
||
| log.LogInfo ("Selected Xcode at '{0}'.", path); | ||
| return true; | ||
| } catch (System.ComponentModel.Win32Exception ex) { | ||
| log.LogInfo ("Could not run xcode-select: {0}", ex.Message); | ||
| return false; | ||
| } catch (InvalidOperationException ex) { | ||
| log.LogInfo ("Could not run xcode-select: {0}", ex.Message); | ||
| return false; | ||
| } | ||
| } | ||
|
|
||
| /// <summary> | ||
| /// Returns the canonicalized path of the currently selected Xcode, or null. | ||
| /// Strips the /Contents/Developer suffix that xcode-select -p returns. | ||
| /// </summary> | ||
| string? GetSelectedPath () | ||
| { | ||
| if (!File.Exists (XcodeSelectPath)) | ||
| return null; | ||
|
|
||
| try { | ||
| var (exitCode, stdout, _) = ProcessUtils.Exec (XcodeSelectPath, "--print-path"); | ||
| if (exitCode != 0) | ||
| return null; | ||
|
|
||
| var path = stdout.Trim (); | ||
| return CanonicalizeXcodePath (path); | ||
| } catch (System.ComponentModel.Win32Exception) { | ||
| return null; | ||
| } catch (InvalidOperationException) { | ||
| return null; | ||
| } | ||
| } | ||
|
|
||
| /// <summary> | ||
| /// Finds Xcode.app bundles via mdfind and /Applications directory listing. | ||
| /// Returns deduplicated list of canonical Xcode.app paths. | ||
| /// </summary> | ||
| List<string> FindXcodeApps () | ||
| { | ||
| var pathSet = new HashSet<string> (StringComparer.Ordinal); | ||
|
|
||
| // 1. Try Spotlight (mdfind) — fastest way to find all Xcode bundles | ||
| if (File.Exists (MdfindPath)) { | ||
| try { | ||
| var (exitCode, stdout, _) = ProcessUtils.Exec (MdfindPath, "kMDItemCFBundleIdentifier == 'com.apple.dt.Xcode'"); | ||
| if (exitCode == 0) { | ||
| foreach (var path in ParseMdfindOutput (stdout)) { | ||
| if (Directory.Exists (path)) | ||
| pathSet.Add (path); | ||
| } | ||
| } | ||
| } catch (System.ComponentModel.Win32Exception ex) { | ||
| log.LogInfo ("Could not run mdfind: {0}", ex.Message); | ||
| } catch (InvalidOperationException ex) { | ||
| log.LogInfo ("Could not run mdfind: {0}", ex.Message); | ||
| } | ||
| } | ||
|
|
||
| // 2. Also scan /Applications for Xcode*.app bundles mdfind might miss | ||
| if (Directory.Exists (ApplicationsDir)) { | ||
| try { | ||
| foreach (var dir in Directory.GetDirectories (ApplicationsDir, "Xcode*.app")) { | ||
| pathSet.Add (dir); | ||
| } | ||
| } catch (UnauthorizedAccessException ex) { | ||
| log.LogInfo ("Could not scan /Applications: {0}", ex.Message); | ||
| } | ||
| } | ||
|
|
||
| return new List<string> (pathSet); | ||
| } | ||
|
|
||
| /// <summary> | ||
| /// Reads Xcode metadata from a .app bundle path. | ||
| /// Returns null if the path is not a valid Xcode installation. | ||
| /// </summary> | ||
| XcodeInfo? ReadXcodeInfo (string appPath) | ||
| { | ||
| var versionPlistPath = Path.Combine (appPath, "Contents", "version.plist"); | ||
| if (!File.Exists (versionPlistPath)) { | ||
| log.LogInfo ("Skipping '{0}': no Contents/version.plist.", appPath); | ||
| return null; | ||
| } | ||
|
|
||
| try { | ||
| var versionPlist = PDictionary.FromFile (versionPlistPath); | ||
| if (versionPlist is null) { | ||
| log.LogInfo ("Skipping '{0}': could not parse version.plist.", appPath); | ||
| return null; | ||
| } | ||
|
|
||
| var versionStr = versionPlist.GetCFBundleShortVersionString (); | ||
| if (!Version.TryParse (versionStr, out var version)) { | ||
| log.LogInfo ("Skipping '{0}': could not parse version '{1}'.", appPath, versionStr); | ||
| return null; | ||
| } | ||
|
|
||
| var info = new XcodeInfo { | ||
| Path = appPath, | ||
| Version = version, | ||
| Build = versionPlist.GetCFBundleVersion () ?? "", | ||
| IsSymlink = PathUtils.IsSymlinkOrHasParentSymlink (appPath), | ||
| }; | ||
|
|
||
| // Read DTXcode from Info.plist if available | ||
| var infoPlistPath = Path.Combine (appPath, "Contents", "Info.plist"); | ||
| if (File.Exists (infoPlistPath)) { | ||
| try { | ||
| var infoPlist = PDictionary.FromFile (infoPlistPath); | ||
| if (infoPlist is not null && infoPlist.TryGetValue<PString> ("DTXcode", out var dtXcode)) | ||
| info.DTXcode = dtXcode.Value; | ||
| } catch (Exception ex) { | ||
| log.LogInfo ("Could not read Info.plist for '{0}': {1}", appPath, ex.Message); | ||
| } | ||
| } | ||
|
|
||
| return info; | ||
| } catch (Exception ex) { | ||
| log.LogInfo ("Could not read Xcode info from '{0}': {1}", appPath, ex.Message); | ||
| return null; | ||
| } | ||
| } | ||
|
|
||
| /// <summary> | ||
| /// Strips /Contents/Developer suffix from an Xcode developer path to get the .app path. | ||
| /// Returns null if the path is empty or does not point to an existing directory. | ||
| /// </summary> | ||
| public static string? CanonicalizeXcodePath (string? path) | ||
| { | ||
| if (string.IsNullOrEmpty (path)) | ||
| return null; | ||
|
|
||
| path = path!.TrimEnd ('/'); | ||
|
|
||
| if (path.EndsWith ("/Contents/Developer", StringComparison.Ordinal)) | ||
| path = path.Substring (0, path.Length - "/Contents/Developer".Length); | ||
|
|
||
| if (!Directory.Exists (path)) | ||
| return null; | ||
|
|
||
| return path; | ||
|
rmarinho marked this conversation as resolved.
Outdated
|
||
| } | ||
|
|
||
| /// <summary> | ||
| /// Parses the output of mdfind into a list of paths. | ||
| /// Exported for testing. | ||
| /// </summary> | ||
| public static List<string> ParseMdfindOutput (string? output) | ||
| { | ||
| var results = new List<string> (); | ||
| if (string.IsNullOrEmpty (output)) | ||
| return results; | ||
|
|
||
| foreach (var rawLine in output!.Split ('\n')) { | ||
| var line = rawLine.Trim (); | ||
| if (line.Length > 0) | ||
| results.Add (line); | ||
| } | ||
|
|
||
| return results; | ||
| } | ||
| } | ||
| } | ||
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -0,0 +1,65 @@ | ||
| // Copyright (c) Microsoft Corporation. | ||
| // Licensed under the MIT License. | ||
|
|
||
| using System.Collections.Generic; | ||
|
|
||
| using NUnit.Framework; | ||
|
|
||
| using Xamarin.MacDev; | ||
|
|
||
| namespace Tests { | ||
|
|
||
| [TestFixture] | ||
| public class XcodeManagerTests { | ||
|
|
||
| [Test] | ||
| public void ParseMdfindOutput_ParsesMultiplePaths () | ||
| { | ||
| var output = "/Applications/Xcode.app\n/Applications/Xcode-beta.app\n"; | ||
| var paths = XcodeManager.ParseMdfindOutput (output); | ||
| Assert.That (paths.Count, Is.EqualTo (2)); | ||
| Assert.That (paths [0], Is.EqualTo ("/Applications/Xcode.app")); | ||
| Assert.That (paths [1], Is.EqualTo ("/Applications/Xcode-beta.app")); | ||
| } | ||
|
|
||
| [Test] | ||
| public void ParseMdfindOutput_IgnoresBlankLines () | ||
| { | ||
| var output = "/Applications/Xcode.app\n\n\n/Applications/Xcode-beta.app\n\n"; | ||
| var paths = XcodeManager.ParseMdfindOutput (output); | ||
| Assert.That (paths.Count, Is.EqualTo (2)); | ||
| } | ||
|
|
||
| [Test] | ||
| public void ParseMdfindOutput_ReturnsEmptyForNullOrEmpty () | ||
| { | ||
| Assert.That (XcodeManager.ParseMdfindOutput (""), Is.Empty); | ||
| Assert.That (XcodeManager.ParseMdfindOutput ((string) null), Is.Empty); | ||
| } | ||
|
|
||
| [Test] | ||
| public void ParseMdfindOutput_HandlesWindowsLineEndings () | ||
| { | ||
| var output = "/Applications/Xcode.app\r\n/Applications/Xcode-beta.app\r\n"; | ||
| var paths = XcodeManager.ParseMdfindOutput (output); | ||
| Assert.That (paths.Count, Is.EqualTo (2)); | ||
| Assert.That (paths [0], Is.EqualTo ("/Applications/Xcode.app")); | ||
| } | ||
|
|
||
| [Test] | ||
| public void ParseMdfindOutput_TrimsWhitespace () | ||
| { | ||
| var output = " /Applications/Xcode.app \n /Applications/Xcode-beta.app \n"; | ||
| var paths = XcodeManager.ParseMdfindOutput (output); | ||
| Assert.That (paths [0], Is.EqualTo ("/Applications/Xcode.app")); | ||
| Assert.That (paths [1], Is.EqualTo ("/Applications/Xcode-beta.app")); | ||
| } | ||
|
|
||
| [Test] | ||
| public void CanonicalizeXcodePath_ReturnsNullForNullOrEmpty () | ||
| { | ||
| Assert.That (XcodeManager.CanonicalizeXcodePath (null), Is.Null); | ||
| Assert.That (XcodeManager.CanonicalizeXcodePath (""), Is.Null); | ||
| } | ||
| } | ||
| } |
Add this suggestion to a batch that can be applied as a single commit.
This suggestion is invalid because no changes were made to the code.
Suggestions cannot be applied while the pull request is closed.
Suggestions cannot be applied while viewing a subset of changes.
Only one suggestion per line can be applied in a batch.
Add this suggestion to a batch that can be applied as a single commit.
Applying suggestions on deleted lines is not supported.
You must change the existing code in this line in order to create a valid suggestion.
Outdated suggestions cannot be applied.
This suggestion has been applied or marked resolved.
Suggestions cannot be applied from pending reviews.
Suggestions cannot be applied on multi-line comments.
Suggestions cannot be applied while the pull request is queued to merge.
Suggestion cannot be applied right now. Please check back later.
Uh oh!
There was an error while loading. Please reload this page.