From e4b685cdec7e869f609a0de3233c3d122a16df19 Mon Sep 17 00:00:00 2001 From: Rui Marinho Date: Thu, 30 Apr 2026 01:40:27 +0100 Subject: [PATCH 1/8] Refactor AppleProvider to delegate to EnvironmentChecker and add install command - Delegate CheckHealth() to Xamarin.MacDev.EnvironmentChecker.Check() instead of manually querying XcodeManager/CommandLineTools/RuntimeService separately - Add Xcode license acceptance check (new E2205 error code) - Add SDK Platforms health check showing discovered platform SDKs - Upgrade CLT-missing severity from Warning to Error (blocks development) - Add 'maui apple install' command wrapping AppleInstaller.Install() for one-command environment bootstrapping (consistent with 'maui android install') - Add --platform and --dry-run options to the install command - Register AppleInstallResult in JSON source generator context - Update FakeAppleProvider with InstallEnvironmentAsync support Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> --- eng/Version.Details.xml | 4 +- eng/Versions.props | 2 +- .../Fakes/FakeAppleProvider.cs | 8 + .../Commands/AppleCommands.cs | 70 +++++ .../Microsoft.Maui.Cli/Errors/ErrorCodes.cs | 2 + .../Output/MauiCliJsonContext.cs | 1 + .../Providers/Apple/AppleProvider.cs | 243 +++++++++++++----- .../Providers/Apple/IAppleProvider.cs | 35 +++ 8 files changed, 297 insertions(+), 68 deletions(-) diff --git a/eng/Version.Details.xml b/eng/Version.Details.xml index 9b1c02261..c8aa19537 100644 --- a/eng/Version.Details.xml +++ b/eng/Version.Details.xml @@ -32,9 +32,9 @@ https://github.com/dotnet/dotnet 73b3b5ac0e4a5658c7a0555b67d91a22ad39de4b - + https://github.com/dotnet/macios-devtools - 14efcb9735fab369066fa3c99130d076aa0dfdc9 + 0c544e8cf5ed4ba7ce7c7cc10802a365fd9ace30 diff --git a/eng/Versions.props b/eng/Versions.props index e8d0b30cf..89b894c19 100644 --- a/eng/Versions.props +++ b/eng/Versions.props @@ -47,7 +47,7 @@ - 1.0.0-preview.1.26201.1 + 1.0.0-preview.1.26228.1 0.3.0 diff --git a/src/Cli/Microsoft.Maui.Cli.UnitTests/Fakes/FakeAppleProvider.cs b/src/Cli/Microsoft.Maui.Cli.UnitTests/Fakes/FakeAppleProvider.cs index 6c8631762..f464f984b 100644 --- a/src/Cli/Microsoft.Maui.Cli.UnitTests/Fakes/FakeAppleProvider.cs +++ b/src/Cli/Microsoft.Maui.Cli.UnitTests/Fakes/FakeAppleProvider.cs @@ -22,6 +22,7 @@ public class FakeAppleProvider : IAppleProvider public List Simulators { get; set; } = new(); public List HealthChecks { get; set; } = new(); public List Devices { get; set; } = new(); + public AppleInstallResult InstallResult { get; set; } = new() { Status = "ok" }; public bool SelectXcodeResult { get; set; } = true; public bool BootSimulatorResult { get; set; } = true; @@ -36,6 +37,7 @@ public class FakeAppleProvider : IAppleProvider public List ShutdownSimulators { get; } = new(); public List DeletedSimulators { get; } = new(); public List<(string Name, string DeviceType, string? Runtime)> CreatedSimulators { get; } = new(); + public List<(IEnumerable? Platforms, bool DryRun)> InstallCalls { get; } = new(); // --- IAppleProvider implementation --- @@ -92,5 +94,11 @@ public bool DeleteSimulator(string udidOrName) public List CheckHealth() => HealthChecks; + public Task InstallEnvironmentAsync(IEnumerable? platforms = null, bool dryRun = false, CancellationToken cancellationToken = default) + { + InstallCalls.Add((platforms, dryRun)); + return Task.FromResult(InstallResult); + } + public List GetDevices() => Devices; } diff --git a/src/Cli/Microsoft.Maui.Cli/Commands/AppleCommands.cs b/src/Cli/Microsoft.Maui.Cli/Commands/AppleCommands.cs index fc0155338..2331730aa 100644 --- a/src/Cli/Microsoft.Maui.Cli/Commands/AppleCommands.cs +++ b/src/Cli/Microsoft.Maui.Cli/Commands/AppleCommands.cs @@ -22,6 +22,7 @@ public static Command Create() command.Add(CreateXcodeCommand()); command.Add(CreateRuntimeCommand()); command.Add(CreateSimulatorCommand()); + command.Add(CreateInstallCommand()); return command; } @@ -129,6 +130,75 @@ static Command CreateRuntimeCommand() return runtimeCommand; } + static Command CreateInstallCommand() + { + var platformOption = new Option("--platform") + { + Description = "Platform(s) to ensure runtimes for (iOS, tvOS, watchOS, visionOS). If omitted, installs all available.", + AllowMultipleArgumentsPerToken = true + }; + var dryRunOption = new Option("--dry-run") + { + Description = "Show what would be installed without making changes" + }; + + var installCommand = new Command("install", "Set up Apple development environment (CLT, runtimes)") + { + platformOption, + dryRunOption + }; + + installCommand.SetAction(async (ParseResult parseResult, CancellationToken ct) => + { + var formatter = Program.GetFormatter(parseResult); + + if (!PlatformDetector.IsMacOS) + { + formatter.WriteWarning("Apple install is only available on macOS."); + return 1; + } + + var appleProvider = Program.AppleProvider; + var useJson = parseResult.GetValue(GlobalOptions.JsonOption); + var platforms = parseResult.GetValue(platformOption); + var dryRun = parseResult.GetValue(dryRunOption); + + if (dryRun && !useJson) + formatter.WriteInfo("Dry run mode — no changes will be made."); + + var result = await appleProvider.InstallEnvironmentAsync( + platforms is { Length: > 0 } ? platforms : null, + dryRun, + ct); + + if (useJson) + { + formatter.Write(result); + } + else + { + if (result.XcodeVersion is not null) + formatter.WriteSuccess($"Xcode: {result.XcodeVersion}"); + else + formatter.WriteWarning("Xcode: not found"); + + formatter.WriteInfo($"Command Line Tools: {(result.CommandLineToolsInstalled ? "installed" : "not installed")}"); + + if (result.Platforms.Count > 0) + formatter.WriteInfo($"Platforms: {string.Join(", ", result.Platforms)}"); + + if (result.Runtimes.Count > 0) + formatter.WriteInfo($"Runtimes: {string.Join(", ", result.Runtimes)}"); + + formatter.WriteInfo($"Status: {result.Status}"); + } + + return result.Status == "ok" ? 0 : 1; + }); + + return installCommand; + } + static Command CreateSimulatorCommand() { var simCommand = new Command("simulator", "Manage iOS simulators"); diff --git a/src/Cli/Microsoft.Maui.Cli/Errors/ErrorCodes.cs b/src/Cli/Microsoft.Maui.Cli/Errors/ErrorCodes.cs index 019b7a291..7542b1fe6 100644 --- a/src/Cli/Microsoft.Maui.Cli/Errors/ErrorCodes.cs +++ b/src/Cli/Microsoft.Maui.Cli/Errors/ErrorCodes.cs @@ -42,6 +42,8 @@ public static class ErrorCodes public const string AppleCltNotFound = "E2202"; public const string AppleSimctlFailed = "E2203"; public const string AppleSimulatorNotFound = "E2204"; + public const string AppleXcodeLicenseNotAccepted = "E2205"; + public const string AppleSetupFailed = "E2206"; // Platform/SDK errors - Windows (E23xx) public const string WindowsSdkNotFound = "E2301"; diff --git a/src/Cli/Microsoft.Maui.Cli/Output/MauiCliJsonContext.cs b/src/Cli/Microsoft.Maui.Cli/Output/MauiCliJsonContext.cs index 04f2086d0..c80d73fa7 100644 --- a/src/Cli/Microsoft.Maui.Cli/Output/MauiCliJsonContext.cs +++ b/src/Cli/Microsoft.Maui.Cli/Output/MauiCliJsonContext.cs @@ -42,6 +42,7 @@ namespace Microsoft.Maui.Cli.Output; [JsonSerializable(typeof(List))] [JsonSerializable(typeof(SimulatorInfo))] [JsonSerializable(typeof(List))] +[JsonSerializable(typeof(AppleInstallResult))] [JsonSerializable(typeof(StatusMessageResult))] [JsonSerializable(typeof(VersionResult))] [JsonSerializable(typeof(CliCommandResult))] diff --git a/src/Cli/Microsoft.Maui.Cli/Providers/Apple/AppleProvider.cs b/src/Cli/Microsoft.Maui.Cli/Providers/Apple/AppleProvider.cs index e98f4224d..1985c1a58 100644 --- a/src/Cli/Microsoft.Maui.Cli/Providers/Apple/AppleProvider.cs +++ b/src/Cli/Microsoft.Maui.Cli/Providers/Apple/AppleProvider.cs @@ -6,12 +6,15 @@ using Microsoft.Maui.Cli.Utils; using System.Text.Json.Nodes; using Xamarin.MacDev; +using Xamarin.MacDev.Models; namespace Microsoft.Maui.Cli.Providers.Apple; /// /// Apple platform provider backed by Xamarin.Apple.Tools.MaciOS. /// Only functional on macOS; returns empty results on other platforms. +/// Delegates environment checks to and +/// environment install to . /// public class AppleProvider : IAppleProvider { @@ -19,6 +22,8 @@ public class AppleProvider : IAppleProvider readonly SimulatorService? _simulatorService; readonly RuntimeService? _runtimeService; readonly CommandLineTools? _commandLineTools; + readonly EnvironmentChecker? _environmentChecker; + readonly AppleInstaller? _appleInstaller; public AppleProvider() { @@ -30,6 +35,8 @@ public AppleProvider() _simulatorService = new SimulatorService(logger); _runtimeService = new RuntimeService(logger); _commandLineTools = new CommandLineTools(logger); + _environmentChecker = new EnvironmentChecker(logger); + _appleInstaller = new AppleInstaller(logger); } public List GetXcodeInstallations() @@ -150,7 +157,7 @@ public List CheckHealth() { var checks = new List(); - if (!PlatformDetector.IsMacOS) + if (!PlatformDetector.IsMacOS || _environmentChecker is null) { checks.Add(new HealthCheck { @@ -162,116 +169,222 @@ public List CheckHealth() return checks; } - // Xcode check - var xcode = _xcodeManager?.GetBest(); - if (xcode is not null) + EnvironmentCheckResult result; + try + { + result = _environmentChecker.Check(); + } + catch (Exception ex) { checks.Add(new HealthCheck { Category = "apple", - Name = "Xcode", + Name = "Environment", + Status = CheckStatus.Error, + Message = $"Environment check failed: {ex.Message}" + }); + return checks; + } + + checks.Add(MapXcodeCheck(result)); + checks.Add(MapCommandLineToolsCheck(result)); + checks.Add(MapXcodeLicenseCheck()); + checks.Add(MapRuntimesCheck(result)); + + if (result.Platforms.Count > 0) + { + checks.Add(new HealthCheck + { + Category = "apple", + Name = "SDK Platforms", Status = CheckStatus.Ok, - Message = $"Xcode {xcode.Version} ({xcode.Build}) at {xcode.Path}", + Message = $"Available: {string.Join(", ", result.Platforms)}", Details = new JsonObject { - ["version"] = xcode.Version.ToString(), - ["build"] = xcode.Build, - ["path"] = xcode.Path, - ["selected"] = xcode.IsSelected + ["platforms"] = new JsonArray(result.Platforms.Select(p => (JsonNode)JsonValue.Create(p)!).ToArray()) } }); } - else + + return checks; + } + + static HealthCheck MapXcodeCheck(EnvironmentCheckResult result) + { + if (result.Xcode is not null) { - checks.Add(new HealthCheck + return new HealthCheck { Category = "apple", Name = "Xcode", - Status = CheckStatus.Error, - Message = "Xcode not found. Install Xcode from the App Store.", - Fix = new FixInfo + Status = CheckStatus.Ok, + Message = $"Xcode {result.Xcode.Version} ({result.Xcode.Build}) at {result.Xcode.Path}", + Details = new JsonObject { - IssueId = ErrorCodes.AppleXcodeNotFound, - Description = "Install Xcode from the Mac App Store", - AutoFixable = false, - ManualSteps = new[] { "Open the Mac App Store and install Xcode" } + ["version"] = result.Xcode.Version.ToString(), + ["build"] = result.Xcode.Build, + ["path"] = result.Xcode.Path, + ["selected"] = result.Xcode.IsSelected } - }); + }; } - // Command Line Tools check - var clt = _commandLineTools?.Check(); - if (clt is not null && clt.IsInstalled) + return new HealthCheck { - checks.Add(new HealthCheck + Category = "apple", + Name = "Xcode", + Status = CheckStatus.Error, + Message = "Xcode not found. Install Xcode from the App Store or run 'maui apple install'.", + Fix = new FixInfo + { + IssueId = ErrorCodes.AppleXcodeNotFound, + Description = "Install Xcode from the Mac App Store", + AutoFixable = false, + ManualSteps = new[] { "Open the Mac App Store and install Xcode", "Or run: maui apple install" } + } + }; + } + + static HealthCheck MapCommandLineToolsCheck(EnvironmentCheckResult result) + { + if (result.CommandLineTools.IsInstalled) + { + return new HealthCheck { Category = "apple", Name = "Command Line Tools", Status = CheckStatus.Ok, - Message = $"CLT {clt.Version ?? "installed"} at {clt.Path}" - }); + Message = $"CLT {result.CommandLineTools.Version ?? "installed"} at {result.CommandLineTools.Path}" + }; } - else + + return new HealthCheck { - checks.Add(new HealthCheck + Category = "apple", + Name = "Command Line Tools", + Status = CheckStatus.Error, + Message = "Xcode Command Line Tools not found. Run 'maui apple install' to install.", + Fix = new FixInfo { - Category = "apple", - Name = "Command Line Tools", - Status = CheckStatus.Warning, - Message = "Xcode Command Line Tools not found", - Fix = new FixInfo - { - IssueId = ErrorCodes.AppleCltNotFound, - Description = "Install Command Line Tools", - AutoFixable = true, - Command = "xcode-select --install" - } - }); - } + IssueId = ErrorCodes.AppleCltNotFound, + Description = "Install Command Line Tools", + AutoFixable = true, + Command = "xcode-select --install" + } + }; + } - // Simulator runtimes check - HealthCheck iosRuntimesCheck; + HealthCheck MapXcodeLicenseCheck() + { try { - var runtimes = _runtimeService?.List(availableOnly: true); - if (runtimes is { Count: > 0 }) + if (_environmentChecker!.IsXcodeLicenseAccepted()) { - var iosRuntimes = runtimes.Where(r => string.Equals(r.Platform, "iOS", StringComparison.OrdinalIgnoreCase)).ToList(); - iosRuntimesCheck = new HealthCheck + return new HealthCheck { Category = "apple", - Name = "iOS Runtimes", - Status = iosRuntimes.Count > 0 ? CheckStatus.Ok : CheckStatus.Warning, - Message = iosRuntimes.Count > 0 - ? $"{iosRuntimes.Count} iOS runtime(s) available (latest: {iosRuntimes.OrderByDescending(r => Version.TryParse(r.Version, out var v) ? v : new Version(0, 0)).First().Name})" - : "No iOS runtimes found. Install one via Xcode." + Name = "Xcode License", + Status = CheckStatus.Ok, + Message = "Xcode license accepted" }; } - else + + return new HealthCheck { - iosRuntimesCheck = new HealthCheck + Category = "apple", + Name = "Xcode License", + Status = CheckStatus.Error, + Message = "Xcode license not accepted. Run 'sudo xcodebuild -license accept'.", + Fix = new FixInfo { - Category = "apple", - Name = "iOS Runtimes", - Status = CheckStatus.Warning, - Message = "No simulator runtimes found. Install simulator runtimes via Xcode." - }; - } + IssueId = ErrorCodes.AppleXcodeLicenseNotAccepted, + Description = "Accept the Xcode license agreement", + AutoFixable = false, + ManualSteps = new[] { "Run: sudo xcodebuild -license accept", "Or run: maui apple install" } + } + }; } - catch (Exception ex) + catch + { + return new HealthCheck + { + Category = "apple", + Name = "Xcode License", + Status = CheckStatus.Warning, + Message = "Unable to determine Xcode license status" + }; + } + } + + static HealthCheck MapRuntimesCheck(EnvironmentCheckResult result) + { + if (result.Runtimes.Count == 0) { - iosRuntimesCheck = new HealthCheck + return new HealthCheck { Category = "apple", Name = "iOS Runtimes", Status = CheckStatus.Warning, - Message = $"Unable to determine installed iOS simulator runtimes: {ex.Message}" + Message = "No simulator runtimes found. Run 'maui apple install --platform iOS' to install." }; } - checks.Add(iosRuntimesCheck); + var iosRuntimes = result.Runtimes + .Where(r => string.Equals(r.Platform, "iOS", StringComparison.OrdinalIgnoreCase)) + .ToList(); - return checks; + if (iosRuntimes.Count == 0) + { + return new HealthCheck + { + Category = "apple", + Name = "iOS Runtimes", + Status = CheckStatus.Warning, + Message = "No iOS runtimes found. Run 'maui apple install --platform iOS' to install." + }; + } + + var latest = iosRuntimes + .OrderByDescending(r => Version.TryParse(r.Version, out var v) ? v : new Version(0, 0)) + .First(); + + return new HealthCheck + { + Category = "apple", + Name = "iOS Runtimes", + Status = CheckStatus.Ok, + Message = $"{iosRuntimes.Count} iOS runtime(s) available (latest: {latest.Name})" + }; + } + + public Task InstallEnvironmentAsync(IEnumerable? platforms = null, bool dryRun = false, CancellationToken cancellationToken = default) + { + if (!PlatformDetector.IsMacOS || _appleInstaller is null) + { + return Task.FromResult(new AppleInstallResult + { + Status = "skipped", + DryRun = dryRun + }); + } + + // AppleInstaller.Install is synchronous; wrap in Task.Run for cancellation support + return Task.Run(() => + { + cancellationToken.ThrowIfCancellationRequested(); + var result = _appleInstaller.Install(platforms, dryRun); + cancellationToken.ThrowIfCancellationRequested(); + + return new AppleInstallResult + { + Status = result.Status.ToString().ToLowerInvariant(), + XcodeVersion = result.Xcode is not null ? $"{result.Xcode.Version} ({result.Xcode.Build})" : null, + CommandLineToolsInstalled = result.CommandLineTools.IsInstalled, + Platforms = result.Platforms, + Runtimes = result.Runtimes.Select(r => r.Name).ToList(), + DryRun = dryRun + }; + }, cancellationToken); } public List GetDevices() diff --git a/src/Cli/Microsoft.Maui.Cli/Providers/Apple/IAppleProvider.cs b/src/Cli/Microsoft.Maui.Cli/Providers/Apple/IAppleProvider.cs index b3d1413e3..1af17a4b4 100644 --- a/src/Cli/Microsoft.Maui.Cli/Providers/Apple/IAppleProvider.cs +++ b/src/Cli/Microsoft.Maui.Cli/Providers/Apple/IAppleProvider.cs @@ -66,12 +66,47 @@ public interface IAppleProvider /// List CheckHealth(); + /// + /// Sets up the Apple development environment by installing missing components. + /// Uses to orchestrate CLT installation, + /// Xcode first-launch, and runtime downloads. + /// + /// Optional set of platforms to ensure runtimes for (e.g., "iOS", "tvOS"). + /// When true, reports what would be installed without making changes. + /// Cancellation token. + /// The environment check result after install completes. + Task InstallEnvironmentAsync(IEnumerable? platforms = null, bool dryRun = false, CancellationToken cancellationToken = default); + /// /// Lists simulator devices as models for device manager integration. /// List GetDevices(); } +/// +/// Result of the Apple environment install operation. +/// +public record AppleInstallResult +{ + /// Overall status of the environment after install. + public required string Status { get; init; } + + /// Xcode version and path, if found. + public string? XcodeVersion { get; init; } + + /// Whether Command Line Tools are installed. + public bool CommandLineToolsInstalled { get; init; } + + /// Available SDK platforms discovered in Xcode. + public List Platforms { get; init; } = new(); + + /// Available simulator runtimes. + public List Runtimes { get; init; } = new(); + + /// Whether this was a dry run (no changes made). + public bool DryRun { get; init; } +} + /// /// Information about an Xcode installation. /// From 0a3064a2857599094886336fd746a2ae02dbc370 Mon Sep 17 00:00:00 2001 From: Rui Marinho Date: Thu, 30 Apr 2026 11:27:26 +0100 Subject: [PATCH 2/8] Address PR review feedback for apple install command - Remove local --dry-run option; use GlobalOptions.DryRunOption (finding #1) - Return exit code 0 for 'skipped' status on non-macOS (finding #2) - Wrap install action in try-catch using E2206 error code (finding #3) - Replace null-forgiving with null-conditional on license check (finding #4) - Defensive copy Platforms list with .ToList() (finding #5) - Clarify cancellation limitation comment (finding #6) Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> --- .../Commands/AppleCommands.cs | 66 +++++++++++-------- .../Providers/Apple/AppleProvider.cs | 13 ++-- 2 files changed, 46 insertions(+), 33 deletions(-) diff --git a/src/Cli/Microsoft.Maui.Cli/Commands/AppleCommands.cs b/src/Cli/Microsoft.Maui.Cli/Commands/AppleCommands.cs index 2331730aa..9dab6f856 100644 --- a/src/Cli/Microsoft.Maui.Cli/Commands/AppleCommands.cs +++ b/src/Cli/Microsoft.Maui.Cli/Commands/AppleCommands.cs @@ -3,6 +3,7 @@ using System.CommandLine; using System.CommandLine.Parsing; +using Microsoft.Maui.Cli.Errors; using Microsoft.Maui.Cli.Output; using Microsoft.Maui.Cli.Providers.Apple; using Microsoft.Maui.Cli.Utils; @@ -137,15 +138,10 @@ static Command CreateInstallCommand() Description = "Platform(s) to ensure runtimes for (iOS, tvOS, watchOS, visionOS). If omitted, installs all available.", AllowMultipleArgumentsPerToken = true }; - var dryRunOption = new Option("--dry-run") - { - Description = "Show what would be installed without making changes" - }; var installCommand = new Command("install", "Set up Apple development environment (CLT, runtimes)") { - platformOption, - dryRunOption + platformOption }; installCommand.SetAction(async (ParseResult parseResult, CancellationToken ct) => @@ -155,45 +151,57 @@ static Command CreateInstallCommand() if (!PlatformDetector.IsMacOS) { formatter.WriteWarning("Apple install is only available on macOS."); - return 1; + return 0; } var appleProvider = Program.AppleProvider; var useJson = parseResult.GetValue(GlobalOptions.JsonOption); var platforms = parseResult.GetValue(platformOption); - var dryRun = parseResult.GetValue(dryRunOption); + var dryRun = parseResult.GetValue(GlobalOptions.DryRunOption); if (dryRun && !useJson) formatter.WriteInfo("Dry run mode — no changes will be made."); - var result = await appleProvider.InstallEnvironmentAsync( - platforms is { Length: > 0 } ? platforms : null, - dryRun, - ct); - - if (useJson) + try { - formatter.Write(result); - } - else - { - if (result.XcodeVersion is not null) - formatter.WriteSuccess($"Xcode: {result.XcodeVersion}"); + var result = await appleProvider.InstallEnvironmentAsync( + platforms is { Length: > 0 } ? platforms : null, + dryRun, + ct); + + if (useJson) + { + formatter.Write(result); + } else - formatter.WriteWarning("Xcode: not found"); + { + if (result.XcodeVersion is not null) + formatter.WriteSuccess($"Xcode: {result.XcodeVersion}"); + else + formatter.WriteWarning("Xcode: not found"); - formatter.WriteInfo($"Command Line Tools: {(result.CommandLineToolsInstalled ? "installed" : "not installed")}"); + formatter.WriteInfo($"Command Line Tools: {(result.CommandLineToolsInstalled ? "installed" : "not installed")}"); - if (result.Platforms.Count > 0) - formatter.WriteInfo($"Platforms: {string.Join(", ", result.Platforms)}"); + if (result.Platforms.Count > 0) + formatter.WriteInfo($"Platforms: {string.Join(", ", result.Platforms)}"); - if (result.Runtimes.Count > 0) - formatter.WriteInfo($"Runtimes: {string.Join(", ", result.Runtimes)}"); + if (result.Runtimes.Count > 0) + formatter.WriteInfo($"Runtimes: {string.Join(", ", result.Runtimes)}"); - formatter.WriteInfo($"Status: {result.Status}"); - } + formatter.WriteInfo($"Status: {result.Status}"); + } - return result.Status == "ok" ? 0 : 1; + return result.Status is "ok" or "skipped" ? 0 : 1; + } + catch (Exception ex) when (ex is not OperationCanceledException) + { + formatter.WriteError(new MauiToolException(ErrorCodes.AppleSetupFailed, "Apple install failed.", ex)); + return 1; + } + catch (Exception ex) + { + return Program.HandleCommandException(formatter, ex); + } }); return installCommand; diff --git a/src/Cli/Microsoft.Maui.Cli/Providers/Apple/AppleProvider.cs b/src/Cli/Microsoft.Maui.Cli/Providers/Apple/AppleProvider.cs index 1985c1a58..68a3b7a00 100644 --- a/src/Cli/Microsoft.Maui.Cli/Providers/Apple/AppleProvider.cs +++ b/src/Cli/Microsoft.Maui.Cli/Providers/Apple/AppleProvider.cs @@ -188,7 +188,11 @@ public List CheckHealth() checks.Add(MapXcodeCheck(result)); checks.Add(MapCommandLineToolsCheck(result)); - checks.Add(MapXcodeLicenseCheck()); + + // License check only meaningful when Xcode is present + if (result.Xcode is not null) + checks.Add(MapXcodeLicenseCheck()); + checks.Add(MapRuntimesCheck(result)); if (result.Platforms.Count > 0) @@ -278,7 +282,7 @@ HealthCheck MapXcodeLicenseCheck() { try { - if (_environmentChecker!.IsXcodeLicenseAccepted()) + if (_environmentChecker?.IsXcodeLicenseAccepted() == true) { return new HealthCheck { @@ -368,7 +372,8 @@ public Task InstallEnvironmentAsync(IEnumerable? pla }); } - // AppleInstaller.Install is synchronous; wrap in Task.Run for cancellation support + // AppleInstaller.Install() is synchronous and cannot be interrupted mid-operation. + // Task.Run enables thread pool cancellation between the before/after checks. return Task.Run(() => { cancellationToken.ThrowIfCancellationRequested(); @@ -380,7 +385,7 @@ public Task InstallEnvironmentAsync(IEnumerable? pla Status = result.Status.ToString().ToLowerInvariant(), XcodeVersion = result.Xcode is not null ? $"{result.Xcode.Version} ({result.Xcode.Build})" : null, CommandLineToolsInstalled = result.CommandLineTools.IsInstalled, - Platforms = result.Platforms, + Platforms = result.Platforms.ToList(), Runtimes = result.Runtimes.Select(r => r.Name).ToList(), DryRun = dryRun }; From 80cb87af406af8b66122aa06910a55fa45f5f3a8 Mon Sep 17 00:00:00 2001 From: Rui Marinho Date: Thu, 30 Apr 2026 12:11:57 +0100 Subject: [PATCH 3/8] Add unit tests for 'maui apple install' command Tests cover: - Command structure (install exists, --platform option, no local --dry-run) - Option parsing (multiple --platform values) - Handler invocation via FakeAppleProvider - Platform filter passthrough - Global --dry-run option propagation - Exit code 0 for 'ok' and 'skipped' status - Exit code 1 for 'failed' status Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> --- .../AppleCommandsTests.cs | 174 ++++++++++++++++++ 1 file changed, 174 insertions(+) create mode 100644 src/Cli/Microsoft.Maui.Cli.UnitTests/AppleCommandsTests.cs diff --git a/src/Cli/Microsoft.Maui.Cli.UnitTests/AppleCommandsTests.cs b/src/Cli/Microsoft.Maui.Cli.UnitTests/AppleCommandsTests.cs new file mode 100644 index 000000000..69524e794 --- /dev/null +++ b/src/Cli/Microsoft.Maui.Cli.UnitTests/AppleCommandsTests.cs @@ -0,0 +1,174 @@ +// Licensed to the .NET Foundation under one or more agreements. +// The .NET Foundation licenses this file to you under the MIT license. + +using System.CommandLine; +using System.CommandLine.Parsing; +using Microsoft.Maui.Cli.Commands; +using Microsoft.Maui.Cli.Providers.Apple; +using Microsoft.Maui.Cli.UnitTests.Fakes; +using Xunit; + +namespace Microsoft.Maui.Cli.UnitTests; + +public class AppleCommandsTests +{ + [Fact] + public void InstallCommand_Exists() + { + var appleCommand = AppleCommands.Create(); + Assert.Contains(appleCommand.Subcommands, c => c.Name == "install"); + } + + [Fact] + public void InstallCommand_HasPlatformOption() + { + var appleCommand = AppleCommands.Create(); + var installCommand = appleCommand.Subcommands.First(c => c.Name == "install"); + + Assert.Contains(installCommand.Options, o => o.Name == "--platform"); + } + + [Fact] + public void InstallCommand_DoesNotDeclareOwnDryRunOption() + { + // Regression: install should use GlobalOptions.DryRunOption, not a local --dry-run + var appleCommand = AppleCommands.Create(); + var installCommand = appleCommand.Subcommands.First(c => c.Name == "install"); + + Assert.DoesNotContain(installCommand.Options, o => o.Name == "--dry-run"); + } + + [Fact] + public void InstallCommand_ParsesPlatformOption() + { + var appleCommand = AppleCommands.Create(); + var installCommand = appleCommand.Subcommands.First(c => c.Name == "install"); + var platformOption = (Option)installCommand.Options.First(o => o.Name == "--platform"); + + var parseResult = installCommand.Parse("install --platform iOS --platform tvOS"); + + Assert.Empty(parseResult.Errors); + var platforms = parseResult.GetValue(platformOption); + Assert.NotNull(platforms); + Assert.Equal(2, platforms.Length); + Assert.Contains("iOS", platforms); + Assert.Contains("tvOS", platforms); + } + + // --- Handler-level tests --- + + static async Task<(int ExitCode, FakeAppleProvider Apple)> InvokeAppleInstallAsync( + Action? configure = null, + params string[] extraArgs) + { + var fakeApple = new FakeAppleProvider(); + configure?.Invoke(fakeApple); + + var testProvider = ServiceConfiguration.CreateTestServiceProvider(appleProvider: fakeApple); + var originalServices = Program.Services; + try + { + Program.Services = testProvider; + + var rootCommand = Program.BuildRootCommand(); + var args = new List { "apple", "install", "--json" }; + args.AddRange(extraArgs); + var parseResult = rootCommand.Parse(args.ToArray()); + var exitCode = await parseResult.InvokeAsync(); + return (exitCode, fakeApple); + } + finally + { + Program.ResetServices(); + } + } + + [Fact] + public async Task InstallCommand_Json_CallsInstallEnvironmentAsync() + { + var (exitCode, fake) = await InvokeAppleInstallAsync(f => + { + f.InstallResult = new AppleInstallResult + { + Status = "ok", + XcodeVersion = "16.0 (16A242d)", + CommandLineToolsInstalled = true, + Platforms = new List { "iOS", "tvOS" }, + Runtimes = new List { "iOS 18.0" }, + DryRun = false + }; + }); + + Assert.Equal(0, exitCode); + Assert.Single(fake.InstallCalls); + var (platforms, dryRun) = fake.InstallCalls[0]; + Assert.Null(platforms); + Assert.False(dryRun); + } + + [Fact] + public async Task InstallCommand_Json_PassesPlatformFilter() + { + var (exitCode, fake) = await InvokeAppleInstallAsync( + f => f.InstallResult = new AppleInstallResult { Status = "ok" }, + "--platform", "iOS"); + + Assert.Equal(0, exitCode); + Assert.Single(fake.InstallCalls); + var (platforms, _) = fake.InstallCalls[0]; + Assert.NotNull(platforms); + Assert.Contains("iOS", platforms); + } + + [Fact] + public async Task InstallCommand_Json_PassesDryRunFromGlobalOption() + { + var fakeApple = new FakeAppleProvider + { + InstallResult = new AppleInstallResult { Status = "ok", DryRun = true } + }; + + var testProvider = ServiceConfiguration.CreateTestServiceProvider(appleProvider: fakeApple); + var originalServices = Program.Services; + try + { + Program.Services = testProvider; + + var rootCommand = Program.BuildRootCommand(); + // --dry-run is a global option, placed before the subcommand path + var parseResult = rootCommand.Parse(new[] { "--dry-run", "apple", "install", "--json" }); + await parseResult.InvokeAsync(); + + Assert.Single(fakeApple.InstallCalls); + var (_, dryRun) = fakeApple.InstallCalls[0]; + Assert.True(dryRun); + } + finally + { + Program.ResetServices(); + } + } + + [Fact] + public async Task InstallCommand_Json_ReturnsZeroForSkippedStatus() + { + // On non-macOS (or when installer is null), status is "skipped" — should not be a failure + var (exitCode, _) = await InvokeAppleInstallAsync(f => + { + f.InstallResult = new AppleInstallResult { Status = "skipped" }; + }); + + Assert.Equal(0, exitCode); + } + + [Fact] + public async Task InstallCommand_Json_ReturnsOneForFailedStatus() + { + var (exitCode, _) = await InvokeAppleInstallAsync(f => + { + f.InstallResult = new AppleInstallResult { Status = "failed" }; + }); + + Assert.Equal(1, exitCode); + } +} From 58d836ec0ac0c100624e3a2634f53fa04361201b Mon Sep 17 00:00:00 2001 From: Rui Marinho Date: Thu, 30 Apr 2026 13:33:39 +0100 Subject: [PATCH 4/8] Address review feedback: fix docs, exit code, default platform - Fix doc to describe AppleInstallResult (not 'environment check') - Fix XcodeVersion doc comment to say 'version and build number' (not path) - Return exit code 1 on non-macOS for consistent signaling - Default --platform to iOS; add 'all' option to install all runtimes - Add test for --platform all passing null filter to provider Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> --- .../AppleCommandsTests.cs | 16 +++++++++++++++- .../Microsoft.Maui.Cli/Commands/AppleCommands.cs | 14 ++++++++++---- .../Providers/Apple/IAppleProvider.cs | 4 ++-- 3 files changed, 27 insertions(+), 7 deletions(-) diff --git a/src/Cli/Microsoft.Maui.Cli.UnitTests/AppleCommandsTests.cs b/src/Cli/Microsoft.Maui.Cli.UnitTests/AppleCommandsTests.cs index 69524e794..1808d8c99 100644 --- a/src/Cli/Microsoft.Maui.Cli.UnitTests/AppleCommandsTests.cs +++ b/src/Cli/Microsoft.Maui.Cli.UnitTests/AppleCommandsTests.cs @@ -102,7 +102,8 @@ public async Task InstallCommand_Json_CallsInstallEnvironmentAsync() Assert.Equal(0, exitCode); Assert.Single(fake.InstallCalls); var (platforms, dryRun) = fake.InstallCalls[0]; - Assert.Null(platforms); + Assert.NotNull(platforms); + Assert.Contains("iOS", platforms); Assert.False(dryRun); } @@ -120,6 +121,19 @@ public async Task InstallCommand_Json_PassesPlatformFilter() Assert.Contains("iOS", platforms); } + [Fact] + public async Task InstallCommand_Json_PlatformAllPassesNullFilter() + { + var (exitCode, fake) = await InvokeAppleInstallAsync( + f => f.InstallResult = new AppleInstallResult { Status = "ok" }, + "--platform", "all"); + + Assert.Equal(0, exitCode); + Assert.Single(fake.InstallCalls); + var (platforms, _) = fake.InstallCalls[0]; + Assert.Null(platforms); // "all" means no filter + } + [Fact] public async Task InstallCommand_Json_PassesDryRunFromGlobalOption() { diff --git a/src/Cli/Microsoft.Maui.Cli/Commands/AppleCommands.cs b/src/Cli/Microsoft.Maui.Cli/Commands/AppleCommands.cs index 9dab6f856..aa4816894 100644 --- a/src/Cli/Microsoft.Maui.Cli/Commands/AppleCommands.cs +++ b/src/Cli/Microsoft.Maui.Cli/Commands/AppleCommands.cs @@ -135,8 +135,9 @@ static Command CreateInstallCommand() { var platformOption = new Option("--platform") { - Description = "Platform(s) to ensure runtimes for (iOS, tvOS, watchOS, visionOS). If omitted, installs all available.", - AllowMultipleArgumentsPerToken = true + Description = "Platform(s) to ensure runtimes for (iOS, tvOS, watchOS, visionOS, all). Defaults to iOS only; use 'all' to install all available runtimes.", + AllowMultipleArgumentsPerToken = true, + DefaultValueFactory = _ => new[] { "iOS" } }; var installCommand = new Command("install", "Set up Apple development environment (CLT, runtimes)") @@ -151,7 +152,7 @@ static Command CreateInstallCommand() if (!PlatformDetector.IsMacOS) { formatter.WriteWarning("Apple install is only available on macOS."); - return 0; + return 1; } var appleProvider = Program.AppleProvider; @@ -164,8 +165,13 @@ static Command CreateInstallCommand() try { + // "all" means no filter — install runtimes for every available platform + var platformFilter = platforms is { Length: > 0 } && !platforms.Any(p => string.Equals(p, "all", StringComparison.OrdinalIgnoreCase)) + ? platforms + : null; + var result = await appleProvider.InstallEnvironmentAsync( - platforms is { Length: > 0 } ? platforms : null, + platformFilter, dryRun, ct); diff --git a/src/Cli/Microsoft.Maui.Cli/Providers/Apple/IAppleProvider.cs b/src/Cli/Microsoft.Maui.Cli/Providers/Apple/IAppleProvider.cs index 1af17a4b4..ee4564e4b 100644 --- a/src/Cli/Microsoft.Maui.Cli/Providers/Apple/IAppleProvider.cs +++ b/src/Cli/Microsoft.Maui.Cli/Providers/Apple/IAppleProvider.cs @@ -74,7 +74,7 @@ public interface IAppleProvider /// Optional set of platforms to ensure runtimes for (e.g., "iOS", "tvOS"). /// When true, reports what would be installed without making changes. /// Cancellation token. - /// The environment check result after install completes. + /// An describing what was installed (or would be installed in dry-run mode). Task InstallEnvironmentAsync(IEnumerable? platforms = null, bool dryRun = false, CancellationToken cancellationToken = default); /// @@ -91,7 +91,7 @@ public record AppleInstallResult /// Overall status of the environment after install. public required string Status { get; init; } - /// Xcode version and path, if found. + /// Xcode version and build number (e.g., "16.0 (16A242d)"), if found. public string? XcodeVersion { get; init; } /// Whether Command Line Tools are installed. From fe42511b083aff4b5f69f015ced93c9289ec28e1 Mon Sep 17 00:00:00 2001 From: Rui Marinho Date: Thu, 30 Apr 2026 14:07:11 +0100 Subject: [PATCH 5/8] Add Apple CLI smoke test script and update instructions - Create eng/smoke-tests/apple-cli-smoke-test.sh with 7 automated checks: xcode list, runtime list, simulator list, start/stop simulator, install dry-run (default iOS), install dry-run (all platforms) - Update copilot-instructions.md to run smoke tests after Apple provider changes or Xamarin.Apple.Tools.MaciOS version updates on macOS Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> --- .github/copilot-instructions.md | 47 +++++++ eng/smoke-tests/apple-cli-smoke-test.sh | 171 ++++++++++++++++++++++++ 2 files changed, 218 insertions(+) create mode 100755 eng/smoke-tests/apple-cli-smoke-test.sh diff --git a/.github/copilot-instructions.md b/.github/copilot-instructions.md index f9bccc0dd..e6fbc0c27 100644 --- a/.github/copilot-instructions.md +++ b/.github/copilot-instructions.md @@ -134,6 +134,53 @@ The repo is at 0.1.0-preview so breaking changes are acceptable, but: - **Signing**: configured in `eng/Signing.props`. New third-party DLLs need a `3PartySHA2` entry. - **Version**: defined in `eng/Versions.props` (`VersionPrefix` + `VersionSuffix`). Per-product overrides in `src/{Product}/Version.props`. +## Dependency Updates (darc) + +Upstream dependencies are managed via [darc/Maestro](https://github.com/dotnet/arcade/blob/main/Documentation/Darc.md). When updating a dependency: + +### Updating Xamarin.Apple.Tools.MaciOS + +```bash +darc update-dependencies --channel ".NET 10.0.1xx SDK" --name Xamarin.Apple.Tools.MaciOS +``` + +This updates both `eng/Version.Details.xml` (SHA + version) and `eng/Versions.props` (`XamarinAppleToolsMaciOSVersion`). + +**After every update**, check the commits between the old and new SHA for changes that affect CLI behavior: + +```bash +# Compare old..new SHA from Version.Details.xml +gh api repos/dotnet/macios-devtools/compare/{oldSha}...{newSha} --jq '.commits[] | .commit.message | split("\n")[0]' +``` + +Look for: +- New public APIs on `EnvironmentChecker`, `AppleInstaller`, `SimulatorService`, `RuntimeService` that the CLI could leverage +- Bug fixes to simctl JSON parsing, stdout pollution, or ILMerge compatibility that previously required workarounds in our code +- Breaking changes to method signatures the CLI calls (would require code updates) + +If the upstream fix removes the need for a workaround in our code, simplify the CLI code accordingly in the same PR. + +### Post-Update Smoke Tests (macOS only) + +After updating `Xamarin.Apple.Tools.MaciOS` or making changes to `src/Cli/.../Providers/Apple/` or `src/Cli/.../Commands/AppleCommands.cs`, **run the Apple CLI smoke tests** on macOS to verify nothing regressed: + +```bash +./eng/smoke-tests/apple-cli-smoke-test.sh +``` + +The script builds the CLI and runs these checks: +1. `maui apple xcode list --json` — detects installed Xcode +2. `maui apple runtime list --json` — lists simulator runtimes +3. `maui apple simulator list --json` — lists available simulators +4. `maui apple simulator start --json` — boots a simulator +5. `maui apple simulator stop --json` — shuts it down +6. `maui --dry-run apple install --json` — validates install flow (iOS default) +7. `maui --dry-run apple install --platform all --json` — validates all-platform install + +You can also pass a pre-built binary: `./eng/smoke-tests/apple-cli-smoke-test.sh path/to/maui` + +> **Note**: These tests require macOS with Xcode installed. They are skipped automatically on other platforms. If you are not on macOS, skip this step — CI on macOS runners will catch regressions. + ## CI/CD — New Product Checklist When adding a new product to this repo you **must** set up two CI surfaces: a GitHub Actions workflow for PR validation and a build job + publish stage in the Azure DevOps official pipeline for signing and NuGet.org publishing. diff --git a/eng/smoke-tests/apple-cli-smoke-test.sh b/eng/smoke-tests/apple-cli-smoke-test.sh new file mode 100755 index 000000000..b8084e19a --- /dev/null +++ b/eng/smoke-tests/apple-cli-smoke-test.sh @@ -0,0 +1,171 @@ +#!/bin/bash +# Apple CLI Smoke Tests +# --------------------- +# Validates the `maui apple` CLI commands work correctly on macOS. +# Run after changes to src/Cli/.../Providers/Apple/ or after updating +# the Xamarin.Apple.Tools.MaciOS package version. +# +# Prerequisites: +# - macOS with Xcode installed +# - .NET SDK (version per global.json) +# - At least one iOS simulator runtime installed +# +# Usage: +# ./eng/smoke-tests/apple-cli-smoke-test.sh [path-to-maui-binary] +# +# If no binary path is provided, builds the CLI in Debug mode first. + +set -euo pipefail + +RED='\033[0;31m' +GREEN='\033[0;32m' +YELLOW='\033[1;33m' +NC='\033[0m' # No Color + +PASS=0 +FAIL=0 +SKIP=0 + +pass() { echo -e " ${GREEN}✅ PASS${NC}: $1"; PASS=$((PASS + 1)); } +fail() { echo -e " ${RED}❌ FAIL${NC}: $1 — $2"; FAIL=$((FAIL + 1)); } +skip() { echo -e " ${YELLOW}⏭️ SKIP${NC}: $1 — $2"; SKIP=$((SKIP + 1)); } + +REPO_ROOT="$(cd "$(dirname "$0")/../.." && pwd)" +cd "$REPO_ROOT" + +# Determine the CLI binary path +if [[ $# -ge 1 ]]; then + MAUI="$1" +else + echo "Building CLI in Debug mode..." + dotnet build src/Cli/Microsoft.Maui.Cli/Microsoft.Maui.Cli.csproj -c Debug --nologo -v q + MAUI="$REPO_ROOT/artifacts/bin/Microsoft.Maui.Cli/Debug/net10.0/maui" +fi + +if [[ ! -x "$MAUI" ]]; then + echo -e "${RED}ERROR${NC}: CLI binary not found or not executable at: $MAUI" + exit 1 +fi + +echo "" +echo "========================================" +echo " Apple CLI Smoke Tests" +echo " Binary: $MAUI" +echo "========================================" +echo "" + +# Check we're on macOS +if [[ "$(uname)" != "Darwin" ]]; then + echo -e "${RED}ERROR${NC}: These smoke tests require macOS." + exit 1 +fi + +# --- Test 1: Xcode List --- +echo "Test 1: maui apple xcode list --json" +OUTPUT=$($MAUI apple xcode list --json 2>&1) || true +if echo "$OUTPUT" | grep -q '"path"'; then + XCODE_VER=$(echo "$OUTPUT" | grep '"version"' | head -1 | sed 's/.*: "//;s/".*//') + pass "Xcode found (version: $XCODE_VER)" +else + fail "Xcode list" "No Xcode installations found in JSON output" +fi + +# --- Test 2: Runtime List --- +echo "Test 2: maui apple runtime list --json" +OUTPUT=$($MAUI apple runtime list --json 2>&1) || true +if echo "$OUTPUT" | grep -q '"identifier"'; then + RUNTIME_COUNT=$(echo "$OUTPUT" | grep -c '"identifier"' || true) + pass "Runtimes listed ($RUNTIME_COUNT runtime(s))" +else + fail "Runtime list" "No runtimes found in JSON output" +fi + +# --- Test 3: Simulator List --- +echo "Test 3: maui apple simulator list --json" +SIM_OUTPUT=$($MAUI apple simulator list --json 2>&1) || true +if echo "$SIM_OUTPUT" | grep -q '"udid"'; then + SIM_COUNT=$(echo "$SIM_OUTPUT" | grep -c '"udid"' || true) + pass "Simulators listed ($SIM_COUNT simulator(s))" +else + fail "Simulator list" "No simulators found in JSON output" +fi + +# --- Test 4: Simulator Start --- +echo "Test 4: maui apple simulator start (boot a simulator)" +# Find the first available iPhone simulator name +SIM_NAME=$(echo "$SIM_OUTPUT" | python3 -c " +import sys, json + +text = sys.stdin.read() +lines = text.split('\n') +json_start = next((i for i, l in enumerate(lines) if l.strip().startswith('[')), None) +if json_start is None: + sys.exit(1) +json_text = '\n'.join(lines[json_start:]) +# Find end of array +bracket_count = 0 +end = 0 +for i, ch in enumerate(json_text): + if ch == '[': bracket_count += 1 + elif ch == ']': bracket_count -= 1 + if bracket_count == 0: + end = i + 1 + break +data = json.loads(json_text[:end]) +for d in data: + if d.get('is_available') and not d.get('is_booted') and 'iPhone' in d.get('name', ''): + print(d['name']) + sys.exit(0) +sys.exit(1) +" 2>/dev/null) || SIM_NAME="" + +if [[ -z "$SIM_NAME" ]]; then + skip "Simulator start" "No available iPhone simulator found to boot" +else + START_OUTPUT=$($MAUI apple simulator start "$SIM_NAME" --json 2>&1) || true + if echo "$START_OUTPUT" | grep -q '"success"' || echo "$START_OUTPUT" | grep -q '"status": "success"'; then + pass "Simulator '$SIM_NAME' booted" + + # --- Test 5: Simulator Stop --- + echo "Test 5: maui apple simulator stop (shut down simulator)" + STOP_OUTPUT=$($MAUI apple simulator stop "$SIM_NAME" --json 2>&1) || true + if echo "$STOP_OUTPUT" | grep -q '"success"' || echo "$STOP_OUTPUT" | grep -q '"status": "success"'; then + pass "Simulator '$SIM_NAME' stopped" + else + fail "Simulator stop" "Failed to shut down '$SIM_NAME'" + fi + else + fail "Simulator start" "Failed to boot '$SIM_NAME'" + SKIP=$((SKIP + 1)) # skip the stop test + fi +fi + +# --- Test 6: Install (dry-run, default platform = iOS) --- +echo "Test 6: maui --dry-run apple install --json" +INSTALL_OUTPUT=$($MAUI --dry-run apple install --json 2>&1) || true +if echo "$INSTALL_OUTPUT" | grep -q '"status"'; then + STATUS=$(echo "$INSTALL_OUTPUT" | grep '"status"' | head -1 | sed 's/.*: "//;s/".*//') + pass "Install dry-run completed (status: $STATUS)" +else + fail "Install dry-run" "No status in JSON output" +fi + +# --- Test 7: Install with --platform all (dry-run) --- +echo "Test 7: maui --dry-run apple install --platform all --json" +INSTALL_ALL_OUTPUT=$($MAUI --dry-run apple install --platform all --json 2>&1) || true +if echo "$INSTALL_ALL_OUTPUT" | grep -q '"status"'; then + pass "Install --platform all dry-run completed" +else + fail "Install --platform all dry-run" "No status in JSON output" +fi + +# --- Summary --- +echo "" +echo "========================================" +echo -e " Results: ${GREEN}$PASS passed${NC}, ${RED}$FAIL failed${NC}, ${YELLOW}$SKIP skipped${NC}" +echo "========================================" + +if [[ $FAIL -gt 0 ]]; then + exit 1 +fi +exit 0 From 43637d1f450f192961c95d27a81705378a6e630a Mon Sep 17 00:00:00 2001 From: Rui Marinho Date: Thu, 30 Apr 2026 14:10:48 +0100 Subject: [PATCH 6/8] Update version --- eng/Version.Details.xml | 4 ++-- eng/Versions.props | 2 +- 2 files changed, 3 insertions(+), 3 deletions(-) diff --git a/eng/Version.Details.xml b/eng/Version.Details.xml index c8aa19537..00753bee9 100644 --- a/eng/Version.Details.xml +++ b/eng/Version.Details.xml @@ -32,9 +32,9 @@ https://github.com/dotnet/dotnet 73b3b5ac0e4a5658c7a0555b67d91a22ad39de4b - + https://github.com/dotnet/macios-devtools - 0c544e8cf5ed4ba7ce7c7cc10802a365fd9ace30 + 11fad7fe464a7de7dd809542e4bb352310236052 diff --git a/eng/Versions.props b/eng/Versions.props index 89b894c19..d217ca52f 100644 --- a/eng/Versions.props +++ b/eng/Versions.props @@ -47,7 +47,7 @@ - 1.0.0-preview.1.26228.1 + 1.0.0-preview.1.26230.1 0.3.0 From 7cdf1aa3e5907bd2b3b4786520c294ff26684e6b Mon Sep 17 00:00:00 2001 From: Rui Marinho Date: Thu, 30 Apr 2026 14:19:09 +0100 Subject: [PATCH 7/8] Open Simulator UI automatically on 'maui apple simulator start' - Add --no-open flag to suppress UI launch (for CI/headless usage) - Add OpenSimulatorApp() to IAppleProvider interface - Implement via 'open -a Simulator' in AppleProvider - Default behavior: boot device + open Simulator.app window Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> --- .../Fakes/FakeAppleProvider.cs | 2 ++ src/Cli/Microsoft.Maui.Cli/Commands/AppleCommands.cs | 12 ++++++++++-- .../Providers/Apple/AppleProvider.cs | 6 ++++++ .../Providers/Apple/IAppleProvider.cs | 5 +++++ 4 files changed, 23 insertions(+), 2 deletions(-) diff --git a/src/Cli/Microsoft.Maui.Cli.UnitTests/Fakes/FakeAppleProvider.cs b/src/Cli/Microsoft.Maui.Cli.UnitTests/Fakes/FakeAppleProvider.cs index f464f984b..1661146cc 100644 --- a/src/Cli/Microsoft.Maui.Cli.UnitTests/Fakes/FakeAppleProvider.cs +++ b/src/Cli/Microsoft.Maui.Cli.UnitTests/Fakes/FakeAppleProvider.cs @@ -74,6 +74,8 @@ public bool BootSimulator(string udidOrName) return BootSimulatorResult; } + public void OpenSimulatorApp() { } + public bool ShutdownSimulator(string udidOrName) { ShutdownSimulators.Add(udidOrName); diff --git a/src/Cli/Microsoft.Maui.Cli/Commands/AppleCommands.cs b/src/Cli/Microsoft.Maui.Cli/Commands/AppleCommands.cs index aa4816894..a45504efe 100644 --- a/src/Cli/Microsoft.Maui.Cli/Commands/AppleCommands.cs +++ b/src/Cli/Microsoft.Maui.Cli/Commands/AppleCommands.cs @@ -258,9 +258,10 @@ static Command CreateSimulatorCommand() return 0; }); - // maui apple simulator start + // maui apple simulator start [--no-open] var startNameArg = new Argument("name-or-udid") { Description = "Simulator name or UDID to boot" }; - var startCommand = new Command("start", "Boot a simulator") { startNameArg }; + var noOpenOption = new Option("--no-open") { Description = "Do not open the Simulator UI window after booting" }; + var startCommand = new Command("start", "Boot a simulator and open the Simulator UI") { startNameArg, noOpenOption }; startCommand.SetAction((ParseResult parseResult) => { var formatter = Program.GetFormatter(parseResult); @@ -273,12 +274,19 @@ static Command CreateSimulatorCommand() var appleProvider = Program.AppleProvider; var target = parseResult.GetValue(startNameArg); + var noOpen = parseResult.GetValue(noOpenOption); var success = appleProvider.BootSimulator(target!); if (success) + { + if (!noOpen) + appleProvider.OpenSimulatorApp(); formatter.WriteSuccess($"Simulator '{target}' booted."); + } else + { formatter.WriteWarning($"Failed to boot simulator '{target}'."); + } return success ? 0 : 1; }); diff --git a/src/Cli/Microsoft.Maui.Cli/Providers/Apple/AppleProvider.cs b/src/Cli/Microsoft.Maui.Cli/Providers/Apple/AppleProvider.cs index 68a3b7a00..a03d4c399 100644 --- a/src/Cli/Microsoft.Maui.Cli/Providers/Apple/AppleProvider.cs +++ b/src/Cli/Microsoft.Maui.Cli/Providers/Apple/AppleProvider.cs @@ -138,6 +138,12 @@ public bool BootSimulator(string udidOrName) return _simulatorService?.Boot(udidOrName) ?? false; } + public void OpenSimulatorApp() + { + using var process = System.Diagnostics.Process.Start("open", ["-a", "Simulator"]); + process?.WaitForExit(5000); + } + public bool ShutdownSimulator(string udidOrName) { return _simulatorService?.Shutdown(udidOrName) ?? false; diff --git a/src/Cli/Microsoft.Maui.Cli/Providers/Apple/IAppleProvider.cs b/src/Cli/Microsoft.Maui.Cli/Providers/Apple/IAppleProvider.cs index ee4564e4b..99628a4b2 100644 --- a/src/Cli/Microsoft.Maui.Cli/Providers/Apple/IAppleProvider.cs +++ b/src/Cli/Microsoft.Maui.Cli/Providers/Apple/IAppleProvider.cs @@ -46,6 +46,11 @@ public interface IAppleProvider /// bool BootSimulator(string udidOrName); + /// + /// Opens the Simulator.app UI window. + /// + void OpenSimulatorApp(); + /// /// Shuts down a simulator device. Pass "all" to shut down all. /// From 2d3f5e8299ffcd1af920b5cb00138661c900f330 Mon Sep 17 00:00:00 2001 From: Rui Marinho Date: Thu, 30 Apr 2026 15:23:09 +0100 Subject: [PATCH 8/8] Fix Apple install tests on Windows: skip handler tests on non-macOS The install command handler returns exit code 1 on non-macOS platforms (by design). Skip the handler-invocation tests when not running on macOS since they test provider interaction, not the platform guard. Command structure tests (Exists, HasPlatformOption, ParsesPlatformOption, ParsesPlatformDefault) remain cross-platform. Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> --- .../AppleCommandsTests.cs | 19 +++++++++++++++++++ 1 file changed, 19 insertions(+) diff --git a/src/Cli/Microsoft.Maui.Cli.UnitTests/AppleCommandsTests.cs b/src/Cli/Microsoft.Maui.Cli.UnitTests/AppleCommandsTests.cs index 1808d8c99..90bd00ba5 100644 --- a/src/Cli/Microsoft.Maui.Cli.UnitTests/AppleCommandsTests.cs +++ b/src/Cli/Microsoft.Maui.Cli.UnitTests/AppleCommandsTests.cs @@ -3,6 +3,7 @@ using System.CommandLine; using System.CommandLine.Parsing; +using System.Runtime.InteropServices; using Microsoft.Maui.Cli.Commands; using Microsoft.Maui.Cli.Providers.Apple; using Microsoft.Maui.Cli.UnitTests.Fakes; @@ -86,6 +87,9 @@ public void InstallCommand_ParsesPlatformOption() [Fact] public async Task InstallCommand_Json_CallsInstallEnvironmentAsync() { + if (!RuntimeInformation.IsOSPlatform(OSPlatform.OSX)) + return; // Install handler requires macOS + var (exitCode, fake) = await InvokeAppleInstallAsync(f => { f.InstallResult = new AppleInstallResult @@ -110,6 +114,9 @@ public async Task InstallCommand_Json_CallsInstallEnvironmentAsync() [Fact] public async Task InstallCommand_Json_PassesPlatformFilter() { + if (!RuntimeInformation.IsOSPlatform(OSPlatform.OSX)) + return; // Install handler requires macOS + var (exitCode, fake) = await InvokeAppleInstallAsync( f => f.InstallResult = new AppleInstallResult { Status = "ok" }, "--platform", "iOS"); @@ -124,6 +131,9 @@ public async Task InstallCommand_Json_PassesPlatformFilter() [Fact] public async Task InstallCommand_Json_PlatformAllPassesNullFilter() { + if (!RuntimeInformation.IsOSPlatform(OSPlatform.OSX)) + return; // Install handler requires macOS + var (exitCode, fake) = await InvokeAppleInstallAsync( f => f.InstallResult = new AppleInstallResult { Status = "ok" }, "--platform", "all"); @@ -137,6 +147,9 @@ public async Task InstallCommand_Json_PlatformAllPassesNullFilter() [Fact] public async Task InstallCommand_Json_PassesDryRunFromGlobalOption() { + if (!RuntimeInformation.IsOSPlatform(OSPlatform.OSX)) + return; // Install handler requires macOS + var fakeApple = new FakeAppleProvider { InstallResult = new AppleInstallResult { Status = "ok", DryRun = true } @@ -166,6 +179,9 @@ public async Task InstallCommand_Json_PassesDryRunFromGlobalOption() [Fact] public async Task InstallCommand_Json_ReturnsZeroForSkippedStatus() { + if (!RuntimeInformation.IsOSPlatform(OSPlatform.OSX)) + return; // Install handler requires macOS + // On non-macOS (or when installer is null), status is "skipped" — should not be a failure var (exitCode, _) = await InvokeAppleInstallAsync(f => { @@ -178,6 +194,9 @@ public async Task InstallCommand_Json_ReturnsZeroForSkippedStatus() [Fact] public async Task InstallCommand_Json_ReturnsOneForFailedStatus() { + if (!RuntimeInformation.IsOSPlatform(OSPlatform.OSX)) + return; // Install handler requires macOS + var (exitCode, _) = await InvokeAppleInstallAsync(f => { f.InstallResult = new AppleInstallResult { Status = "failed" };