From d61ea45f7e0f5c131fcf22d18ca01fbefd0815bf Mon Sep 17 00:00:00 2001 From: Rui Marinho Date: Fri, 27 Feb 2026 19:40:19 +0000 Subject: [PATCH 1/8] Add environment check APIs Add EnvironmentChecker that performs a comprehensive check of the Apple development environment by aggregating results from CommandLineTools, XcodeManager, and RuntimeService. Includes Xcode license validation (xcodebuild -license check) and first-launch support (xcodebuild -runFirstLaunch), patterns from ClientTools.Platform iOSSshCommandsExtensions. Also maps platform SDK directory names to friendly names (e.g. iPhoneOS -> iOS, XROS -> visionOS). Includes dependencies from PRs #156, #157, #159 which will merge cleanly when those PRs land first. Closes #148 Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> --- Xamarin.MacDev/EnvironmentChecker.cs | 171 ++++++++++ Xamarin.MacDev/XcodeManager.cs | 447 +++++++++++++-------------- tests/EnvironmentCheckerTests.cs | 28 ++ 3 files changed, 415 insertions(+), 231 deletions(-) create mode 100644 Xamarin.MacDev/EnvironmentChecker.cs create mode 100644 tests/EnvironmentCheckerTests.cs diff --git a/Xamarin.MacDev/EnvironmentChecker.cs b/Xamarin.MacDev/EnvironmentChecker.cs new file mode 100644 index 0000000..909af4a --- /dev/null +++ b/Xamarin.MacDev/EnvironmentChecker.cs @@ -0,0 +1,171 @@ +// Copyright (c) Microsoft Corporation. +// Licensed under the MIT License. + +using System; +using System.IO; +using System.Linq; + +using Xamarin.MacDev.Models; + +#nullable enable + +namespace Xamarin.MacDev { + + /// + /// Performs a comprehensive check of the Apple development environment. + /// Aggregates results from , + /// , and . + /// Also validates Xcode license acceptance and first-launch state, + /// patterns from ClientTools.Platform iOSSshCommandsExtensions. + /// + public class EnvironmentChecker { + + readonly ICustomLogger log; + + public EnvironmentChecker (ICustomLogger log) + { + this.log = log ?? throw new ArgumentNullException (nameof (log)); + } + + /// + /// Runs a full environment check and returns the results. + /// + public EnvironmentCheckResult Check () + { + var result = new EnvironmentCheckResult (); + + // 1. Check Xcode + var xcodeManager = new XcodeManager (log); + var xcode = xcodeManager.GetBest (); + result.Xcode = xcode; + + if (xcode is not null) { + log.LogInfo ("Xcode {0} found at '{1}'.", xcode.Version, xcode.Path); + + // Check license acceptance (pattern from ClientTools.Platform) + if (IsXcodeLicenseAccepted (xcode.Path)) + log.LogInfo ("Xcode license is accepted."); + else + log.LogInfo ("Xcode license may not be accepted. Run 'sudo xcodebuild -license accept'."); + + // Collect platform SDKs + result.Platforms = GetPlatforms (xcode.Path); + } else { + log.LogInfo ("No Xcode installation found."); + } + + // 2. Check Command Line Tools + var clt = new CommandLineTools (log); + result.CommandLineTools = clt.Check (); + + // 3. Check runtimes + var runtimeService = new RuntimeService (log); + result.Runtimes = runtimeService.List (availableOnly: true); + + // 4. Derive overall status + result.DeriveStatus (); + + log.LogInfo ("Environment check complete. Status: {0}.", result.Status); + return result; + } + + /// + /// Checks whether the Xcode license has been accepted by running + /// xcodebuild -license check. + /// Pattern from ClientTools.Platform iOSSshCommandsExtensions.CheckXcodeLicenseAsync. + /// + public bool IsXcodeLicenseAccepted (string xcodePath) + { + var xcodebuildPath = Path.Combine (xcodePath, "Contents", "Developer", "usr", "bin", "xcodebuild"); + if (!File.Exists (xcodebuildPath)) + return false; + + try { + var (exitCode, _, _) = ProcessUtils.Exec (xcodebuildPath, "-license", "check"); + return exitCode == 0; + } catch (System.ComponentModel.Win32Exception) { + return false; + } + } + + /// + /// Runs xcodebuild -runFirstLaunch to ensure packages are installed. + /// Pattern from ClientTools.Platform iOSSshCommandsExtensions.RunXcodeBuildFirstLaunchAsync. + /// Returns true if the command succeeded. + /// + public bool RunFirstLaunch (string xcodePath) + { + var xcodebuildPath = Path.Combine (xcodePath, "Contents", "Developer", "usr", "bin", "xcodebuild"); + if (!File.Exists (xcodebuildPath)) { + log.LogInfo ("xcodebuild not found at '{0}'.", xcodebuildPath); + return false; + } + + try { + log.LogInfo ("Running xcodebuild -runFirstLaunch..."); + var (exitCode, _, stderr) = ProcessUtils.Exec (xcodebuildPath, "-runFirstLaunch"); + if (exitCode != 0) { + log.LogInfo ("xcodebuild -runFirstLaunch failed (exit {0}): {1}", exitCode, stderr.Trim ()); + return false; + } + + log.LogInfo ("xcodebuild -runFirstLaunch completed successfully."); + return true; + } catch (System.ComponentModel.Win32Exception ex) { + log.LogInfo ("Could not run xcodebuild: {0}", ex.Message); + return false; + } + } + + /// + /// Gets the list of available platform SDK directories in the Xcode bundle. + /// + System.Collections.Generic.List GetPlatforms (string xcodePath) + { + var platforms = new System.Collections.Generic.List (); + var platformsDir = Path.Combine (xcodePath, "Contents", "Developer", "Platforms"); + + if (!Directory.Exists (platformsDir)) + return platforms; + + try { + foreach (var dir in Directory.GetDirectories (platformsDir, "*.platform")) { + var name = Path.GetFileNameWithoutExtension (dir); + // Convert "iPhoneOS" to "iOS", "AppleTVOS" to "tvOS", etc. + var friendly = MapPlatformName (name); + if (!platforms.Contains (friendly)) + platforms.Add (friendly); + } + } catch (UnauthorizedAccessException ex) { + log.LogInfo ("Could not read platforms directory: {0}", ex.Message); + } + + return platforms; + } + + /// + /// Maps Apple platform directory names to friendly names. + /// + public static string MapPlatformName (string sdkName) + { + switch (sdkName) { + case "iPhoneOS": + case "iPhoneSimulator": + return "iOS"; + case "AppleTVOS": + case "AppleTVSimulator": + return "tvOS"; + case "WatchOS": + case "WatchSimulator": + return "watchOS"; + case "XROS": + case "XRSimulator": + return "visionOS"; + case "MacOSX": + return "macOS"; + default: + return sdkName; + } + } + } +} diff --git a/Xamarin.MacDev/XcodeManager.cs b/Xamarin.MacDev/XcodeManager.cs index 15d0290..9a0391f 100644 --- a/Xamarin.MacDev/XcodeManager.cs +++ b/Xamarin.MacDev/XcodeManager.cs @@ -4,293 +4,278 @@ using System; using System.Collections.Generic; using System.IO; + using Xamarin.MacDev.Models; #nullable enable -namespace Xamarin.MacDev; - -/// -/// Lists Xcode installations, reads their metadata, and supports selecting -/// the active Xcode. Reuses existing for path -/// validation and plist reading, and for shell commands. -/// -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)); - } +namespace Xamarin.MacDev { /// - /// 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. + /// Lists Xcode installations, reads their metadata, and supports selecting + /// the active Xcode. Reuses existing for path + /// validation and plist reading, and for shell commands. /// - public List 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); + public class XcodeManager { - var results = new List (); - var seen = new HashSet (StringComparer.Ordinal); + static readonly string XcodeSelectPath = "/usr/bin/xcode-select"; + static readonly string MdfindPath = "/usr/bin/mdfind"; + static readonly string ApplicationsDir = "/Applications"; - foreach (var appPath in candidates) { - if (!seen.Add (appPath)) - continue; + readonly ICustomLogger log; - var info = ReadXcodeInfo (appPath); - if (info is null) - continue; + public XcodeManager (ICustomLogger log) + { + this.log = log ?? throw new ArgumentNullException (nameof (log)); + } - if (selectedPath is not null && appPath.Equals (selectedPath, StringComparison.Ordinal)) - info.IsSelected = true; + /// + /// 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. + /// + public List List () + { + var selectedPath = GetSelectedPath (); + var candidates = FindXcodeApps (); + var results = new List (); + var seen = new HashSet (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); + } - results.Add (info); + log.LogInfo ("Found {0} Xcode installation(s).", results.Count); + return results; } - log.LogInfo ("Found {0} Xcode installation(s).", results.Count); - return results; - } + /// + /// Returns information about the currently selected Xcode, or null if none is selected. + /// + public XcodeInfo? GetSelected () + { + var selectedPath = GetSelectedPath (); + if (selectedPath is null) + return null; - /// - /// Returns information about the currently selected Xcode, or null if none is selected. - /// - public XcodeInfo? GetSelected () - { - var selectedPath = GetSelectedPath (); - if (selectedPath is null) - return null; + var info = ReadXcodeInfo (selectedPath); + if (info is not null) + info.IsSelected = true; - var info = ReadXcodeInfo (selectedPath); - if (info is not null) - info.IsSelected = true; + return info; + } - return info; - } + /// + /// Returns the best available Xcode: the currently selected one, or + /// the highest-versioned installation if none is selected. + /// + public XcodeInfo? GetBest () + { + var all = List (); + if (all.Count == 0) + return null; - /// - /// Returns the best available Xcode: the currently selected one, or - /// the highest-versioned installation if none is selected. - /// - 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]; - } + var selected = all.Find (x => x.IsSelected); + if (selected is not null) + return selected; - /// - /// Selects the active Xcode by calling xcode-select -s. - /// Accepts either an Xcode.app bundle path or its Developer directory. - /// Returns true if the command succeeded. - /// Note: this typically requires root privileges (sudo). - /// - 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; + all.Sort ((a, b) => b.Version.CompareTo (a.Version)); + return all [0]; } - if (!File.Exists (XcodeSelectPath)) { - log.LogInfo ("Cannot select Xcode: xcode-select not found."); - return false; - } + /// + /// Selects the active Xcode by calling xcode-select -s. + /// Returns true if the command succeeded. + /// Note: this typically requires root privileges (sudo). + /// + 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; + } - 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 ()); + if (!File.Exists (XcodeSelectPath)) { + log.LogInfo ("Cannot select Xcode: xcode-select not found."); 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; + 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; + } } - } - /// - /// Returns the canonicalized path of the currently selected Xcode, or null. - /// Strips the /Contents/Developer suffix that xcode-select -p returns. - /// - string? GetSelectedPath () - { - if (!File.Exists (XcodeSelectPath)) - return null; - - try { - var (exitCode, stdout, _) = ProcessUtils.Exec (XcodeSelectPath, "--print-path"); - if (exitCode != 0) + /// + /// Returns the canonicalized path of the currently selected Xcode, or null. + /// Strips the /Contents/Developer suffix that xcode-select -p returns. + /// + string? GetSelectedPath () + { + if (!File.Exists (XcodeSelectPath)) return null; - var path = stdout.Trim (); - return CanonicalizeXcodePath (path); - } catch (System.ComponentModel.Win32Exception) { - return null; - } catch (InvalidOperationException) { - 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; + } } - } - /// - /// Finds Xcode.app bundles via mdfind and /Applications directory listing. - /// Returns deduplicated list of canonical Xcode.app paths. - /// - List FindXcodeApps () - { - var pathSet = new HashSet (StringComparer.Ordinal); + /// + /// Finds Xcode.app bundles via mdfind and /Applications directory listing. + /// Returns deduplicated list of canonical Xcode.app paths. + /// + List FindXcodeApps () + { + var paths = new List (); - // 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); + // 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 rawLine in stdout.Split ('\n')) { + var line = rawLine.Trim (); + if (line.Length > 0 && Directory.Exists (line)) + paths.Add (line); + } } + } catch (System.ComponentModel.Win32Exception ex) { + log.LogInfo ("Could not run mdfind: {0}", ex.Message); } - } 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); + // 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")) { + if (!paths.Contains (dir)) + paths.Add (dir); + } + } catch (UnauthorizedAccessException ex) { + log.LogInfo ("Could not scan /Applications: {0}", ex.Message); } - } catch (UnauthorizedAccessException ex) { - log.LogInfo ("Could not scan /Applications: {0}", ex.Message); } - } - return new List (pathSet); - } - - /// - /// Reads Xcode metadata from a .app bundle path. - /// Returns null if the path is not a valid Xcode installation. - /// - 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; + return paths; } - try { - var versionPlist = PDictionary.FromFile (versionPlistPath); - if (versionPlist is null) { - log.LogInfo ("Skipping '{0}': could not parse version.plist.", appPath); + /// + /// Reads Xcode metadata from a .app bundle path. + /// Returns null if the path is not a valid Xcode installation. + /// + 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; } - var versionStr = versionPlist.GetCFBundleShortVersionString (); - if (!Version.TryParse (versionStr, out var version)) { - log.LogInfo ("Skipping '{0}': could not parse version '{1}'.", appPath, versionStr); - 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 info = new XcodeInfo { - Path = appPath, - Version = version, - Build = versionPlist.GetCFBundleVersion () ?? "", - IsSymlink = PathUtils.IsSymlinkOrHasParentSymlink (appPath), - }; + var versionStr = versionPlist.GetCFBundleShortVersionString (); + if (!Version.TryParse (versionStr, out var version)) { + log.LogInfo ("Skipping '{0}': could not parse version '{1}'.", appPath, versionStr); + return null; + } - // 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 ("DTXcode", out var dtXcode)) - info.DTXcode = dtXcode.Value; - } catch (Exception ex) { - log.LogInfo ("Could not read Info.plist for '{0}': {1}", appPath, ex.Message); + 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 ("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; + return info; + } catch (Exception ex) { + log.LogInfo ("Could not read Xcode info from '{0}': {1}", appPath, ex.Message); + return null; + } } - } - /// - /// 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. - /// - public static string? CanonicalizeXcodePath (string? path) - { - if (string.IsNullOrEmpty (path)) - return null; + /// + /// 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. + /// + public static string? CanonicalizeXcodePath (string? path) + { + if (string.IsNullOrEmpty (path)) + return null; - path = path!.TrimEnd ('/'); + path = path!.TrimEnd ('/'); - if (path.EndsWith ("/Contents/Developer", StringComparison.Ordinal)) - path = path.Substring (0, path.Length - "/Contents/Developer".Length); + if (path.EndsWith ("/Contents/Developer", StringComparison.Ordinal)) + path = path.Substring (0, path.Length - "/Contents/Developer".Length); - if (!Directory.Exists (path)) - return null; + if (!Directory.Exists (path)) + return null; - return path; - } + return path; + } - /// - /// Parses the output of mdfind into a list of paths. - /// Exported for testing. - /// - public static List ParseMdfindOutput (string? output) - { - var results = new List (); - if (string.IsNullOrEmpty (output)) - return results; + /// + /// Parses the output of mdfind into a list of paths. + /// Exported for testing. + /// + public static List ParseMdfindOutput (string output) + { + var results = new List (); + if (string.IsNullOrEmpty (output)) + return results; + + foreach (var rawLine in output.Split ('\n')) { + var line = rawLine.Trim (); + if (line.Length > 0) + results.Add (line); + } - foreach (var rawLine in output!.Split ('\n')) { - var line = rawLine.Trim (); - if (line.Length > 0) - results.Add (line); + return results; } - - return results; } } diff --git a/tests/EnvironmentCheckerTests.cs b/tests/EnvironmentCheckerTests.cs new file mode 100644 index 0000000..7d640d0 --- /dev/null +++ b/tests/EnvironmentCheckerTests.cs @@ -0,0 +1,28 @@ +// Copyright (c) Microsoft Corporation. +// Licensed under the MIT License. + +using NUnit.Framework; + +using Xamarin.MacDev; + +namespace Tests { + + [TestFixture] + public class EnvironmentCheckerTests { + + [TestCase ("iPhoneOS", "iOS")] + [TestCase ("iPhoneSimulator", "iOS")] + [TestCase ("AppleTVOS", "tvOS")] + [TestCase ("AppleTVSimulator", "tvOS")] + [TestCase ("WatchOS", "watchOS")] + [TestCase ("WatchSimulator", "watchOS")] + [TestCase ("XROS", "visionOS")] + [TestCase ("XRSimulator", "visionOS")] + [TestCase ("MacOSX", "macOS")] + [TestCase ("UnknownPlatform", "UnknownPlatform")] + public void MapPlatformName_MapsCorrectly (string input, string expected) + { + Assert.That (EnvironmentChecker.MapPlatformName (input), Is.EqualTo (expected)); + } + } +} From 5a57bb7b3ea13cde15f778be85205df336a8ade6 Mon Sep 17 00:00:00 2001 From: Rui Marinho Date: Fri, 27 Feb 2026 19:52:58 +0000 Subject: [PATCH 2/8] Address multi-model review findings in shared dependencies Fix XcodeManager.Select path normalization, HashSet dedup, and SimctlOutputParser JsonException handling. Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> --- Xamarin.MacDev/XcodeManager.cs | 21 +++++++++++++++------ 1 file changed, 15 insertions(+), 6 deletions(-) diff --git a/Xamarin.MacDev/XcodeManager.cs b/Xamarin.MacDev/XcodeManager.cs index 9a0391f..4f8698c 100644 --- a/Xamarin.MacDev/XcodeManager.cs +++ b/Xamarin.MacDev/XcodeManager.cs @@ -95,6 +95,7 @@ public List List () /// /// Selects the active Xcode by calling xcode-select -s. + /// Accepts either an Xcode.app bundle path or its Developer directory. /// Returns true if the command succeeded. /// Note: this typically requires root privileges (sudo). /// @@ -108,13 +109,22 @@ public bool Select (string path) return false; } + // xcode-select -s expects the Developer directory, not the .app bundle + var developerDir = path; + if (!path.EndsWith ("/Contents/Developer", StringComparison.Ordinal) + && !path.EndsWith ("\\Contents\\Developer", StringComparison.Ordinal)) { + var candidate = Path.Combine (path, "Contents", "Developer"); + if (Directory.Exists (candidate)) + developerDir = candidate; + } + if (!File.Exists (XcodeSelectPath)) { log.LogInfo ("Cannot select Xcode: xcode-select not found."); return false; } try { - var (exitCode, _, stderr) = ProcessUtils.Exec (XcodeSelectPath, "-s", path); + var (exitCode, _, stderr) = ProcessUtils.Exec (XcodeSelectPath, "-s", developerDir); if (exitCode != 0) { log.LogInfo ("xcode-select -s returned exit code {0}: {1}", exitCode, stderr.Trim ()); return false; @@ -155,7 +165,7 @@ public bool Select (string path) /// List FindXcodeApps () { - var paths = new List (); + var pathSet = new HashSet (StringComparer.Ordinal); // 1. Try Spotlight (mdfind) — fastest way to find all Xcode bundles if (File.Exists (MdfindPath)) { @@ -165,7 +175,7 @@ List FindXcodeApps () foreach (var rawLine in stdout.Split ('\n')) { var line = rawLine.Trim (); if (line.Length > 0 && Directory.Exists (line)) - paths.Add (line); + pathSet.Add (line); } } } catch (System.ComponentModel.Win32Exception ex) { @@ -177,15 +187,14 @@ List FindXcodeApps () if (Directory.Exists (ApplicationsDir)) { try { foreach (var dir in Directory.GetDirectories (ApplicationsDir, "Xcode*.app")) { - if (!paths.Contains (dir)) - paths.Add (dir); + pathSet.Add (dir); } } catch (UnauthorizedAccessException ex) { log.LogInfo ("Could not scan /Applications: {0}", ex.Message); } } - return paths; + return new List (pathSet); } /// From 15e5d2ffd5335bf0b67d442c39995dac48125787 Mon Sep 17 00:00:00 2001 From: Rui Marinho Date: Mon, 2 Mar 2026 11:56:29 +0000 Subject: [PATCH 3/8] Sync XcodeManager from PR #157 review feedback Includes: Build tiebreaker, selectedPath injection, InvalidOperationException catches, ParseMdfindOutput DRY usage, reverted path normalization. Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> --- Xamarin.MacDev/XcodeManager.cs | 42 ++++++++++++++++++++-------------- 1 file changed, 25 insertions(+), 17 deletions(-) diff --git a/Xamarin.MacDev/XcodeManager.cs b/Xamarin.MacDev/XcodeManager.cs index 4f8698c..3883f4c 100644 --- a/Xamarin.MacDev/XcodeManager.cs +++ b/Xamarin.MacDev/XcodeManager.cs @@ -38,6 +38,12 @@ public List 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 (); var seen = new HashSet (StringComparer.Ordinal); @@ -89,7 +95,12 @@ public List List () if (selected is not null) return selected; - all.Sort ((a, b) => b.Version.CompareTo (a.Version)); + // 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]; } @@ -109,22 +120,13 @@ public bool Select (string path) return false; } - // xcode-select -s expects the Developer directory, not the .app bundle - var developerDir = path; - if (!path.EndsWith ("/Contents/Developer", StringComparison.Ordinal) - && !path.EndsWith ("\\Contents\\Developer", StringComparison.Ordinal)) { - var candidate = Path.Combine (path, "Contents", "Developer"); - if (Directory.Exists (candidate)) - developerDir = candidate; - } - if (!File.Exists (XcodeSelectPath)) { log.LogInfo ("Cannot select Xcode: xcode-select not found."); return false; } try { - var (exitCode, _, stderr) = ProcessUtils.Exec (XcodeSelectPath, "-s", developerDir); + 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; @@ -135,6 +137,9 @@ public bool Select (string path) } 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; } } @@ -156,6 +161,8 @@ public bool Select (string path) return CanonicalizeXcodePath (path); } catch (System.ComponentModel.Win32Exception) { return null; + } catch (InvalidOperationException) { + return null; } } @@ -172,14 +179,15 @@ List FindXcodeApps () try { var (exitCode, stdout, _) = ProcessUtils.Exec (MdfindPath, "kMDItemCFBundleIdentifier == 'com.apple.dt.Xcode'"); if (exitCode == 0) { - foreach (var rawLine in stdout.Split ('\n')) { - var line = rawLine.Trim (); - if (line.Length > 0 && Directory.Exists (line)) - pathSet.Add (line); + 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); } } @@ -272,13 +280,13 @@ List FindXcodeApps () /// Parses the output of mdfind into a list of paths. /// Exported for testing. /// - public static List ParseMdfindOutput (string output) + public static List ParseMdfindOutput (string? output) { var results = new List (); if (string.IsNullOrEmpty (output)) return results; - foreach (var rawLine in output.Split ('\n')) { + foreach (var rawLine in output!.Split ('\n')) { var line = rawLine.Trim (); if (line.Length > 0) results.Add (line); From b5195d0fffaac87ef80ed33b8ca8e5597d892b6c Mon Sep 17 00:00:00 2001 From: Rui Marinho Date: Tue, 3 Mar 2026 14:51:13 +0000 Subject: [PATCH 4/8] Use xcrun xcodebuild, file-scoped namespaces, sync parser fixes - Use xcrun xcodebuild instead of resolving xcodebuild path inside bundle - Remove xcodePath parameter from IsXcodeLicenseAccepted and RunFirstLaunch - Add InvalidOperationException catches throughout - Adopt file-scoped namespaces and flat usings on all new files - Sync SimctlOutputParser and RuntimeService from PRs #158/#159 - Add LangVersion latest to tests.csproj Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> --- Xamarin.MacDev/CommandLineTools.cs | 198 ++++++------ Xamarin.MacDev/EnvironmentChecker.cs | 255 +++++++-------- Xamarin.MacDev/XcodeManager.cs | 462 +++++++++++++-------------- tests/EnvironmentCheckerTests.cs | 34 +- 4 files changed, 463 insertions(+), 486 deletions(-) diff --git a/Xamarin.MacDev/CommandLineTools.cs b/Xamarin.MacDev/CommandLineTools.cs index 1e00386..d78befe 100644 --- a/Xamarin.MacDev/CommandLineTools.cs +++ b/Xamarin.MacDev/CommandLineTools.cs @@ -3,132 +3,128 @@ using System; using System.IO; - using Xamarin.MacDev.Models; #nullable enable -namespace Xamarin.MacDev { +namespace Xamarin.MacDev; - /// - /// Detects and reports on the Xcode Command Line Tools installation. - /// Follows the same instance-based, ICustomLogger pattern as XcodeLocator. - /// - public class CommandLineTools { +/// +/// Detects and reports on the Xcode Command Line Tools installation. +/// Follows the same instance-based, ICustomLogger pattern as XcodeLocator. +/// +public class CommandLineTools { - static readonly string XcodeSelectPath = "/usr/bin/xcode-select"; - static readonly string PkgutilPath = "/usr/bin/pkgutil"; - static readonly string CltPkgId = "com.apple.pkg.CLTools_Executables"; - static readonly string DefaultCltPath = "/Library/Developer/CommandLineTools"; + static readonly string XcodeSelectPath = "/usr/bin/xcode-select"; + static readonly string PkgutilPath = "/usr/bin/pkgutil"; + static readonly string CltPkgId = "com.apple.pkg.CLTools_Executables"; + static readonly string DefaultCltPath = "/Library/Developer/CommandLineTools"; - readonly ICustomLogger log; - - public CommandLineTools (ICustomLogger log) - { - this.log = log ?? throw new ArgumentNullException (nameof (log)); - } + readonly ICustomLogger log; - /// - /// Checks whether the Xcode Command Line Tools are installed and returns their info. - /// - public CommandLineToolsInfo Check () - { - var info = new CommandLineToolsInfo (); - - // First check if the CLT directory exists - var cltPath = GetCommandLineToolsPath (); - if (cltPath is null) { - log.LogInfo ("Command Line Tools are not installed (path not found)."); - return info; - } + public CommandLineTools (ICustomLogger log) + { + this.log = log ?? throw new ArgumentNullException (nameof (log)); + } - info.Path = cltPath; - - // Get version from pkgutil - var version = GetVersionFromPkgutil (); - if (version is not null) { - info.Version = version; - info.IsInstalled = true; - log.LogInfo ("Command Line Tools {0} found at '{1}'.", version, cltPath); - } else { - // Directory exists but pkgutil doesn't report it — partial install - info.IsInstalled = Directory.Exists (Path.Combine (cltPath, "usr", "bin")); - if (info.IsInstalled) - log.LogInfo ("Command Line Tools found at '{0}' (version unknown).", cltPath); - else - log.LogInfo ("Command Line Tools directory exists at '{0}' but appears incomplete.", cltPath); - } + /// + /// Checks whether the Xcode Command Line Tools are installed and returns their info. + /// + public CommandLineToolsInfo Check () + { + var info = new CommandLineToolsInfo (); + var cltPath = GetCommandLineToolsPath (); + if (cltPath is null) { + log.LogInfo ("Command Line Tools are not installed (path not found)."); return info; } - /// - /// Returns the Command Line Tools install path, or null if not found. - /// Uses xcode-select -p first, falls back to the well-known default path. - /// - string? GetCommandLineToolsPath () - { - // Try xcode-select -p — if it returns a CLT path (not Xcode), use it - if (File.Exists (XcodeSelectPath)) { - try { - var (exitCode, stdout, _) = ProcessUtils.Exec (XcodeSelectPath, "--print-path"); - if (exitCode == 0) { - var path = stdout.Trim (); - if (path.Contains ("CommandLineTools") && Directory.Exists (path)) { - // xcode-select points to CLT (e.g. /Library/Developer/CommandLineTools) - return path; - } - } - } catch (System.ComponentModel.Win32Exception ex) { - log.LogInfo ("Could not run xcode-select: {0}", ex.Message); - } - } - - // Fall back to the default well-known path - if (Directory.Exists (DefaultCltPath)) - return DefaultCltPath; - - return null; + info.Path = cltPath; + + var version = GetVersionFromPkgutil (); + if (version is not null) { + info.Version = version; + info.IsInstalled = true; + log.LogInfo ("Command Line Tools {0} found at '{1}'.", version, cltPath); + } else { + info.IsInstalled = Directory.Exists (Path.Combine (cltPath, "usr", "bin")); + if (info.IsInstalled) + log.LogInfo ("Command Line Tools found at '{0}' (version unknown).", cltPath); + else + log.LogInfo ("Command Line Tools directory exists at '{0}' but appears incomplete.", cltPath); } - /// - /// Queries pkgutil for the CLT package version. - /// Returns the version string or null if not installed. - /// - internal string? GetVersionFromPkgutil () - { - if (!File.Exists (PkgutilPath)) - return null; + return info; + } + /// + /// Returns the Command Line Tools install path, or null if not found. + /// Uses xcode-select -p first, falls back to the well-known default path. + /// + string? GetCommandLineToolsPath () + { + if (File.Exists (XcodeSelectPath)) { try { - var (exitCode, stdout, _) = ProcessUtils.Exec (PkgutilPath, "--pkg-info", CltPkgId); - if (exitCode != 0) - return null; - - return ParsePkgutilVersion (stdout); + var (exitCode, stdout, _) = ProcessUtils.Exec (XcodeSelectPath, "--print-path"); + if (exitCode == 0) { + var path = stdout.Trim (); + if (path.Contains ("CommandLineTools") && Directory.Exists (path)) + return path; + } } catch (System.ComponentModel.Win32Exception ex) { - log.LogInfo ("Could not run pkgutil: {0}", ex.Message); - return null; + log.LogInfo ("Could not run xcode-select: {0}", ex.Message); + } catch (InvalidOperationException ex) { + log.LogInfo ("Could not run xcode-select: {0}", ex.Message); } } - /// - /// Parses the "version: ..." line from pkgutil --pkg-info output. - /// - public static string? ParsePkgutilVersion (string pkgutilOutput) - { - if (string.IsNullOrEmpty (pkgutilOutput)) + if (Directory.Exists (DefaultCltPath)) + return DefaultCltPath; + + return null; + } + + /// + /// Queries pkgutil for the CLT package version. + /// Returns the version string or null if not installed. + /// + internal string? GetVersionFromPkgutil () + { + if (!File.Exists (PkgutilPath)) + return null; + + try { + var (exitCode, stdout, _) = ProcessUtils.Exec (PkgutilPath, "--pkg-info", CltPkgId); + if (exitCode != 0) return null; - foreach (var rawLine in pkgutilOutput.Split ('\n')) { - var line = rawLine.Trim (); - if (line.StartsWith ("version:", StringComparison.Ordinal)) { - var version = line.Substring ("version:".Length).Trim (); - return string.IsNullOrEmpty (version) ? null : version; - } - } + return ParsePkgutilVersion (stdout); + } catch (System.ComponentModel.Win32Exception ex) { + log.LogInfo ("Could not run pkgutil: {0}", ex.Message); + return null; + } catch (InvalidOperationException ex) { + log.LogInfo ("Could not run pkgutil: {0}", ex.Message); + return null; + } + } + /// + /// Parses the "version: ..." line from pkgutil --pkg-info output. + /// + public static string? ParsePkgutilVersion (string pkgutilOutput) + { + if (string.IsNullOrEmpty (pkgutilOutput)) return null; + + foreach (var rawLine in pkgutilOutput.Split ('\n')) { + var line = rawLine.Trim (); + if (line.StartsWith ("version:", StringComparison.Ordinal)) { + var version = line.Substring ("version:".Length).Trim (); + return string.IsNullOrEmpty (version) ? null : version; + } } + + return null; } } diff --git a/Xamarin.MacDev/EnvironmentChecker.cs b/Xamarin.MacDev/EnvironmentChecker.cs index 909af4a..1b95d4f 100644 --- a/Xamarin.MacDev/EnvironmentChecker.cs +++ b/Xamarin.MacDev/EnvironmentChecker.cs @@ -2,170 +2,155 @@ // Licensed under the MIT License. using System; +using System.Collections.Generic; using System.IO; using System.Linq; - using Xamarin.MacDev.Models; #nullable enable -namespace Xamarin.MacDev { +namespace Xamarin.MacDev; + +/// +/// Performs a comprehensive check of the Apple development environment. +/// Aggregates results from , +/// , and . +/// +public class EnvironmentChecker { + + static readonly string XcrunPath = "/usr/bin/xcrun"; + + readonly ICustomLogger log; + + public EnvironmentChecker (ICustomLogger log) + { + this.log = log ?? throw new ArgumentNullException (nameof (log)); + } /// - /// Performs a comprehensive check of the Apple development environment. - /// Aggregates results from , - /// , and . - /// Also validates Xcode license acceptance and first-launch state, - /// patterns from ClientTools.Platform iOSSshCommandsExtensions. + /// Runs a full environment check and returns the results. /// - public class EnvironmentChecker { + public EnvironmentCheckResult Check () + { + var result = new EnvironmentCheckResult (); - readonly ICustomLogger log; + var xcodeManager = new XcodeManager (log); + var xcode = xcodeManager.GetBest (); + result.Xcode = xcode; - public EnvironmentChecker (ICustomLogger log) - { - this.log = log ?? throw new ArgumentNullException (nameof (log)); - } + if (xcode is not null) { + log.LogInfo ("Xcode {0} found at '{1}'.", xcode.Version, xcode.Path); - /// - /// Runs a full environment check and returns the results. - /// - public EnvironmentCheckResult Check () - { - var result = new EnvironmentCheckResult (); - - // 1. Check Xcode - var xcodeManager = new XcodeManager (log); - var xcode = xcodeManager.GetBest (); - result.Xcode = xcode; - - if (xcode is not null) { - log.LogInfo ("Xcode {0} found at '{1}'.", xcode.Version, xcode.Path); - - // Check license acceptance (pattern from ClientTools.Platform) - if (IsXcodeLicenseAccepted (xcode.Path)) - log.LogInfo ("Xcode license is accepted."); - else - log.LogInfo ("Xcode license may not be accepted. Run 'sudo xcodebuild -license accept'."); - - // Collect platform SDKs - result.Platforms = GetPlatforms (xcode.Path); - } else { - log.LogInfo ("No Xcode installation found."); - } + if (IsXcodeLicenseAccepted ()) + log.LogInfo ("Xcode license is accepted."); + else + log.LogInfo ("Xcode license may not be accepted. Run 'sudo xcodebuild -license accept'."); - // 2. Check Command Line Tools - var clt = new CommandLineTools (log); - result.CommandLineTools = clt.Check (); + result.Platforms = GetPlatforms (xcode.Path); + } else { + log.LogInfo ("No Xcode installation found."); + } - // 3. Check runtimes - var runtimeService = new RuntimeService (log); - result.Runtimes = runtimeService.List (availableOnly: true); + var clt = new CommandLineTools (log); + result.CommandLineTools = clt.Check (); - // 4. Derive overall status - result.DeriveStatus (); + var runtimeService = new RuntimeService (log); + result.Runtimes = runtimeService.List (availableOnly: true); - log.LogInfo ("Environment check complete. Status: {0}.", result.Status); - return result; - } + result.DeriveStatus (); - /// - /// Checks whether the Xcode license has been accepted by running - /// xcodebuild -license check. - /// Pattern from ClientTools.Platform iOSSshCommandsExtensions.CheckXcodeLicenseAsync. - /// - public bool IsXcodeLicenseAccepted (string xcodePath) - { - var xcodebuildPath = Path.Combine (xcodePath, "Contents", "Developer", "usr", "bin", "xcodebuild"); - if (!File.Exists (xcodebuildPath)) - return false; + log.LogInfo ("Environment check complete. Status: {0}.", result.Status); + return result; + } - try { - var (exitCode, _, _) = ProcessUtils.Exec (xcodebuildPath, "-license", "check"); - return exitCode == 0; - } catch (System.ComponentModel.Win32Exception) { - return false; - } + /// + /// Checks whether the Xcode license has been accepted by running + /// xcrun xcodebuild -license check. + /// + public bool IsXcodeLicenseAccepted () + { + try { + var (exitCode, _, _) = ProcessUtils.Exec (XcrunPath, "xcodebuild", "-license", "check"); + return exitCode == 0; + } catch (System.ComponentModel.Win32Exception) { + return false; + } catch (InvalidOperationException) { + return false; } + } - /// - /// Runs xcodebuild -runFirstLaunch to ensure packages are installed. - /// Pattern from ClientTools.Platform iOSSshCommandsExtensions.RunXcodeBuildFirstLaunchAsync. - /// Returns true if the command succeeded. - /// - public bool RunFirstLaunch (string xcodePath) - { - var xcodebuildPath = Path.Combine (xcodePath, "Contents", "Developer", "usr", "bin", "xcodebuild"); - if (!File.Exists (xcodebuildPath)) { - log.LogInfo ("xcodebuild not found at '{0}'.", xcodebuildPath); + /// + /// Runs xcrun xcodebuild -runFirstLaunch to ensure packages are installed. + /// Returns true if the command succeeded. + /// + public bool RunFirstLaunch () + { + try { + log.LogInfo ("Running xcodebuild -runFirstLaunch..."); + var (exitCode, _, stderr) = ProcessUtils.Exec (XcrunPath, "xcodebuild", "-runFirstLaunch"); + if (exitCode != 0) { + log.LogInfo ("xcodebuild -runFirstLaunch failed (exit {0}): {1}", exitCode, stderr.Trim ()); return false; } - try { - log.LogInfo ("Running xcodebuild -runFirstLaunch..."); - var (exitCode, _, stderr) = ProcessUtils.Exec (xcodebuildPath, "-runFirstLaunch"); - if (exitCode != 0) { - log.LogInfo ("xcodebuild -runFirstLaunch failed (exit {0}): {1}", exitCode, stderr.Trim ()); - return false; - } - - log.LogInfo ("xcodebuild -runFirstLaunch completed successfully."); - return true; - } catch (System.ComponentModel.Win32Exception ex) { - log.LogInfo ("Could not run xcodebuild: {0}", ex.Message); - return false; - } + log.LogInfo ("xcodebuild -runFirstLaunch completed successfully."); + return true; + } catch (System.ComponentModel.Win32Exception ex) { + log.LogInfo ("Could not run xcodebuild: {0}", ex.Message); + return false; + } catch (InvalidOperationException ex) { + log.LogInfo ("Could not run xcodebuild: {0}", ex.Message); + return false; } + } - /// - /// Gets the list of available platform SDK directories in the Xcode bundle. - /// - System.Collections.Generic.List GetPlatforms (string xcodePath) - { - var platforms = new System.Collections.Generic.List (); - var platformsDir = Path.Combine (xcodePath, "Contents", "Developer", "Platforms"); - - if (!Directory.Exists (platformsDir)) - return platforms; - - try { - foreach (var dir in Directory.GetDirectories (platformsDir, "*.platform")) { - var name = Path.GetFileNameWithoutExtension (dir); - // Convert "iPhoneOS" to "iOS", "AppleTVOS" to "tvOS", etc. - var friendly = MapPlatformName (name); - if (!platforms.Contains (friendly)) - platforms.Add (friendly); - } - } catch (UnauthorizedAccessException ex) { - log.LogInfo ("Could not read platforms directory: {0}", ex.Message); - } + /// + /// Gets the list of available platform SDK directories in the Xcode bundle. + /// + List GetPlatforms (string xcodePath) + { + var platforms = new List (); + var platformsDir = Path.Combine (xcodePath, "Contents", "Developer", "Platforms"); + if (!Directory.Exists (platformsDir)) return platforms; - } - /// - /// Maps Apple platform directory names to friendly names. - /// - public static string MapPlatformName (string sdkName) - { - switch (sdkName) { - case "iPhoneOS": - case "iPhoneSimulator": - return "iOS"; - case "AppleTVOS": - case "AppleTVSimulator": - return "tvOS"; - case "WatchOS": - case "WatchSimulator": - return "watchOS"; - case "XROS": - case "XRSimulator": - return "visionOS"; - case "MacOSX": - return "macOS"; - default: - return sdkName; + try { + foreach (var dir in Directory.GetDirectories (platformsDir, "*.platform")) { + var name = Path.GetFileNameWithoutExtension (dir); + var friendly = MapPlatformName (name); + if (!platforms.Contains (friendly)) + platforms.Add (friendly); } + } catch (UnauthorizedAccessException ex) { + log.LogInfo ("Could not read platforms directory: {0}", ex.Message); + } + + return platforms; + } + + /// + /// Maps Apple platform directory names to friendly names. + /// + public static string MapPlatformName (string sdkName) + { + switch (sdkName) { + case "iPhoneOS": + case "iPhoneSimulator": + return "iOS"; + case "AppleTVOS": + case "AppleTVSimulator": + return "tvOS"; + case "WatchOS": + case "WatchSimulator": + return "watchOS"; + case "XROS": + case "XRSimulator": + return "visionOS"; + case "MacOSX": + return "macOS"; + default: + return sdkName; } } } diff --git a/Xamarin.MacDev/XcodeManager.cs b/Xamarin.MacDev/XcodeManager.cs index 3883f4c..b466948 100644 --- a/Xamarin.MacDev/XcodeManager.cs +++ b/Xamarin.MacDev/XcodeManager.cs @@ -4,295 +4,293 @@ using System; using System.Collections.Generic; using System.IO; - using Xamarin.MacDev.Models; #nullable enable -namespace Xamarin.MacDev { - - /// - /// Lists Xcode installations, reads their metadata, and supports selecting - /// the active Xcode. Reuses existing for path - /// validation and plist reading, and for shell commands. - /// - public class XcodeManager { +namespace Xamarin.MacDev; - static readonly string XcodeSelectPath = "/usr/bin/xcode-select"; - static readonly string MdfindPath = "/usr/bin/mdfind"; - static readonly string ApplicationsDir = "/Applications"; +/// +/// Lists Xcode installations, reads their metadata, and supports selecting +/// the active Xcode. Reuses existing for path +/// validation and plist reading, and for shell commands. +/// +public class XcodeManager { - readonly ICustomLogger log; + static readonly string XcodeSelectPath = "/usr/bin/xcode-select"; + static readonly string MdfindPath = "/usr/bin/mdfind"; + static readonly string ApplicationsDir = "/Applications"; - public XcodeManager (ICustomLogger log) - { - this.log = log ?? throw new ArgumentNullException (nameof (log)); - } + readonly ICustomLogger log; - /// - /// 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. - /// - public List List () - { - var selectedPath = GetSelectedPath (); - var candidates = FindXcodeApps (); + public XcodeManager (ICustomLogger log) + { + this.log = log ?? throw new ArgumentNullException (nameof (log)); + } - // 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); + /// + /// 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. + /// + public List List () + { + var selectedPath = GetSelectedPath (); + var candidates = FindXcodeApps (); - var results = new List (); - var seen = new HashSet (StringComparer.Ordinal); + // 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); - foreach (var appPath in candidates) { - if (!seen.Add (appPath)) - continue; + var results = new List (); + var seen = new HashSet (StringComparer.Ordinal); - var info = ReadXcodeInfo (appPath); - if (info is null) - continue; + foreach (var appPath in candidates) { + if (!seen.Add (appPath)) + continue; - if (selectedPath is not null && appPath.Equals (selectedPath, StringComparison.Ordinal)) - info.IsSelected = true; + var info = ReadXcodeInfo (appPath); + if (info is null) + continue; - results.Add (info); - } + if (selectedPath is not null && appPath.Equals (selectedPath, StringComparison.Ordinal)) + info.IsSelected = true; - log.LogInfo ("Found {0} Xcode installation(s).", results.Count); - return results; + results.Add (info); } - /// - /// Returns information about the currently selected Xcode, or null if none is selected. - /// - public XcodeInfo? GetSelected () - { - var selectedPath = GetSelectedPath (); - if (selectedPath is null) - return null; + log.LogInfo ("Found {0} Xcode installation(s).", results.Count); + return results; + } - var info = ReadXcodeInfo (selectedPath); - if (info is not null) - info.IsSelected = true; + /// + /// Returns information about the currently selected Xcode, or null if none is selected. + /// + public XcodeInfo? GetSelected () + { + var selectedPath = GetSelectedPath (); + if (selectedPath is null) + return null; - return info; - } + var info = ReadXcodeInfo (selectedPath); + if (info is not null) + info.IsSelected = true; - /// - /// Returns the best available Xcode: the currently selected one, or - /// the highest-versioned installation if none is selected. - /// - public XcodeInfo? GetBest () - { - var all = List (); - if (all.Count == 0) - return null; + return info; + } - 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]; + /// + /// Returns the best available Xcode: the currently selected one, or + /// the highest-versioned installation if none is selected. + /// + 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]; + } + + /// + /// Selects the active Xcode by calling xcode-select -s. + /// Accepts either an Xcode.app bundle path or its Developer directory. + /// Returns true if the command succeeded. + /// Note: this typically requires root privileges (sudo). + /// + 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; } - /// - /// Selects the active Xcode by calling xcode-select -s. - /// Accepts either an Xcode.app bundle path or its Developer directory. - /// Returns true if the command succeeded. - /// Note: this typically requires root privileges (sudo). - /// - 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; - } + if (!File.Exists (XcodeSelectPath)) { + log.LogInfo ("Cannot select Xcode: xcode-select not found."); + return false; + } - if (!File.Exists (XcodeSelectPath)) { - log.LogInfo ("Cannot select Xcode: xcode-select not found."); + 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; } - 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; - } + 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; } + } - /// - /// Returns the canonicalized path of the currently selected Xcode, or null. - /// Strips the /Contents/Developer suffix that xcode-select -p returns. - /// - string? GetSelectedPath () - { - if (!File.Exists (XcodeSelectPath)) + /// + /// Returns the canonicalized path of the currently selected Xcode, or null. + /// Strips the /Contents/Developer suffix that xcode-select -p returns. + /// + string? GetSelectedPath () + { + if (!File.Exists (XcodeSelectPath)) + return null; + + try { + var (exitCode, stdout, _) = ProcessUtils.Exec (XcodeSelectPath, "--print-path"); + if (exitCode != 0) 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; - } + var path = stdout.Trim (); + return CanonicalizeXcodePath (path); + } catch (System.ComponentModel.Win32Exception) { + return null; + } catch (InvalidOperationException) { + return null; } + } - /// - /// Finds Xcode.app bundles via mdfind and /Applications directory listing. - /// Returns deduplicated list of canonical Xcode.app paths. - /// - List FindXcodeApps () - { - var pathSet = new HashSet (StringComparer.Ordinal); + /// + /// Finds Xcode.app bundles via mdfind and /Applications directory listing. + /// Returns deduplicated list of canonical Xcode.app paths. + /// + List FindXcodeApps () + { + var pathSet = new HashSet (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); - } + // 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); } + } 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); + // 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 (pathSet); + return new List (pathSet); + } + + /// + /// Reads Xcode metadata from a .app bundle path. + /// Returns null if the path is not a valid Xcode installation. + /// + 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; } - /// - /// Reads Xcode metadata from a .app bundle path. - /// Returns null if the path is not a valid Xcode installation. - /// - 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); + try { + var versionPlist = PDictionary.FromFile (versionPlistPath); + if (versionPlist is null) { + log.LogInfo ("Skipping '{0}': could not parse 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 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), + }; - 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 ("DTXcode", out var dtXcode)) - info.DTXcode = dtXcode.Value; - } catch (Exception ex) { - log.LogInfo ("Could not read Info.plist for '{0}': {1}", appPath, ex.Message); - } + // 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 ("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; } - } - /// - /// 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. - /// - public static string? CanonicalizeXcodePath (string? path) - { - if (string.IsNullOrEmpty (path)) - return null; + return info; + } catch (Exception ex) { + log.LogInfo ("Could not read Xcode info from '{0}': {1}", appPath, ex.Message); + return null; + } + } - path = path!.TrimEnd ('/'); + /// + /// 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. + /// + public static string? CanonicalizeXcodePath (string? path) + { + if (string.IsNullOrEmpty (path)) + return null; - if (path.EndsWith ("/Contents/Developer", StringComparison.Ordinal)) - path = path.Substring (0, path.Length - "/Contents/Developer".Length); + path = path!.TrimEnd ('/'); - if (!Directory.Exists (path)) - return null; + if (path.EndsWith ("/Contents/Developer", StringComparison.Ordinal)) + path = path.Substring (0, path.Length - "/Contents/Developer".Length); - return path; - } + if (!Directory.Exists (path)) + return null; - /// - /// Parses the output of mdfind into a list of paths. - /// Exported for testing. - /// - public static List ParseMdfindOutput (string? output) - { - var results = new List (); - 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 path; + } + /// + /// Parses the output of mdfind into a list of paths. + /// Exported for testing. + /// + public static List ParseMdfindOutput (string? output) + { + var results = new List (); + 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; + } } -} diff --git a/tests/EnvironmentCheckerTests.cs b/tests/EnvironmentCheckerTests.cs index 7d640d0..6999241 100644 --- a/tests/EnvironmentCheckerTests.cs +++ b/tests/EnvironmentCheckerTests.cs @@ -2,27 +2,25 @@ // Licensed under the MIT License. using NUnit.Framework; - using Xamarin.MacDev; -namespace Tests { +namespace tests; - [TestFixture] - public class EnvironmentCheckerTests { +[TestFixture] +public class EnvironmentCheckerTests { - [TestCase ("iPhoneOS", "iOS")] - [TestCase ("iPhoneSimulator", "iOS")] - [TestCase ("AppleTVOS", "tvOS")] - [TestCase ("AppleTVSimulator", "tvOS")] - [TestCase ("WatchOS", "watchOS")] - [TestCase ("WatchSimulator", "watchOS")] - [TestCase ("XROS", "visionOS")] - [TestCase ("XRSimulator", "visionOS")] - [TestCase ("MacOSX", "macOS")] - [TestCase ("UnknownPlatform", "UnknownPlatform")] - public void MapPlatformName_MapsCorrectly (string input, string expected) - { - Assert.That (EnvironmentChecker.MapPlatformName (input), Is.EqualTo (expected)); - } + [TestCase ("iPhoneOS", "iOS")] + [TestCase ("iPhoneSimulator", "iOS")] + [TestCase ("AppleTVOS", "tvOS")] + [TestCase ("AppleTVSimulator", "tvOS")] + [TestCase ("WatchOS", "watchOS")] + [TestCase ("WatchSimulator", "watchOS")] + [TestCase ("XROS", "visionOS")] + [TestCase ("XRSimulator", "visionOS")] + [TestCase ("MacOSX", "macOS")] + [TestCase ("UnknownPlatform", "UnknownPlatform")] + public void MapPlatformName_MapsCorrectly (string input, string expected) + { + Assert.That (EnvironmentChecker.MapPlatformName (input), Is.EqualTo (expected)); } } From 7b36b9acd5f3354a69825793d47920600e65815b Mon Sep 17 00:00:00 2001 From: GitHub Actions Autoformatter Date: Tue, 3 Mar 2026 17:22:56 +0000 Subject: [PATCH 5/8] Auto-format source code --- Xamarin.MacDev/XcodeManager.cs | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/Xamarin.MacDev/XcodeManager.cs b/Xamarin.MacDev/XcodeManager.cs index b466948..15d0290 100644 --- a/Xamarin.MacDev/XcodeManager.cs +++ b/Xamarin.MacDev/XcodeManager.cs @@ -25,7 +25,7 @@ public class XcodeManager { public XcodeManager (ICustomLogger log) { - this.log = log ?? throw new ArgumentNullException (nameof (log)); + this.log = log ?? throw new ArgumentNullException (nameof (log)); } /// @@ -293,4 +293,4 @@ public static List ParseMdfindOutput (string? output) return results; } - } +} From bc7ac20df71bb8e89b201b2f817dc451fc443633 Mon Sep 17 00:00:00 2001 From: Rui Marinho Date: Thu, 5 Mar 2026 12:29:50 +0000 Subject: [PATCH 6/8] Address PR #160 review feedback - Revert CommandLineTools.cs to match main (whitespace-only changes belong in separate PR) - Remove unused 'using System.Linq' from EnvironmentChecker.cs - Wrap CommandLineTools.Check() and RuntimeService.List() in try/catch for resilience - Add EnvironmentChecker smoke tests (Check, IsXcodeLicenseAccepted, RunFirstLaunch) - Add constructor null-check test and MapPlatformName edge case tests Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> --- Xamarin.MacDev/CommandLineTools.cs | 198 ++++++++++++++------------- Xamarin.MacDev/EnvironmentChecker.cs | 17 ++- tests/EnvironmentCheckerTests.cs | 55 ++++++++ 3 files changed, 168 insertions(+), 102 deletions(-) diff --git a/Xamarin.MacDev/CommandLineTools.cs b/Xamarin.MacDev/CommandLineTools.cs index d78befe..1e00386 100644 --- a/Xamarin.MacDev/CommandLineTools.cs +++ b/Xamarin.MacDev/CommandLineTools.cs @@ -3,128 +3,132 @@ using System; using System.IO; + using Xamarin.MacDev.Models; #nullable enable -namespace Xamarin.MacDev; - -/// -/// Detects and reports on the Xcode Command Line Tools installation. -/// Follows the same instance-based, ICustomLogger pattern as XcodeLocator. -/// -public class CommandLineTools { - - static readonly string XcodeSelectPath = "/usr/bin/xcode-select"; - static readonly string PkgutilPath = "/usr/bin/pkgutil"; - static readonly string CltPkgId = "com.apple.pkg.CLTools_Executables"; - static readonly string DefaultCltPath = "/Library/Developer/CommandLineTools"; - - readonly ICustomLogger log; - - public CommandLineTools (ICustomLogger log) - { - this.log = log ?? throw new ArgumentNullException (nameof (log)); - } +namespace Xamarin.MacDev { /// - /// Checks whether the Xcode Command Line Tools are installed and returns their info. + /// Detects and reports on the Xcode Command Line Tools installation. + /// Follows the same instance-based, ICustomLogger pattern as XcodeLocator. /// - public CommandLineToolsInfo Check () - { - var info = new CommandLineToolsInfo (); + public class CommandLineTools { - var cltPath = GetCommandLineToolsPath (); - if (cltPath is null) { - log.LogInfo ("Command Line Tools are not installed (path not found)."); - return info; - } + static readonly string XcodeSelectPath = "/usr/bin/xcode-select"; + static readonly string PkgutilPath = "/usr/bin/pkgutil"; + static readonly string CltPkgId = "com.apple.pkg.CLTools_Executables"; + static readonly string DefaultCltPath = "/Library/Developer/CommandLineTools"; - info.Path = cltPath; - - var version = GetVersionFromPkgutil (); - if (version is not null) { - info.Version = version; - info.IsInstalled = true; - log.LogInfo ("Command Line Tools {0} found at '{1}'.", version, cltPath); - } else { - info.IsInstalled = Directory.Exists (Path.Combine (cltPath, "usr", "bin")); - if (info.IsInstalled) - log.LogInfo ("Command Line Tools found at '{0}' (version unknown).", cltPath); - else - log.LogInfo ("Command Line Tools directory exists at '{0}' but appears incomplete.", cltPath); + readonly ICustomLogger log; + + public CommandLineTools (ICustomLogger log) + { + this.log = log ?? throw new ArgumentNullException (nameof (log)); } - return info; - } + /// + /// Checks whether the Xcode Command Line Tools are installed and returns their info. + /// + public CommandLineToolsInfo Check () + { + var info = new CommandLineToolsInfo (); + + // First check if the CLT directory exists + var cltPath = GetCommandLineToolsPath (); + if (cltPath is null) { + log.LogInfo ("Command Line Tools are not installed (path not found)."); + return info; + } - /// - /// Returns the Command Line Tools install path, or null if not found. - /// Uses xcode-select -p first, falls back to the well-known default path. - /// - string? GetCommandLineToolsPath () - { - if (File.Exists (XcodeSelectPath)) { - try { - var (exitCode, stdout, _) = ProcessUtils.Exec (XcodeSelectPath, "--print-path"); - if (exitCode == 0) { - var path = stdout.Trim (); - if (path.Contains ("CommandLineTools") && Directory.Exists (path)) - return path; - } - } catch (System.ComponentModel.Win32Exception ex) { - log.LogInfo ("Could not run xcode-select: {0}", ex.Message); - } catch (InvalidOperationException ex) { - log.LogInfo ("Could not run xcode-select: {0}", ex.Message); + info.Path = cltPath; + + // Get version from pkgutil + var version = GetVersionFromPkgutil (); + if (version is not null) { + info.Version = version; + info.IsInstalled = true; + log.LogInfo ("Command Line Tools {0} found at '{1}'.", version, cltPath); + } else { + // Directory exists but pkgutil doesn't report it — partial install + info.IsInstalled = Directory.Exists (Path.Combine (cltPath, "usr", "bin")); + if (info.IsInstalled) + log.LogInfo ("Command Line Tools found at '{0}' (version unknown).", cltPath); + else + log.LogInfo ("Command Line Tools directory exists at '{0}' but appears incomplete.", cltPath); } + + return info; } - if (Directory.Exists (DefaultCltPath)) - return DefaultCltPath; + /// + /// Returns the Command Line Tools install path, or null if not found. + /// Uses xcode-select -p first, falls back to the well-known default path. + /// + string? GetCommandLineToolsPath () + { + // Try xcode-select -p — if it returns a CLT path (not Xcode), use it + if (File.Exists (XcodeSelectPath)) { + try { + var (exitCode, stdout, _) = ProcessUtils.Exec (XcodeSelectPath, "--print-path"); + if (exitCode == 0) { + var path = stdout.Trim (); + if (path.Contains ("CommandLineTools") && Directory.Exists (path)) { + // xcode-select points to CLT (e.g. /Library/Developer/CommandLineTools) + return path; + } + } + } catch (System.ComponentModel.Win32Exception ex) { + log.LogInfo ("Could not run xcode-select: {0}", ex.Message); + } + } - return null; - } + // Fall back to the default well-known path + if (Directory.Exists (DefaultCltPath)) + return DefaultCltPath; - /// - /// Queries pkgutil for the CLT package version. - /// Returns the version string or null if not installed. - /// - internal string? GetVersionFromPkgutil () - { - if (!File.Exists (PkgutilPath)) return null; + } - try { - var (exitCode, stdout, _) = ProcessUtils.Exec (PkgutilPath, "--pkg-info", CltPkgId); - if (exitCode != 0) + /// + /// Queries pkgutil for the CLT package version. + /// Returns the version string or null if not installed. + /// + internal string? GetVersionFromPkgutil () + { + if (!File.Exists (PkgutilPath)) return null; - return ParsePkgutilVersion (stdout); - } catch (System.ComponentModel.Win32Exception ex) { - log.LogInfo ("Could not run pkgutil: {0}", ex.Message); - return null; - } catch (InvalidOperationException ex) { - log.LogInfo ("Could not run pkgutil: {0}", ex.Message); - return null; + try { + var (exitCode, stdout, _) = ProcessUtils.Exec (PkgutilPath, "--pkg-info", CltPkgId); + if (exitCode != 0) + return null; + + return ParsePkgutilVersion (stdout); + } catch (System.ComponentModel.Win32Exception ex) { + log.LogInfo ("Could not run pkgutil: {0}", ex.Message); + return null; + } } - } - /// - /// Parses the "version: ..." line from pkgutil --pkg-info output. - /// - public static string? ParsePkgutilVersion (string pkgutilOutput) - { - if (string.IsNullOrEmpty (pkgutilOutput)) - return null; + /// + /// Parses the "version: ..." line from pkgutil --pkg-info output. + /// + public static string? ParsePkgutilVersion (string pkgutilOutput) + { + if (string.IsNullOrEmpty (pkgutilOutput)) + return null; - foreach (var rawLine in pkgutilOutput.Split ('\n')) { - var line = rawLine.Trim (); - if (line.StartsWith ("version:", StringComparison.Ordinal)) { - var version = line.Substring ("version:".Length).Trim (); - return string.IsNullOrEmpty (version) ? null : version; + foreach (var rawLine in pkgutilOutput.Split ('\n')) { + var line = rawLine.Trim (); + if (line.StartsWith ("version:", StringComparison.Ordinal)) { + var version = line.Substring ("version:".Length).Trim (); + return string.IsNullOrEmpty (version) ? null : version; + } } - } - return null; + return null; + } } } diff --git a/Xamarin.MacDev/EnvironmentChecker.cs b/Xamarin.MacDev/EnvironmentChecker.cs index 1b95d4f..9b8c90b 100644 --- a/Xamarin.MacDev/EnvironmentChecker.cs +++ b/Xamarin.MacDev/EnvironmentChecker.cs @@ -4,7 +4,6 @@ using System; using System.Collections.Generic; using System.IO; -using System.Linq; using Xamarin.MacDev.Models; #nullable enable @@ -51,11 +50,19 @@ public EnvironmentCheckResult Check () log.LogInfo ("No Xcode installation found."); } - var clt = new CommandLineTools (log); - result.CommandLineTools = clt.Check (); + try { + var clt = new CommandLineTools (log); + result.CommandLineTools = clt.Check (); + } catch (Exception ex) { + log.LogInfo ("Could not check Command Line Tools: {0}", ex.Message); + } - var runtimeService = new RuntimeService (log); - result.Runtimes = runtimeService.List (availableOnly: true); + try { + var runtimeService = new RuntimeService (log); + result.Runtimes = runtimeService.List (availableOnly: true); + } catch (Exception ex) { + log.LogInfo ("Could not check runtimes: {0}", ex.Message); + } result.DeriveStatus (); diff --git a/tests/EnvironmentCheckerTests.cs b/tests/EnvironmentCheckerTests.cs index 6999241..3a85ca1 100644 --- a/tests/EnvironmentCheckerTests.cs +++ b/tests/EnvironmentCheckerTests.cs @@ -1,14 +1,56 @@ // Copyright (c) Microsoft Corporation. // Licensed under the MIT License. +using System; using NUnit.Framework; using Xamarin.MacDev; +using Xamarin.MacDev.Models; namespace tests; [TestFixture] public class EnvironmentCheckerTests { + [Test] + public void Constructor_ThrowsOnNullLogger () + { + Assert.Throws (() => new EnvironmentChecker (null!)); + } + + [Test] + [Platform ("MacOsX")] + public void Check_DoesNotThrow () + { + var checker = new EnvironmentChecker (ConsoleLogger.Instance); + Assert.DoesNotThrow (() => checker.Check ()); + } + + [Test] + [Platform ("MacOsX")] + public void Check_ReturnsValidResult () + { + var checker = new EnvironmentChecker (ConsoleLogger.Instance); + var result = checker.Check (); + Assert.That (result, Is.Not.Null); + Assert.That (result.Status, Is.AnyOf (EnvironmentStatus.Ok, EnvironmentStatus.Partial, EnvironmentStatus.Missing)); + } + + [Test] + [Platform ("MacOsX")] + public void IsXcodeLicenseAccepted_DoesNotThrow () + { + var checker = new EnvironmentChecker (ConsoleLogger.Instance); + Assert.DoesNotThrow (() => checker.IsXcodeLicenseAccepted ()); + } + + [Test] + [Platform ("MacOsX")] + public void RunFirstLaunch_DoesNotThrow () + { + var checker = new EnvironmentChecker (ConsoleLogger.Instance); + Assert.DoesNotThrow (() => checker.RunFirstLaunch ()); + } + [TestCase ("iPhoneOS", "iOS")] [TestCase ("iPhoneSimulator", "iOS")] [TestCase ("AppleTVOS", "tvOS")] @@ -23,4 +65,17 @@ public void MapPlatformName_MapsCorrectly (string input, string expected) { Assert.That (EnvironmentChecker.MapPlatformName (input), Is.EqualTo (expected)); } + + [Test] + public void MapPlatformName_ReturnsSameForEmpty () + { + Assert.That (EnvironmentChecker.MapPlatformName (""), Is.EqualTo ("")); + } + + [Test] + public void MapPlatformName_IsCaseSensitive () + { + Assert.That (EnvironmentChecker.MapPlatformName ("iphoneos"), Is.EqualTo ("iphoneos")); + Assert.That (EnvironmentChecker.MapPlatformName ("MACOSX"), Is.EqualTo ("MACOSX")); + } } From ab137b1d481dcf12e750923d9e10b22db1b8b20d Mon Sep 17 00:00:00 2001 From: Rui Marinho Date: Thu, 5 Mar 2026 12:45:05 +0000 Subject: [PATCH 7/8] Refactor EnvironmentChecker for testability, add unit tests - Extract virtual factory methods (GetBestXcode, CheckCommandLineTools, ListRuntimes, GetPlatforms) so Check() aggregation logic can be tested without invoking external tools - Extract MapDirectoryNamesToPlatforms as public static (testable core of GetPlatforms without filesystem access) - Add TestableEnvironmentChecker subclass overriding virtual methods - Add 7 new Check() aggregation tests: no Xcode, no CLT, no runtimes, everything present, CLT throws, runtimes throws, no-Xcode skips platforms - Add 4 MapDirectoryNamesToPlatforms tests: dedup, all platforms, empty, unknown - Total: 24 tests (up from 13), all passing on both net472 and net10.0 Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> --- Xamarin.MacDev/EnvironmentChecker.cs | 75 ++++++++--- tests/EnvironmentCheckerTests.cs | 178 +++++++++++++++++++++++++++ 2 files changed, 233 insertions(+), 20 deletions(-) diff --git a/Xamarin.MacDev/EnvironmentChecker.cs b/Xamarin.MacDev/EnvironmentChecker.cs index 9b8c90b..022ca07 100644 --- a/Xamarin.MacDev/EnvironmentChecker.cs +++ b/Xamarin.MacDev/EnvironmentChecker.cs @@ -33,33 +33,29 @@ public EnvironmentCheckResult Check () { var result = new EnvironmentCheckResult (); - var xcodeManager = new XcodeManager (log); - var xcode = xcodeManager.GetBest (); - result.Xcode = xcode; + result.Xcode = GetBestXcode (); - if (xcode is not null) { - log.LogInfo ("Xcode {0} found at '{1}'.", xcode.Version, xcode.Path); + if (result.Xcode is not null) { + log.LogInfo ("Xcode {0} found at '{1}'.", result.Xcode.Version, result.Xcode.Path); if (IsXcodeLicenseAccepted ()) log.LogInfo ("Xcode license is accepted."); else log.LogInfo ("Xcode license may not be accepted. Run 'sudo xcodebuild -license accept'."); - result.Platforms = GetPlatforms (xcode.Path); + result.Platforms = GetPlatforms (result.Xcode.Path); } else { log.LogInfo ("No Xcode installation found."); } try { - var clt = new CommandLineTools (log); - result.CommandLineTools = clt.Check (); + result.CommandLineTools = CheckCommandLineTools (); } catch (Exception ex) { log.LogInfo ("Could not check Command Line Tools: {0}", ex.Message); } try { - var runtimeService = new RuntimeService (log); - result.Runtimes = runtimeService.List (availableOnly: true); + result.Runtimes = ListRuntimes (); } catch (Exception ex) { log.LogInfo ("Could not check runtimes: {0}", ex.Message); } @@ -70,11 +66,38 @@ public EnvironmentCheckResult Check () return result; } + /// + /// Returns the best available Xcode installation, or null if none found. + /// + protected virtual XcodeInfo? GetBestXcode () + { + var xcodeManager = new XcodeManager (log); + return xcodeManager.GetBest (); + } + + /// + /// Checks Command Line Tools installation status. + /// + protected virtual CommandLineToolsInfo CheckCommandLineTools () + { + var clt = new CommandLineTools (log); + return clt.Check (); + } + + /// + /// Lists available simulator runtimes. + /// + protected virtual List ListRuntimes () + { + var runtimeService = new RuntimeService (log); + return runtimeService.List (availableOnly: true); + } + /// /// Checks whether the Xcode license has been accepted by running /// xcrun xcodebuild -license check. /// - public bool IsXcodeLicenseAccepted () + public virtual bool IsXcodeLicenseAccepted () { try { var (exitCode, _, _) = ProcessUtils.Exec (XcrunPath, "xcodebuild", "-license", "check"); @@ -114,25 +137,37 @@ public bool RunFirstLaunch () /// /// Gets the list of available platform SDK directories in the Xcode bundle. /// - List GetPlatforms (string xcodePath) + protected virtual List GetPlatforms (string xcodePath) { - var platforms = new List (); var platformsDir = Path.Combine (xcodePath, "Contents", "Developer", "Platforms"); if (!Directory.Exists (platformsDir)) - return platforms; + return new List (); try { - foreach (var dir in Directory.GetDirectories (platformsDir, "*.platform")) { - var name = Path.GetFileNameWithoutExtension (dir); - var friendly = MapPlatformName (name); - if (!platforms.Contains (friendly)) - platforms.Add (friendly); - } + var directoryNames = new List (); + foreach (var dir in Directory.GetDirectories (platformsDir, "*.platform")) + directoryNames.Add (Path.GetFileNameWithoutExtension (dir)); + + return MapDirectoryNamesToPlatforms (directoryNames); } catch (UnauthorizedAccessException ex) { log.LogInfo ("Could not read platforms directory: {0}", ex.Message); + return new List (); } + } + /// + /// Maps a list of Apple platform directory names (e.g. "iPhoneOS", "MacOSX") + /// to deduplicated friendly names (e.g. "iOS", "macOS"). + /// + public static List MapDirectoryNamesToPlatforms (IEnumerable directoryNames) + { + var platforms = new List (); + foreach (var name in directoryNames) { + var friendly = MapPlatformName (name); + if (!platforms.Contains (friendly)) + platforms.Add (friendly); + } return platforms; } diff --git a/tests/EnvironmentCheckerTests.cs b/tests/EnvironmentCheckerTests.cs index 3a85ca1..ec722f9 100644 --- a/tests/EnvironmentCheckerTests.cs +++ b/tests/EnvironmentCheckerTests.cs @@ -2,21 +2,163 @@ // Licensed under the MIT License. using System; +using System.Collections.Generic; using NUnit.Framework; using Xamarin.MacDev; using Xamarin.MacDev.Models; +#nullable enable + namespace tests; [TestFixture] public class EnvironmentCheckerTests { + /// + /// Subclass that overrides external dependencies so Check() can be + /// unit-tested without invoking Xcode, xcode-select, or simctl. + /// + class TestableEnvironmentChecker : EnvironmentChecker { + + public XcodeInfo? XcodeResult { get; set; } + public CommandLineToolsInfo CltResult { get; set; } = new CommandLineToolsInfo (); + public List RuntimesResult { get; set; } = new List (); + public bool LicenseAccepted { get; set; } = true; + public List PlatformsResult { get; set; } = new List (); + public bool ThrowOnClt { get; set; } + public bool ThrowOnRuntimes { get; set; } + + public TestableEnvironmentChecker () : base (ConsoleLogger.Instance) { } + + protected override XcodeInfo? GetBestXcode () => XcodeResult; + + protected override CommandLineToolsInfo CheckCommandLineTools () + { + if (ThrowOnClt) + throw new InvalidOperationException ("simulated CLT failure"); + return CltResult; + } + + protected override List ListRuntimes () + { + if (ThrowOnRuntimes) + throw new InvalidOperationException ("simulated runtime failure"); + return RuntimesResult; + } + + public override bool IsXcodeLicenseAccepted () => LicenseAccepted; + + protected override List GetPlatforms (string xcodePath) => PlatformsResult; + } + + // ── Constructor ── + [Test] public void Constructor_ThrowsOnNullLogger () { Assert.Throws (() => new EnvironmentChecker (null!)); } + // ── Check() aggregation tests ── + + [Test] + public void Check_NoXcode_ReturnsMissing () + { + var checker = new TestableEnvironmentChecker { + XcodeResult = null, + CltResult = new CommandLineToolsInfo { IsInstalled = true }, + }; + var result = checker.Check (); + Assert.That (result.Xcode, Is.Null); + Assert.That (result.Status, Is.EqualTo (EnvironmentStatus.Missing)); + } + + [Test] + public void Check_XcodeAndClt_NoRuntimes_ReturnsPartial () + { + var checker = new TestableEnvironmentChecker { + XcodeResult = new XcodeInfo { Path = "/Applications/Xcode.app", Version = new Version (16, 2) }, + CltResult = new CommandLineToolsInfo { IsInstalled = true }, + RuntimesResult = new List (), + PlatformsResult = new List { "iOS", "macOS" }, + }; + var result = checker.Check (); + Assert.That (result.Status, Is.EqualTo (EnvironmentStatus.Partial)); + Assert.That (result.Xcode, Is.Not.Null); + Assert.That (result.CommandLineTools.IsInstalled, Is.True); + Assert.That (result.Platforms, Has.Count.EqualTo (2)); + } + + [Test] + public void Check_EverythingPresent_ReturnsOk () + { + var checker = new TestableEnvironmentChecker { + XcodeResult = new XcodeInfo { Path = "/Applications/Xcode.app", Version = new Version (16, 2) }, + CltResult = new CommandLineToolsInfo { IsInstalled = true }, + RuntimesResult = new List { + new SimulatorRuntimeInfo { Platform = "iOS", Version = "18.2", IsAvailable = true }, + }, + PlatformsResult = new List { "iOS", "macOS" }, + }; + var result = checker.Check (); + Assert.That (result.Status, Is.EqualTo (EnvironmentStatus.Ok)); + } + + [Test] + public void Check_CltThrows_DoesNotCrash_ReturnsMissing () + { + var checker = new TestableEnvironmentChecker { + XcodeResult = new XcodeInfo { Path = "/Applications/Xcode.app", Version = new Version (16, 2) }, + ThrowOnClt = true, + RuntimesResult = new List { + new SimulatorRuntimeInfo { Platform = "iOS", Version = "18.2", IsAvailable = true }, + }, + }; + var result = checker.Check (); + Assert.That (result.CommandLineTools.IsInstalled, Is.False); + Assert.That (result.Status, Is.EqualTo (EnvironmentStatus.Missing)); + } + + [Test] + public void Check_RuntimesThrows_DoesNotCrash () + { + var checker = new TestableEnvironmentChecker { + XcodeResult = new XcodeInfo { Path = "/Applications/Xcode.app", Version = new Version (16, 2) }, + CltResult = new CommandLineToolsInfo { IsInstalled = true }, + ThrowOnRuntimes = true, + }; + var result = checker.Check (); + Assert.That (result.Runtimes, Is.Empty); + Assert.That (result.Status, Is.EqualTo (EnvironmentStatus.Partial)); + } + + [Test] + public void Check_NoClt_ReturnsMissing () + { + var checker = new TestableEnvironmentChecker { + XcodeResult = new XcodeInfo { Path = "/Applications/Xcode.app", Version = new Version (16, 2) }, + CltResult = new CommandLineToolsInfo { IsInstalled = false }, + RuntimesResult = new List { + new SimulatorRuntimeInfo { Platform = "iOS", Version = "18.2", IsAvailable = true }, + }, + }; + var result = checker.Check (); + Assert.That (result.Status, Is.EqualTo (EnvironmentStatus.Missing)); + } + + [Test] + public void Check_NoXcode_SkipsPlatformsAndLicense () + { + var checker = new TestableEnvironmentChecker { + XcodeResult = null, + PlatformsResult = new List { "iOS" }, + }; + var result = checker.Check (); + Assert.That (result.Platforms, Is.Empty); + } + + // ── Smoke tests (macOS only) ── + [Test] [Platform ("MacOsX")] public void Check_DoesNotThrow () @@ -51,6 +193,42 @@ public void RunFirstLaunch_DoesNotThrow () Assert.DoesNotThrow (() => checker.RunFirstLaunch ()); } + // ── MapDirectoryNamesToPlatforms ── + + [Test] + public void MapDirectoryNamesToPlatforms_DeduplicatesIOS () + { + var result = EnvironmentChecker.MapDirectoryNamesToPlatforms ( + new [] { "iPhoneOS", "iPhoneSimulator", "MacOSX" }); + Assert.That (result, Is.EqualTo (new List { "iOS", "macOS" })); + } + + [Test] + public void MapDirectoryNamesToPlatforms_AllApplePlatforms () + { + var result = EnvironmentChecker.MapDirectoryNamesToPlatforms ( + new [] { "iPhoneOS", "iPhoneSimulator", "AppleTVOS", "AppleTVSimulator", + "WatchOS", "WatchSimulator", "XROS", "XRSimulator", "MacOSX" }); + Assert.That (result, Is.EqualTo (new List { "iOS", "tvOS", "watchOS", "visionOS", "macOS" })); + } + + [Test] + public void MapDirectoryNamesToPlatforms_Empty () + { + var result = EnvironmentChecker.MapDirectoryNamesToPlatforms (Array.Empty ()); + Assert.That (result, Is.Empty); + } + + [Test] + public void MapDirectoryNamesToPlatforms_UnknownPassedThrough () + { + var result = EnvironmentChecker.MapDirectoryNamesToPlatforms ( + new [] { "DriverKit", "MacOSX" }); + Assert.That (result, Is.EqualTo (new List { "DriverKit", "macOS" })); + } + + // ── MapPlatformName ── + [TestCase ("iPhoneOS", "iOS")] [TestCase ("iPhoneSimulator", "iOS")] [TestCase ("AppleTVOS", "tvOS")] From 9d2f1eda7a44648ffabaa79c955b53ea6a0ae95d Mon Sep 17 00:00:00 2001 From: GitHub Actions Autoformatter Date: Thu, 5 Mar 2026 12:45:37 +0000 Subject: [PATCH 8/8] Auto-format source code --- tests/EnvironmentCheckerTests.cs | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/tests/EnvironmentCheckerTests.cs b/tests/EnvironmentCheckerTests.cs index ec722f9..a0b1c48 100644 --- a/tests/EnvironmentCheckerTests.cs +++ b/tests/EnvironmentCheckerTests.cs @@ -208,7 +208,7 @@ public void MapDirectoryNamesToPlatforms_AllApplePlatforms () { var result = EnvironmentChecker.MapDirectoryNamesToPlatforms ( new [] { "iPhoneOS", "iPhoneSimulator", "AppleTVOS", "AppleTVSimulator", - "WatchOS", "WatchSimulator", "XROS", "XRSimulator", "MacOSX" }); + "WatchOS", "WatchSimulator", "XROS", "XRSimulator", "MacOSX" }); Assert.That (result, Is.EqualTo (new List { "iOS", "tvOS", "watchOS", "visionOS", "macOS" })); }