diff --git a/Xamarin.MacDev/AppleInstaller.cs b/Xamarin.MacDev/AppleInstaller.cs
new file mode 100644
index 0000000..99f907f
--- /dev/null
+++ b/Xamarin.MacDev/AppleInstaller.cs
@@ -0,0 +1,124 @@
+// Copyright (c) Microsoft Corporation.
+// Licensed under the MIT License.
+
+using System;
+using System.Collections.Generic;
+using Xamarin.MacDev.Models;
+
+#nullable enable
+
+namespace Xamarin.MacDev {
+
+ ///
+ /// Orchestrates Apple development environment setup. Checks the current
+ /// state via and installs missing
+ /// components (Command Line Tools, Xcode first-launch packages, and
+ /// simulator runtimes).
+ ///
+ public class AppleInstaller {
+
+ static readonly string XcodeSelectPath = "/usr/bin/xcode-select";
+
+ readonly ICustomLogger log;
+
+ public AppleInstaller (ICustomLogger log)
+ {
+ this.log = log ?? throw new ArgumentNullException (nameof (log));
+ }
+
+ ///
+ /// Ensures the Apple development environment is ready.
+ /// When is true, reports what would be
+ /// installed without making any changes.
+ ///
+ public EnvironmentCheckResult Install (IEnumerable? requestedPlatforms = null, bool dryRun = false)
+ {
+ var checker = new EnvironmentChecker (log);
+
+ log.LogInfo ("Running initial environment check...");
+ var result = checker.Check ();
+
+ EnsureCommandLineTools (result.CommandLineTools, dryRun);
+
+ if (result.Xcode is not null)
+ EnsureFirstLaunch (checker, dryRun);
+ else
+ log.LogInfo ("No Xcode found — skipping first-launch check.");
+
+ if (requestedPlatforms is not null)
+ EnsureRuntimes (result, requestedPlatforms, dryRun);
+
+ if (!dryRun) {
+ log.LogInfo ("Running final environment check...");
+ result = checker.Check ();
+ }
+
+ log.LogInfo ("Install complete. Status: {0}.", result.Status);
+ return result;
+ }
+
+ void EnsureCommandLineTools (CommandLineToolsInfo clt, bool dryRun)
+ {
+ if (clt.IsInstalled) {
+ log.LogInfo ("Command Line Tools already installed (v{0}).", clt.Version);
+ return;
+ }
+
+ if (dryRun) {
+ log.LogInfo ("[DRY RUN] Would trigger Command Line Tools installation.");
+ return;
+ }
+
+ log.LogInfo ("Command Line Tools not found. Triggering installation...");
+ try {
+ var (exitCode, _, stderr) = ProcessUtils.Exec (XcodeSelectPath, "--install");
+ if (exitCode == 0)
+ log.LogInfo ("Command Line Tools installer triggered. Complete the dialog to continue.");
+ else
+ log.LogInfo ("xcode-select --install failed (exit {0}): {1}", exitCode, stderr.Trim ());
+ } 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);
+ }
+ }
+
+ void EnsureFirstLaunch (EnvironmentChecker checker, bool dryRun)
+ {
+ if (dryRun) {
+ log.LogInfo ("[DRY RUN] Would run xcodebuild -runFirstLaunch.");
+ return;
+ }
+
+ checker.RunFirstLaunch ();
+ }
+
+ void EnsureRuntimes (EnvironmentCheckResult result, IEnumerable requestedPlatforms, bool dryRun)
+ {
+ var available = new HashSet (StringComparer.OrdinalIgnoreCase);
+ foreach (var rt in result.Runtimes) {
+ if (!string.IsNullOrEmpty (rt.Platform))
+ available.Add (rt.Platform);
+ }
+
+ var runtimeService = new RuntimeService (log);
+
+ foreach (var platform in requestedPlatforms) {
+ if (available.Contains (platform)) {
+ log.LogInfo ("Runtime for '{0}' is already available.", platform);
+ continue;
+ }
+
+ if (dryRun) {
+ log.LogInfo ("[DRY RUN] Would download runtime for '{0}'.", platform);
+ continue;
+ }
+
+ log.LogInfo ("Downloading runtime for '{0}'...", platform);
+ var success = runtimeService.DownloadPlatform (platform);
+ if (!success)
+ log.LogInfo ("Failed to download runtime for '{0}'.", platform);
+ }
+ }
+ }
+}
diff --git a/Xamarin.MacDev/CommandLineTools.cs b/Xamarin.MacDev/CommandLineTools.cs
index 1e00386..e7278df 100644
--- a/Xamarin.MacDev/CommandLineTools.cs
+++ b/Xamarin.MacDev/CommandLineTools.cs
@@ -81,6 +81,8 @@ public CommandLineToolsInfo Check ()
}
} 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);
}
}
@@ -109,6 +111,9 @@ public CommandLineToolsInfo Check ()
} 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;
}
}
diff --git a/tests/AppleInstallerTests.cs b/tests/AppleInstallerTests.cs
new file mode 100644
index 0000000..4f891dc
--- /dev/null
+++ b/tests/AppleInstallerTests.cs
@@ -0,0 +1,50 @@
+// Copyright (c) Microsoft Corporation.
+// Licensed under the MIT License.
+
+using NUnit.Framework;
+using Xamarin.MacDev;
+
+#nullable enable
+
+namespace tests;
+
+[TestFixture]
+public class AppleInstallerTests {
+
+ [Test]
+ public void Constructor_ThrowsOnNullLogger ()
+ {
+ Assert.Throws (() => new AppleInstaller (null!));
+ }
+
+ [Test]
+ [Platform ("MacOsX")]
+ public void Install_DryRun_DoesNotThrow ()
+ {
+ var installer = new AppleInstaller (ConsoleLogger.Instance);
+ Assert.DoesNotThrow (() => installer.Install (dryRun: true));
+ }
+
+ [Test]
+ [Platform ("MacOsX")]
+ public void Install_DryRun_ReturnsValidResult ()
+ {
+ var installer = new AppleInstaller (ConsoleLogger.Instance);
+ var result = installer.Install (dryRun: true);
+ Assert.That (result, Is.Not.Null);
+ Assert.That (result.Status, Is.AnyOf (
+ Xamarin.MacDev.Models.EnvironmentStatus.Ok,
+ Xamarin.MacDev.Models.EnvironmentStatus.Partial,
+ Xamarin.MacDev.Models.EnvironmentStatus.Missing));
+ }
+
+ [Test]
+ [Platform ("MacOsX")]
+ public void Install_WithPlatforms_DryRun_DoesNotThrow ()
+ {
+ var installer = new AppleInstaller (ConsoleLogger.Instance);
+ Assert.DoesNotThrow (() => installer.Install (
+ requestedPlatforms: new [] { "iOS", "macOS" },
+ dryRun: true));
+ }
+}