Skip to content

Commit 2a9eed3

Browse files
rmarinhoCopilot
andcommitted
Add Apple install orchestrator
Add AppleInstaller that checks the current Apple development environment and installs missing components: - Command Line Tools (via xcode-select --install) - Xcode first-launch packages (via xcodebuild -runFirstLaunch) - Simulator runtimes (via RuntimeService.DownloadPlatform) Supports dry-run mode for reporting planned actions without changes. Includes EnvironmentChecker, CommandLineTools, XcodeManager, RuntimeService, and SimctlOutputParser dependencies from PRs #156-#160. Closes #152 Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>
1 parent 95f7788 commit 2a9eed3

9 files changed

Lines changed: 1127 additions & 0 deletions

Xamarin.MacDev/AppleInstaller.cs

Lines changed: 145 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,145 @@
1+
// Copyright (c) Microsoft Corporation.
2+
// Licensed under the MIT License.
3+
4+
using System;
5+
using System.Collections.Generic;
6+
7+
using Xamarin.MacDev.Models;
8+
9+
#nullable enable
10+
11+
namespace Xamarin.MacDev {
12+
13+
/// <summary>
14+
/// Orchestrates Apple development environment setup. Checks the current
15+
/// state via <see cref="EnvironmentChecker"/> and installs missing
16+
/// components (Command Line Tools, Xcode first-launch packages, and
17+
/// simulator runtimes).
18+
/// </summary>
19+
public class AppleInstaller {
20+
21+
readonly ICustomLogger log;
22+
23+
public AppleInstaller (ICustomLogger log)
24+
{
25+
this.log = log ?? throw new ArgumentNullException (nameof (log));
26+
}
27+
28+
/// <summary>
29+
/// Ensures the Apple development environment is ready.
30+
/// When <paramref name="dryRun"/> is true, reports what would be
31+
/// installed without making any changes.
32+
/// Returns the final <see cref="EnvironmentCheckResult"/>.
33+
/// </summary>
34+
/// <param name="requestedPlatforms">
35+
/// Platforms to ensure runtimes for (e.g. "iOS", "tvOS").
36+
/// If null or empty, only existing runtimes are verified.
37+
/// </param>
38+
/// <param name="dryRun">
39+
/// When true, logs planned actions but does not execute them.
40+
/// </param>
41+
public EnvironmentCheckResult Install (IEnumerable<string>? requestedPlatforms = null, bool dryRun = false)
42+
{
43+
var checker = new EnvironmentChecker (log);
44+
45+
// 1. Initial check
46+
log.LogInfo ("Running initial environment check...");
47+
var result = checker.Check ();
48+
49+
// 2. Ensure Command Line Tools
50+
EnsureCommandLineTools (result.CommandLineTools, dryRun);
51+
52+
// 3. Ensure Xcode first-launch packages
53+
if (result.Xcode is not null)
54+
EnsureFirstLaunch (checker, result.Xcode.Path, dryRun);
55+
else
56+
log.LogInfo ("No Xcode found — skipping first-launch check.");
57+
58+
// 4. Ensure requested runtimes
59+
if (requestedPlatforms is not null)
60+
EnsureRuntimes (result, requestedPlatforms, dryRun);
61+
62+
// 5. Re-check and return
63+
if (!dryRun) {
64+
log.LogInfo ("Running final environment check...");
65+
result = checker.Check ();
66+
}
67+
68+
log.LogInfo ("Install complete. Status: {0}.", result.Status);
69+
return result;
70+
}
71+
72+
/// <summary>
73+
/// Triggers CLT installation if not already present.
74+
/// Uses <c>xcode-select --install</c> to launch the macOS installer UI.
75+
/// </summary>
76+
void EnsureCommandLineTools (CommandLineToolsInfo clt, bool dryRun)
77+
{
78+
if (clt.IsInstalled) {
79+
log.LogInfo ("Command Line Tools already installed (v{0}).", clt.Version);
80+
return;
81+
}
82+
83+
if (dryRun) {
84+
log.LogInfo ("[DRY RUN] Would trigger Command Line Tools installation.");
85+
return;
86+
}
87+
88+
log.LogInfo ("Command Line Tools not found. Triggering installation...");
89+
try {
90+
// xcode-select --install triggers the macOS installer dialog
91+
var (exitCode, _, stderr) = ProcessUtils.Exec ("xcode-select", "--install");
92+
if (exitCode == 0)
93+
log.LogInfo ("Command Line Tools installer triggered. Complete the dialog to continue.");
94+
else
95+
log.LogInfo ("xcode-select --install failed (exit {0}): {1}", exitCode, stderr.Trim ());
96+
} catch (System.ComponentModel.Win32Exception ex) {
97+
log.LogInfo ("Could not run xcode-select: {0}", ex.Message);
98+
}
99+
}
100+
101+
/// <summary>
102+
/// Ensures Xcode first-launch packages are installed.
103+
/// </summary>
104+
void EnsureFirstLaunch (EnvironmentChecker checker, string xcodePath, bool dryRun)
105+
{
106+
if (dryRun) {
107+
log.LogInfo ("[DRY RUN] Would run xcodebuild -runFirstLaunch.");
108+
return;
109+
}
110+
111+
checker.RunFirstLaunch (xcodePath);
112+
}
113+
114+
/// <summary>
115+
/// Ensures simulator runtimes are available for the requested platforms.
116+
/// Downloads missing runtimes via <see cref="RuntimeService.DownloadPlatform"/>.
117+
/// </summary>
118+
void EnsureRuntimes (EnvironmentCheckResult result, IEnumerable<string> requestedPlatforms, bool dryRun)
119+
{
120+
// Build a set of available runtime platforms
121+
var available = new HashSet<string> (StringComparer.OrdinalIgnoreCase);
122+
foreach (var rt in result.Runtimes) {
123+
if (!string.IsNullOrEmpty (rt.Platform))
124+
available.Add (rt.Platform);
125+
}
126+
127+
var runtimeService = new RuntimeService (log);
128+
129+
foreach (var platform in requestedPlatforms) {
130+
if (available.Contains (platform)) {
131+
log.LogInfo ("Runtime for '{0}' is already available.", platform);
132+
continue;
133+
}
134+
135+
if (dryRun) {
136+
log.LogInfo ("[DRY RUN] Would download runtime for '{0}'.", platform);
137+
continue;
138+
}
139+
140+
log.LogInfo ("Downloading runtime for '{0}'...", platform);
141+
runtimeService.DownloadPlatform (platform);
142+
}
143+
}
144+
}
145+
}

