From 5f2f704457ed8c43476cb9217f380e71bcd2a44e Mon Sep 17 00:00:00 2001 From: Rui Marinho Date: Mon, 9 Mar 2026 12:53:38 +0000 Subject: [PATCH] Add Apple install orchestrator - Add AppleInstaller class that orchestrates environment setup: checks state via EnvironmentChecker, installs CLT, triggers first-launch packages, and downloads simulator runtimes - Add InvalidOperationException catch clauses to CommandLineTools GetCommandLineToolsPath and GetVersionFromPkgutil - Address review feedback: use static readonly for paths, log DownloadPlatform failures, remove duplicate DeriveStatus tests - Tests: constructor null-check + macOS smoke tests (dry-run) Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> --- Xamarin.MacDev/AppleInstaller.cs | 124 +++++++++++++++++++++++++++++ Xamarin.MacDev/CommandLineTools.cs | 5 ++ tests/AppleInstallerTests.cs | 50 ++++++++++++ 3 files changed, 179 insertions(+) create mode 100644 Xamarin.MacDev/AppleInstaller.cs create mode 100644 tests/AppleInstallerTests.cs 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)); + } +}