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; + } + } + } +}