Xamarin.MacDev/CommandLineTools.cs

Lines changed: 134 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,134 @@
1+
// Copyright (c) Microsoft Corporation.
2+
// Licensed under the MIT License.
3+
4+
using System;
5+
using System.IO;
6+
7+
using Xamarin.MacDev.Models;
8+
9+
#nullable enable
10+
11+
namespace Xamarin.MacDev {
12+
13+
/// <summary>
14+
/// Detects and reports on the Xcode Command Line Tools installation.
15+
/// Follows the same instance-based, ICustomLogger pattern as XcodeLocator.
16+
/// </summary>
17+
public class CommandLineTools {
18+
19+
static readonly string XcodeSelectPath = "/usr/bin/xcode-select";
20+
static readonly string PkgutilPath = "/usr/bin/pkgutil";
21+
static readonly string CltPkgId = "com.apple.pkg.CLTools_Executables";
22+
static readonly string DefaultCltPath = "/Library/Developer/CommandLineTools";
23+
24+
readonly ICustomLogger log;
25+
26+
public CommandLineTools (ICustomLogger log)
27+
{
28+
this.log = log ?? throw new ArgumentNullException (nameof (log));
29+
}
30+
31+
/// <summary>
32+
/// Checks whether the Xcode Command Line Tools are installed and returns their info.
33+
/// </summary>
34+
public CommandLineToolsInfo Check ()
35+
{
36+
var info = new CommandLineToolsInfo ();
37+
38+
// First check if the CLT directory exists
39+
var cltPath = GetCommandLineToolsPath ();
40+
if (cltPath is null) {
41+
log.LogInfo ("Command Line Tools are not installed (path not found).");
42+
return info;
43+
}
44+
45+
info.Path = cltPath;
46+
47+
// Get version from pkgutil
48+
var version = GetVersionFromPkgutil ();
49+
if (version is not null) {
50+
info.Version = version;
51+
info.IsInstalled = true;
52+
log.LogInfo ("Command Line Tools {0} found at '{1}'.", version, cltPath);
53+
} else {
54+
// Directory exists but pkgutil doesn't report it — partial install
55+
info.IsInstalled = Directory.Exists (Path.Combine (cltPath, "usr", "bin"));
56+
if (info.IsInstalled)
57+
log.LogInfo ("Command Line Tools found at '{0}' (version unknown).", cltPath);
58+
else
59+
log.LogInfo ("Command Line Tools directory exists at '{0}' but appears incomplete.", cltPath);
60+
}
61+
62+
return info;
63+
}
64+
65+
/// <summary>
66+
/// Returns the Command Line Tools install path, or null if not found.
67+
/// Uses xcode-select -p first, falls back to the well-known default path.
68+
/// </summary>
69+
string? GetCommandLineToolsPath ()
70+
{
71+
// Try xcode-select -p — if it returns a CLT path (not Xcode), use it
72+
if (File.Exists (XcodeSelectPath)) {
73+
try {
74+
var (exitCode, stdout, _) = ProcessUtils.Exec (XcodeSelectPath, "--print-path");
75+
if (exitCode == 0) {
76+
var path = stdout.Trim ();
77+
if (path.Contains ("CommandLineTools") && Directory.Exists (path)) {
78+
// xcode-select points to CLT (e.g. /Library/Developer/CommandLineTools)
79+
return path;
80+
}
81+
}
82+
} catch (System.ComponentModel.Win32Exception ex) {
83+
log.LogInfo ("Could not run xcode-select: {0}", ex.Message);
84+
}
85+
}
86+
87+
// Fall back to the default well-known path
88+
if (Directory.Exists (DefaultCltPath))
89+
return DefaultCltPath;
90+
91+
return null;
92+
}
93+
94+
/// <summary>
95+
/// Queries pkgutil for the CLT package version.
96+
/// Returns the version string or null if not installed.
97+
/// </summary>
98+
internal string? GetVersionFromPkgutil ()
99+
{
100+
if (!File.Exists (PkgutilPath))
101+
return null;
102+
103+
try {
104+
var (exitCode, stdout, _) = ProcessUtils.Exec (PkgutilPath, "--pkg-info", CltPkgId);
105+
if (exitCode != 0)
106+
return null;
107+
108+
return ParsePkgutilVersion (stdout);
109+
} catch (System.ComponentModel.Win32Exception ex) {
110+
log.LogInfo ("Could not run pkgutil: {0}", ex.Message);
111+
return null;
112+
}
113+
}
114+
115+
/// <summary>
116+
/// Parses the "version: ..." line from pkgutil --pkg-info output.
117+
/// </summary>
118+
public static string? ParsePkgutilVersion (string pkgutilOutput)
119+
{
120+
if (string.IsNullOrEmpty (pkgutilOutput))
121+
return null;
122+
123+
foreach (var rawLine in pkgutilOutput.Split ('\n')) {
124+
var line = rawLine.Trim ();
125+
if (line.StartsWith ("version:", StringComparison.Ordinal)) {
126+
var version = line.Substring ("version:".Length).Trim ();
127+
return string.IsNullOrEmpty (version) ? null : version;
128+
}
129+
}
130+
131+
return null;
132+
}
133+
}
134+
}

0 commit comments

Comments
 (0)