diff --git a/Xamarin.MacDev/AppleSdkSettings.cs b/Xamarin.MacDev/AppleSdkSettings.cs
index 953d4b3..685b62f 100644
--- a/Xamarin.MacDev/AppleSdkSettings.cs
+++ b/Xamarin.MacDev/AppleSdkSettings.cs
@@ -30,6 +30,7 @@
using System.IO;
namespace Xamarin.MacDev {
+ [Obsolete ("Use 'XcodeLocator' instead, this class keeps static state, which causes various problems when the state isn't updated.")]
public static class AppleSdkSettings {
static readonly string SettingsPath;
@@ -104,23 +105,11 @@ public static void SetConfiguredSdkLocation (string location)
public static string GetConfiguredSdkLocation ()
{
- PDictionary plist = null;
- PString value;
-
- try {
- if (File.Exists (SettingsPath))
- plist = PDictionary.FromFile (SettingsPath, out var _);
- } catch (FileNotFoundException) {
- }
-
- // First try the configured location in Visual Studio
- if (plist != null && plist.TryGetValue ("AppleSdkRoot", out value) && !string.IsNullOrEmpty (value?.Value)) {
- LoggingService.LogInfo (string.Format ("An Xcode location was found in the file '{0}': {1}", SettingsPath, value.Value));
- return value.Value;
- }
+ if (XcodeLocator.TryReadSettingsPath (LoggingServiceLogger.Instance, SettingsPath, out var path))
+ return path;
// Then check the system's default Xcode
- if (TryGetSystemXcode (out var path))
+ if (TryGetSystemXcode (out path))
return path;
// Finally return the hardcoded default
@@ -143,14 +132,11 @@ static void SetInvalid ()
static AppleSdkSettings ()
{
- var home = Environment.GetFolderPath (Environment.SpecialFolder.UserProfile);
-
- SettingsPath = Path.Combine (home, "Library", "Preferences", "maui", "Settings.plist");
-
- if (!File.Exists (SettingsPath)) {
- var oldSettings = Path.Combine (home, "Library", "Preferences", "Xamarin", "Settings.plist");
- if (File.Exists (oldSettings))
- SettingsPath = oldSettings;
+ foreach (var path in XcodeLocator.SettingsPathCandidates) {
+ if (!File.Exists (path))
+ continue;
+ SettingsPath = path;
+ break;
}
Directory.CreateDirectory (Path.GetDirectoryName (SettingsPath));
@@ -160,37 +146,7 @@ static AppleSdkSettings ()
public static bool TryGetSystemXcode (out string path)
{
- path = null;
- if (!File.Exists ("/usr/bin/xcode-select"))
- return false;
-
- try {
- using var process = new Process ();
- process.StartInfo.FileName = "/usr/bin/xcode-select";
- process.StartInfo.Arguments = "--print-path";
- process.StartInfo.RedirectStandardOutput = true;
- process.StartInfo.UseShellExecute = false;
- process.Start ();
- var stdout = process.StandardOutput.ReadToEnd ();
- process.WaitForExit ();
-
- stdout = stdout.Trim ();
- if (Directory.Exists (stdout)) {
- if (stdout.EndsWith ("/Contents/Developer", StringComparison.Ordinal))
- stdout = stdout.Substring (0, stdout.Length - "/Contents/Developer".Length);
-
- path = stdout;
- LoggingService.LogInfo (string.Format ("Using the Xcode location configured for this system (found using 'xcode-select -p'): {0}", path));
- return true;
- }
-
- LoggingService.LogInfo ("The system's Xcode location {0} does not exist", stdout);
-
- return false;
- } catch (Exception e) {
- LoggingService.LogInfo ("Could not get the system's Xcode location: {0}", e);
- return false;
- }
+ return XcodeLocator.TryGetSystemXcode (LoggingServiceLogger.Instance, out path);
}
public static void Init ()
diff --git a/Xamarin.MacDev/LoggingService.cs b/Xamarin.MacDev/LoggingService.cs
index 3ab1c3d..b91ea48 100644
--- a/Xamarin.MacDev/LoggingService.cs
+++ b/Xamarin.MacDev/LoggingService.cs
@@ -72,7 +72,63 @@ public static void LogDebug (string messageFormat, params object [] args)
public interface ICustomLogger {
void LogError (string message, Exception ex);
void LogWarning (string messageFormat, params object [] args);
- void LogInfo (string messageFormat, object [] args);
+ void LogInfo (string messageFormat, params object [] args);
void LogDebug (string messageFormat, params object [] args);
}
+
+#nullable enable
+ // This is a logger that prints to Console.[Error.]WriteLine.
+ public class ConsoleLogger : ICustomLogger {
+ public static ConsoleLogger Instance = new ();
+
+ public void LogError (string message, Exception? ex)
+ {
+ if (ex is null) {
+ Console.Error.WriteLine ($"Error: {message}");
+ } else {
+ Console.Error.WriteLine ($"Error: {message} ({ex})");
+ }
+ }
+
+ public void LogWarning (string messageFormat, params object [] args)
+ {
+ Console.WriteLine ("Warning: " + messageFormat, args);
+ }
+
+ public void LogInfo (string messageFormat, params object [] args)
+ {
+ Console.WriteLine ("Info: " + messageFormat, args);
+ }
+
+ public void LogDebug (string messageFormat, params object [] args)
+ {
+ Console.WriteLine ("Debug: " + messageFormat, args);
+ }
+ }
+
+ // This is a logger that just calls the static LoggingService class.
+ // To be used only until all code can switch away from static state.
+ public class LoggingServiceLogger : ICustomLogger {
+ public static LoggingServiceLogger Instance = new ();
+
+ public void LogError (string message, Exception? ex)
+ {
+ LoggingService.LogError (message, ex);
+ }
+
+ public void LogWarning (string messageFormat, params object [] args)
+ {
+ LoggingService.LogWarning (messageFormat, args);
+ }
+
+ public void LogInfo (string messageFormat, params object [] args)
+ {
+ LoggingService.LogInfo (messageFormat, args);
+ }
+
+ public void LogDebug (string messageFormat, params object [] args)
+ {
+ LoggingService.LogDebug (messageFormat, args);
+ }
+ }
}
diff --git a/Xamarin.MacDev/PathUtils.cs b/Xamarin.MacDev/PathUtils.cs
new file mode 100644
index 0000000..34bc51d
--- /dev/null
+++ b/Xamarin.MacDev/PathUtils.cs
@@ -0,0 +1,80 @@
+using System;
+using System.IO;
+using System.Runtime.InteropServices;
+
+#nullable enable
+
+namespace Xamarin.MacDev {
+ internal static class PathUtils {
+ struct Timespec {
+ public IntPtr tv_sec;
+ public IntPtr tv_nsec;
+ }
+
+ struct Stat { /* when _DARWIN_FEATURE_64_BIT_INODE is defined */
+ public uint st_dev;
+ public ushort st_mode;
+ public ushort st_nlink;
+ public ulong st_ino;
+ public uint st_uid;
+ public uint st_gid;
+ public uint st_rdev;
+ public Timespec st_atimespec;
+ public Timespec st_mtimespec;
+ public Timespec st_ctimespec;
+ public Timespec st_birthtimespec;
+ public ulong st_size;
+ public ulong st_blocks;
+ public uint st_blksize;
+ public uint st_flags;
+ public uint st_gen;
+ public uint st_lspare;
+ public ulong st_qspare_1;
+ public ulong st_qspare_2;
+ }
+
+ [DllImport ("/usr/lib/libc.dylib", EntryPoint = "lstat$INODE64", SetLastError = true)]
+ static extern int lstat_x64 (string file_name, out Stat buf);
+
+ [DllImport ("/usr/lib/libc.dylib", EntryPoint = "lstat", SetLastError = true)]
+ static extern int lstat_arm64 (string file_name, out Stat buf);
+
+ static int lstat (string path, out Stat buf)
+ {
+ if (RuntimeInformation.ProcessArchitecture == Architecture.Arm64) {
+ return lstat_arm64 (path, out buf);
+ } else {
+ return lstat_x64 (path, out buf);
+ }
+ }
+
+ public static bool IsSymlink (string file)
+ {
+ if (Environment.OSVersion.Platform == PlatformID.Win32NT) {
+ var attr = File.GetAttributes (file);
+ return attr.HasFlag (FileAttributes.ReparsePoint);
+ }
+ Stat buf;
+ var rv = lstat (file, out buf);
+ if (rv != 0)
+ throw new Exception (string.Format ("Could not lstat '{0}': {1}", file, Marshal.GetLastWin32Error ()));
+ const int S_IFLNK = 40960;
+ return (buf.st_mode & S_IFLNK) == S_IFLNK;
+ }
+
+ public static bool IsSymlinkOrHasParentSymlink (string directoryOrFile)
+ {
+ if (IsSymlink (directoryOrFile))
+ return true;
+
+ if (!Directory.Exists (directoryOrFile))
+ return false;
+
+ var parentDirectory = Path.GetDirectoryName (directoryOrFile);
+ if (string.IsNullOrEmpty (parentDirectory) || parentDirectory == directoryOrFile)
+ return false;
+
+ return IsSymlinkOrHasParentSymlink (parentDirectory);
+ }
+ }
+}
diff --git a/Xamarin.MacDev/XcodeLocator.cs b/Xamarin.MacDev/XcodeLocator.cs
new file mode 100644
index 0000000..d222f4c
--- /dev/null
+++ b/Xamarin.MacDev/XcodeLocator.cs
@@ -0,0 +1,252 @@
+// Copyright (c) Microsoft Corporation.
+// Licensed under the MIT License.
+
+using System;
+using System.Collections.Generic;
+using System.Diagnostics;
+using System.Diagnostics.CodeAnalysis;
+using System.IO;
+
+#nullable enable
+
+namespace Xamarin.MacDev {
+ ///
+ /// This is a class that can be used to locate Xcode according to various rules.
+ /// An important design goal is that it doesn't store any static state (which has caused numerous problems in the past).
+ ///
+ public class XcodeLocator {
+
+ ICustomLogger log;
+
+ ///
+ /// canonicalized Xcode location (no trailing slash, no /Contents/Developer)
+ ///
+ public string XcodeLocation { get; private set; } = "";
+
+ ///
+ /// The Developer root path, e.g. /Applications/Xcode.app/Contents/Developer
+ ///
+ public string DeveloperRoot { get => Path.Combine (XcodeLocation, "Contents", "Developer"); }
+
+ ///
+ /// The path to the version.plist file inside the Xcode bundle.
+ ///
+ public string DeveloperRootVersionPlist { get => Path.Combine (XcodeLocation, "Contents", "version.plist"); }
+
+ ///
+ /// The Xcode version.
+ ///
+ public Version XcodeVersion { get; private set; } = new Version (0, 0, 0);
+
+ public string DTXcode { get; private set; } = "";
+
+ /// If the Xcode location is a symlink or has a parent directory that is a symlink.
+ public bool IsXcodeSymlink => PathUtils.IsSymlinkOrHasParentSymlink (XcodeLocation);
+
+ /// Look for the Xcode location in the MD_APPLE_SDK_ROOT or not. Defaults to false.
+ public bool SupportEnvironmentVariableLookup { get; set; } = false;
+
+ /// Look for the Xcode location in ~/Library/Preferences/maui/Settings.plist or ~/Library/Preferences/Xamarin/Settings.plist. Defaults to true at the moment.
+ public bool SupportSettingsFileLookup { get; set; } = true;
+
+ public static IEnumerable SettingsPathCandidates => new string [] {
+ Path.Combine (Environment.GetFolderPath (Environment.SpecialFolder.UserProfile), "Library", "Preferences", "maui", "Settings.plist"),
+ Path.Combine (Environment.GetFolderPath (Environment.SpecialFolder.UserProfile), "Library", "Preferences", "Xamarin", "Settings.plist"),
+ };
+
+ public XcodeLocator (ICustomLogger logger)
+ {
+ log = logger;
+ }
+
+ public bool TryLocatingXcode (string? xcodeLocationOverride)
+ {
+ // First try the override.
+ if (TryLocatingSpecificXcode (xcodeLocationOverride, out var canonicalizedXcodePath)) {
+ XcodeLocation = canonicalizedXcodePath;
+ return true;
+ }
+
+ // Historically, this is what we've done: https://github.com/dotnet/macios/issues/11172#issuecomment-1422092279
+ //
+ // 1. If the MD_APPLE_SDK_ROOT environment variable is set, use that.
+ // 2. If the ~/Library/Preferences/maui/Settings.plist or ~/Library/Preferences/Xamarin/Settings.plist file exists, use that.
+ // 3. Check xcode-select --print-path.
+ // 4. Use /Applications/Xcode.app.
+ //
+ // A few points:
+ //
+ // 1. I've never seen anyone outside our own code use MD_APPLE_SDK_ROOT, so we don't need to support that, but let's make it opt-in for now.
+ // 2. We want to deprecate the settings files, because they just confuse people. Yet we don't want to break people, so make this opt-out for now.
+ // 3. This is good.
+ // 4. Why check something that probably doesn't even exist (if xcode-select --print-path doesn't know about it, it probably doesn't work).
+
+ // 1. This is opt-in
+ if (SupportEnvironmentVariableLookup && TryLocatingSpecificXcode (Environment.GetEnvironmentVariable ("MD_APPLE_SDK_ROOT"), out var location)) {
+ XcodeLocation = location;
+ return true;
+ }
+
+ // 2. This is opt-out for the moment, but will likely become opt-in at some point.
+ if (SupportSettingsFileLookup) {
+ foreach (var candidate in SettingsPathCandidates) {
+ if (!TryReadSettingsPath (log, candidate, out var sdkLocation))
+ continue;
+ if (TryLocatingSpecificXcode (sdkLocation, out location)) {
+ XcodeLocation = location;
+ return true;
+ }
+ }
+ }
+
+ // 3. Not optional
+ if (TryGetSystemXcode (log, out location)) {
+ XcodeLocation = location;
+ return true;
+ }
+
+ // 4. Nope
+ return false;
+ }
+
+ bool TryLocatingSpecificXcode (string? xcodePath, [NotNullWhen (true)] out string? canonicalizedXcodePath)
+ {
+ if (!TryValidateAndCanonicalizeXcodePath (xcodePath, out canonicalizedXcodePath))
+ return false;
+
+ var versionPlistPath = Path.Combine (canonicalizedXcodePath, "Contents", "version.plist");
+ if (!File.Exists (versionPlistPath)) {
+ log.LogInfo ("Discarded the Xcode location '{0}' because it doesn't have the file 'Contents/version.plist'.", canonicalizedXcodePath);
+ return false;
+ }
+ var versionPlist = PDictionary.FromFile (versionPlistPath);
+ var cfBundleShortVersion = versionPlist.GetCFBundleShortVersionString ();
+ var cfBundleVersion = versionPlist.GetCFBundleVersion ();
+
+ if (!Version.TryParse (cfBundleShortVersion, out var xcodeVersion)) {
+ log.LogInfo ("Discarded the Xcode location '{0}' because failure to parse the CFBundleShortVersionString value '{1}' from the file 'Contents/version.plist'.", canonicalizedXcodePath, cfBundleShortVersion);
+ return false;
+ }
+
+ var infoPlistPath = Path.Combine (canonicalizedXcodePath, "Contents", "Info.plist");
+ if (!File.Exists (infoPlistPath)) {
+ log.LogInfo ("Discarded the Xcode location '{0}' because it doesn't have the file 'Contents/Info.plist'.", canonicalizedXcodePath);
+ return false;
+ }
+ var infoPlist = PDictionary.FromFile (infoPlistPath);
+ if (infoPlist is null) {
+ log.LogInfo ("Discarded the Xcode location '{0}' because failure to parse the file 'Contents/Info.plist'.", canonicalizedXcodePath, cfBundleShortVersion);
+ return false;
+ }
+
+ // Success!
+ XcodeVersion = xcodeVersion;
+ if (infoPlist.TryGetValue ("DTXcode", out var value))
+ DTXcode = value.Value;
+
+ log.LogInfo ("Found a valid Xcode location (Xcode {1} with CFBundleVersion={2}): {0}", canonicalizedXcodePath, cfBundleShortVersion, cfBundleVersion);
+
+ return true;
+ }
+
+ // Accept all of these variations:
+ // * /Applications/Xcode.app
+ // * /Applications/Xcode.app/
+ // * /Applications/Xcode.app/Contents/Developer
+ // * /Applications/Xcode.app/Contents/Developer/
+ // Also accept Windows-style directory separators (but the output will contain Mac-style directory separators).
+ bool TryValidateAndCanonicalizeXcodePath (string? xcodePath, [NotNullWhen (true)] out string? canonicalizedXcodePath)
+ {
+ canonicalizedXcodePath = null;
+
+ if (string.IsNullOrEmpty (xcodePath))
+ return false;
+
+ if (!Directory.Exists (xcodePath)) {
+ log.LogInfo ("Discarded the Xcode location '{0}' because it doesn't exist.", xcodePath);
+ return false;
+ }
+
+ xcodePath = xcodePath!.Replace ('\\', '/');
+ xcodePath = xcodePath.TrimEnd ('/');
+
+ if (xcodePath.EndsWith ("/Contents/Developer", StringComparison.Ordinal))
+ xcodePath = xcodePath.Substring (0, xcodePath.Length - "/Contents/Developer".Length);
+
+ canonicalizedXcodePath = xcodePath;
+ return true;
+ }
+
+ public static bool TryReadSettingsPath (ICustomLogger log, string settingsPath, [NotNullWhen (true)] out string? sdkLocation)
+ {
+ sdkLocation = null;
+
+ if (!File.Exists (settingsPath)) {
+ log.LogInfo ("The settings file {0} doesn't exist.", settingsPath);
+ return false;
+ }
+
+ var plist = PDictionary.FromFile (settingsPath);
+ if (plist is null) {
+ log.LogInfo ("The settings file {0} exists, but it couldn't be loaded.", settingsPath);
+ return false;
+ }
+
+ if (!plist.TryGetValue ("AppleSdkRoot", out var value)) {
+ log.LogInfo ("The settings file {0} exists, but there's no 'AppleSdkRoot' entry in it.", settingsPath);
+ return false;
+ }
+
+ var location = value?.Value;
+ if (string.IsNullOrEmpty (location)) {
+ log.LogInfo ("The settings file {0} exists, but the 'AppleSdkRoot' is empty.", settingsPath);
+ return false;
+ }
+
+ log.LogInfo ("An Xcode location was found in the file '{0}': {1}", settingsPath, location);
+#if NET
+ sdkLocation = location;
+#else
+ sdkLocation = location!;
+#endif
+ return true;
+ }
+
+ public static bool TryGetSystemXcode (ICustomLogger log, [NotNullWhen (true)] out string? path)
+ {
+ path = null;
+
+ var xcodeSelect = "/usr/bin/xcode-select";
+ if (!File.Exists (xcodeSelect))
+ return false;
+
+ try {
+ using var process = new Process ();
+ process.StartInfo.FileName = xcodeSelect;
+ process.StartInfo.Arguments = "--print-path";
+ process.StartInfo.RedirectStandardOutput = true;
+ process.StartInfo.UseShellExecute = false;
+ process.Start ();
+ var stdout = process.StandardOutput.ReadToEnd ();
+ process.WaitForExit ();
+
+ stdout = stdout.Trim ();
+ if (Directory.Exists (stdout)) {
+ if (stdout.EndsWith ("/Contents/Developer", StringComparison.Ordinal))
+ stdout = stdout.Substring (0, stdout.Length - "/Contents/Developer".Length);
+
+ path = stdout;
+ log.LogInfo ("Detect the Xcode location configured for this system (found using 'xcode-select -p'): {0}", path);
+ return true;
+ }
+
+ log.LogInfo ("The system's Xcode location (found using 'xcode-select -p') does not exist: {0}", stdout);
+
+ return false;
+ } catch (Exception e) {
+ log.LogInfo ("Could not get the system's Xcode location: {0}", e);
+ return false;
+ }
+ }
+ }
+}