diff --git a/Directory.Packages.props b/Directory.Packages.props index 9d701c70..2ea1d115 100644 --- a/Directory.Packages.props +++ b/Directory.Packages.props @@ -39,6 +39,7 @@ + diff --git a/eng/Version.Details.xml b/eng/Version.Details.xml index ceb052e0..37298737 100644 --- a/eng/Version.Details.xml +++ b/eng/Version.Details.xml @@ -32,6 +32,10 @@ https://github.com/dotnet/dotnet 73b3b5ac0e4a5658c7a0555b67d91a22ad39de4b + + https://github.com/dotnet/macios-devtools + 14efcb9735fab369066fa3c99130d076aa0dfdc9 + diff --git a/eng/Versions.props b/eng/Versions.props index cb0f2a20..9cc80e44 100644 --- a/eng/Versions.props +++ b/eng/Versions.props @@ -44,6 +44,10 @@ 1.0.143-preview.8 + + + 1.0.0-preview.1.26201.1 + 0.3.0 $(PlatformMauiMacOSVersion) diff --git a/src/Cli/Microsoft.Maui.Cli.UnitTests/DeviceManagerTests.cs b/src/Cli/Microsoft.Maui.Cli.UnitTests/DeviceManagerTests.cs index ad6c5125..082948ca 100644 --- a/src/Cli/Microsoft.Maui.Cli.UnitTests/DeviceManagerTests.cs +++ b/src/Cli/Microsoft.Maui.Cli.UnitTests/DeviceManagerTests.cs @@ -33,6 +33,70 @@ public async Task GetAllDevicesAsync_ReturnsAndroidDevices() Assert.Contains(devices, d => d.Platforms.Contains("android")); } + [Fact] + public async Task GetAllDevicesAsync_ReturnsAppleSimulators() + { + // Arrange + var fakeApple = new FakeAppleProvider + { + Devices = new List + { + new Device + { + Id = "sim-udid-1234", + Name = "iPhone 15 Pro", + Platforms = new[] { "ios" }, + Type = DeviceType.Simulator, + State = DeviceState.Booted, + IsEmulator = true, + IsRunning = true, + EmulatorId = "sim-udid-1234", + Version = "18.0" + } + } + }; + + var manager = new DeviceManager(appleProvider: fakeApple); + + // Act + var devices = await manager.GetAllDevicesAsync(); + + // Assert + Assert.Single(devices); + Assert.Contains(devices, d => d.Platforms.Contains("ios")); + Assert.Equal(DeviceType.Simulator, devices[0].Type); + } + + [Fact] + public async Task GetAllDevicesAsync_ReturnsBothAndroidAndApple() + { + // Arrange + var fakeAndroid = new FakeAndroidProvider + { + Devices = new List + { + new Device { Id = "emulator-5554", Name = "Pixel 6", Platforms = new[] { "android" }, Type = DeviceType.Emulator, State = DeviceState.Booted, IsEmulator = true, IsRunning = true } + } + }; + var fakeApple = new FakeAppleProvider + { + Devices = new List + { + new Device { Id = "sim-udid", Name = "iPhone 15", Platforms = new[] { "ios" }, Type = DeviceType.Simulator, State = DeviceState.Booted, IsEmulator = true, IsRunning = true } + } + }; + + var manager = new DeviceManager(fakeAndroid, fakeApple); + + // Act + var devices = await manager.GetAllDevicesAsync(); + + // Assert + Assert.Equal(2, devices.Count); + Assert.Contains(devices, d => d.Platforms.Contains("android")); + Assert.Contains(devices, d => d.Platforms.Contains("ios")); + } + [Fact] public async Task GetDevicesByPlatformAsync_FiltersCorrectly() { @@ -55,6 +119,35 @@ public async Task GetDevicesByPlatformAsync_FiltersCorrectly() Assert.All(androidOnly, d => Assert.Contains("android", d.Platforms)); } + [Fact] + public async Task GetDevicesByPlatformAsync_FiltersIosDevices() + { + // Arrange + var fakeAndroid = new FakeAndroidProvider + { + Devices = new List + { + new Device { Id = "emulator-5554", Name = "Pixel 6", Platforms = new[] { "android" }, Type = DeviceType.Emulator, State = DeviceState.Booted, IsEmulator = true, IsRunning = true } + } + }; + var fakeApple = new FakeAppleProvider + { + Devices = new List + { + new Device { Id = "sim-udid", Name = "iPhone 15", Platforms = new[] { "ios" }, Type = DeviceType.Simulator, State = DeviceState.Booted, IsEmulator = true, IsRunning = true } + } + }; + + var manager = new DeviceManager(fakeAndroid, fakeApple); + + // Act + var iosOnly = await manager.GetDevicesByPlatformAsync("ios"); + + // Assert + Assert.Single(iosOnly); + Assert.All(iosOnly, d => Assert.Contains("ios", d.Platforms)); + } + [Fact] public async Task GetDeviceByIdAsync_FindsCorrectDevice() { diff --git a/src/Cli/Microsoft.Maui.Cli.UnitTests/DoctorServiceTests.cs b/src/Cli/Microsoft.Maui.Cli.UnitTests/DoctorServiceTests.cs index bd9553d7..55dc4d7f 100644 --- a/src/Cli/Microsoft.Maui.Cli.UnitTests/DoctorServiceTests.cs +++ b/src/Cli/Microsoft.Maui.Cli.UnitTests/DoctorServiceTests.cs @@ -59,6 +59,41 @@ public async Task RunAllChecksAsync_IncludesAndroidChecks_WhenProviderReturnsChe Assert.Contains(report.Checks, c => c.Category == "android" && c.Name == "Android SDK"); } + [Fact] + public async Task RunAllChecksAsync_IncludesAppleChecks_WhenProviderReturnsChecks() + { + // Arrange + var fakeApple = new FakeAppleProvider + { + HealthChecks = new List + { + new HealthCheck + { + Category = "apple", + Name = "Xcode", + Status = CheckStatus.Ok, + Message = "Xcode 16.0" + }, + new HealthCheck + { + Category = "apple", + Name = "Command Line Tools", + Status = CheckStatus.Ok, + Message = "CLT installed" + } + } + }; + + var service = new DoctorService(appleProvider: fakeApple); + + // Act + var report = await service.RunAllChecksAsync(); + + // Assert + Assert.Contains(report.Checks, c => c.Category == "apple" && c.Name == "Xcode"); + Assert.Contains(report.Checks, c => c.Category == "apple" && c.Name == "Command Line Tools"); + } + [Fact] public async Task RunAllChecksAsync_CalculatesCorrectSummary() { @@ -105,6 +140,27 @@ public async Task RunCategoryChecksAsync_SetsStatusBasedOnChecks() Assert.NotEqual(HealthStatus.Unhealthy, report.Status); } + [Fact] + public async Task RunCategoryChecksAsync_AppleCategory_ReturnsAppleChecks() + { + // Arrange + var fakeApple = new FakeAppleProvider + { + HealthChecks = new List + { + new HealthCheck { Category = "apple", Name = "Xcode", Status = CheckStatus.Ok, Message = "Xcode 16.0" } + } + }; + + var service = new DoctorService(appleProvider: fakeApple); + + // Act + var report = await service.RunCategoryChecksAsync("apple"); + + // Assert + Assert.Contains(report.Checks, c => c.Category == "apple" && c.Name == "Xcode"); + } + [Fact] public async Task RunAllChecksAsync_IncludesAndroidChecks_WhenProviderReturnsAndroidOnly() { @@ -125,4 +181,33 @@ public async Task RunAllChecksAsync_IncludesAndroidChecks_WhenProviderReturnsAnd // Assert - android checks should be present Assert.Contains(report.Checks, c => c.Category == "android" && c.Name == "JDK"); } + + [Fact] + public async Task RunAllChecksAsync_BothProviders_IncludesBothChecks() + { + // Arrange + var fakeAndroid = new FakeAndroidProvider + { + HealthChecks = new List + { + new HealthCheck { Category = "android", Name = "JDK", Status = CheckStatus.Ok } + } + }; + var fakeApple = new FakeAppleProvider + { + HealthChecks = new List + { + new HealthCheck { Category = "apple", Name = "Xcode", Status = CheckStatus.Ok } + } + }; + + var service = new DoctorService(fakeAndroid, fakeApple); + + // Act + var report = await service.RunAllChecksAsync(); + + // Assert + Assert.Contains(report.Checks, c => c.Category == "android"); + Assert.Contains(report.Checks, c => c.Category == "apple"); + } } diff --git a/src/Cli/Microsoft.Maui.Cli.UnitTests/Fakes/FakeAppleProvider.cs b/src/Cli/Microsoft.Maui.Cli.UnitTests/Fakes/FakeAppleProvider.cs new file mode 100644 index 00000000..6c863176 --- /dev/null +++ b/src/Cli/Microsoft.Maui.Cli.UnitTests/Fakes/FakeAppleProvider.cs @@ -0,0 +1,96 @@ +// Licensed to the .NET Foundation under one or more agreements. +// The .NET Foundation licenses this file to you under the MIT license. + +using Microsoft.Maui.Cli.Models; +using Microsoft.Maui.Cli.Providers.Apple; + +namespace Microsoft.Maui.Cli.UnitTests.Fakes; + +/// +/// Hand-written fake for used in unit tests. +/// Set the public properties to control return values; inspect the tracking +/// lists to verify which methods were called and with what arguments. +/// +public class FakeAppleProvider : IAppleProvider +{ + // --- Configurable return values --- + + public List XcodeInstallations { get; set; } = new(); + public XcodeInstallation? SelectedXcode { get; set; } + public CommandLineToolsStatus CltStatus { get; set; } = new(); + public List Runtimes { get; set; } = new(); + public List Simulators { get; set; } = new(); + public List HealthChecks { get; set; } = new(); + public List Devices { get; set; } = new(); + + public bool SelectXcodeResult { get; set; } = true; + public bool BootSimulatorResult { get; set; } = true; + public bool ShutdownSimulatorResult { get; set; } = true; + public bool DeleteSimulatorResult { get; set; } = true; + public string? CreateSimulatorResult { get; set; } = "new-udid"; + + // --- Call tracking --- + + public List SelectedXcodePaths { get; } = new(); + public List BootedSimulators { get; } = new(); + public List ShutdownSimulators { get; } = new(); + public List DeletedSimulators { get; } = new(); + public List<(string Name, string DeviceType, string? Runtime)> CreatedSimulators { get; } = new(); + + // --- IAppleProvider implementation --- + + public List GetXcodeInstallations() => XcodeInstallations; + + public XcodeInstallation? GetSelectedXcode() => SelectedXcode; + + public bool SelectXcode(string path) + { + SelectedXcodePaths.Add(path); + return SelectXcodeResult; + } + + public CommandLineToolsStatus GetCommandLineToolsStatus() => CltStatus; + + public List GetRuntimes(string? platform = null, bool availableOnly = false) + { + var result = Runtimes; + if (platform is not null) + result = result.Where(r => string.Equals(r.Platform, platform, StringComparison.OrdinalIgnoreCase)).ToList(); + if (availableOnly) + result = result.Where(r => r.IsAvailable).ToList(); + return result; + } + + public List GetSimulators(bool availableOnly = false) + { + return availableOnly ? Simulators.Where(s => s.IsAvailable).ToList() : Simulators; + } + + public bool BootSimulator(string udidOrName) + { + BootedSimulators.Add(udidOrName); + return BootSimulatorResult; + } + + public bool ShutdownSimulator(string udidOrName) + { + ShutdownSimulators.Add(udidOrName); + return ShutdownSimulatorResult; + } + + public bool DeleteSimulator(string udidOrName) + { + DeletedSimulators.Add(udidOrName); + return DeleteSimulatorResult; + } + + public string? CreateSimulator(string name, string deviceTypeIdentifier, string? runtimeIdentifier = null) + { + CreatedSimulators.Add((name, deviceTypeIdentifier, runtimeIdentifier)); + return CreateSimulatorResult; + } + + public List CheckHealth() => HealthChecks; + + public List GetDevices() => Devices; +} diff --git a/src/Cli/Microsoft.Maui.Cli.UnitTests/ServiceConfigurationTests.cs b/src/Cli/Microsoft.Maui.Cli.UnitTests/ServiceConfigurationTests.cs index 0d45df3d..53a57f07 100644 --- a/src/Cli/Microsoft.Maui.Cli.UnitTests/ServiceConfigurationTests.cs +++ b/src/Cli/Microsoft.Maui.Cli.UnitTests/ServiceConfigurationTests.cs @@ -4,6 +4,7 @@ using Microsoft.Extensions.DependencyInjection; using Microsoft.Maui.Cli.DevFlow; using Microsoft.Maui.Cli.Providers.Android; +using Microsoft.Maui.Cli.Providers.Apple; using Microsoft.Maui.Cli.Services; using Microsoft.Maui.Cli.UnitTests.Fakes; using Xunit; @@ -21,6 +22,7 @@ public void CreateServiceProvider_RegistersAllServices() // Assert Assert.NotNull(provider.GetService()); Assert.NotNull(provider.GetService()); + Assert.NotNull(provider.GetService()); Assert.NotNull(provider.GetService()); Assert.NotNull(provider.GetService()); Assert.NotNull(provider.GetService()); @@ -33,9 +35,12 @@ public void CreateServiceProvider_ReturnsSingletonForProviders() var provider = ServiceConfiguration.CreateServiceProvider(); var android1 = provider.GetService(); var android2 = provider.GetService(); + var apple1 = provider.GetService(); + var apple2 = provider.GetService(); // Assert Assert.Same(android1, android2); + Assert.Same(apple1, apple2); } [Fact] @@ -43,13 +48,16 @@ public void CreateTestServiceProvider_UsesProvidedFakes() { // Arrange var fakeAndroid = new FakeAndroidProvider(); + var fakeApple = new FakeAppleProvider(); // Act var provider = ServiceConfiguration.CreateTestServiceProvider( - androidProvider: fakeAndroid); + androidProvider: fakeAndroid, + appleProvider: fakeApple); // Assert Assert.Same(fakeAndroid, provider.GetService()); + Assert.Same(fakeApple, provider.GetService()); } [Fact] @@ -64,6 +72,7 @@ public void CreateTestServiceProvider_CreatesMissingServices() // Assert - should create real services for everything else Assert.Same(fakeAndroid, provider.GetService()); + Assert.NotNull(provider.GetService()); Assert.NotNull(provider.GetService()); } @@ -74,14 +83,17 @@ public void Program_Services_CanBeOverridden() { // Arrange var fakeAndroid = new FakeAndroidProvider(); + var fakeApple = new FakeAppleProvider(); var testProvider = ServiceConfiguration.CreateTestServiceProvider( - androidProvider: fakeAndroid); + androidProvider: fakeAndroid, + appleProvider: fakeApple); // Act Program.Services = testProvider; // Assert Assert.Same(fakeAndroid, Program.AndroidProvider); + Assert.Same(fakeApple, Program.AppleProvider); } finally { diff --git a/src/Cli/Microsoft.Maui.Cli/Commands/AppleCommands.cs b/src/Cli/Microsoft.Maui.Cli/Commands/AppleCommands.cs new file mode 100644 index 00000000..fc015533 --- /dev/null +++ b/src/Cli/Microsoft.Maui.Cli/Commands/AppleCommands.cs @@ -0,0 +1,258 @@ +// 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.Output; +using Microsoft.Maui.Cli.Providers.Apple; +using Microsoft.Maui.Cli.Utils; + +namespace Microsoft.Maui.Cli.Commands; + +/// +/// Implementation of 'maui apple' command group. +/// Sub-commands: xcode, runtime, simulator. +/// +public static class AppleCommands +{ + public static Command Create() + { + var command = new Command("apple", "Apple platform management (Xcode, simulators, runtimes)"); + + command.Add(CreateXcodeCommand()); + command.Add(CreateRuntimeCommand()); + command.Add(CreateSimulatorCommand()); + + return command; + } + + static Command CreateXcodeCommand() + { + var xcodeCommand = new Command("xcode", "Manage Xcode installations"); + + // maui apple xcode list + var listCommand = new Command("list", "List installed Xcode versions"); + listCommand.SetAction((ParseResult parseResult) => + { + var formatter = Program.GetFormatter(parseResult); + + if (!PlatformDetector.IsMacOS) + { + formatter.WriteWarning("Xcode is only available on macOS."); + return 1; + } + + var appleProvider = Program.AppleProvider; + var useJson = parseResult.GetValue(GlobalOptions.JsonOption); + + var installations = appleProvider.GetXcodeInstallations(); + if (useJson) + { + formatter.Write(installations); + } + else + { + if (!installations.Any()) + { + formatter.WriteWarning("No Xcode installations found."); + return 0; + } + + if (formatter is SpectreOutputFormatter spectre) + { + spectre.WriteTable(installations, + ("Version", x => x.Version ?? "?"), + ("Build", x => x.Build ?? "?"), + ("Path", x => x.Path), + ("Selected", x => x.IsSelected ? "✓" : "")); + } + } + return 0; + }); + + xcodeCommand.Add(listCommand); + return xcodeCommand; + } + + static Command CreateRuntimeCommand() + { + var runtimeCommand = new Command("runtime", "Manage simulator runtimes"); + + // maui apple runtime list [--platform ios] + var platformOption = new Option("--platform") { Description = "Filter by platform (iOS, tvOS, watchOS, visionOS)" }; + var listCommand = new Command("list", "List installed simulator runtimes") + { + platformOption + }; + + listCommand.SetAction((ParseResult parseResult) => + { + var formatter = Program.GetFormatter(parseResult); + + if (!PlatformDetector.IsMacOS) + { + formatter.WriteWarning("Runtimes are only available on macOS."); + return 1; + } + + var appleProvider = Program.AppleProvider; + var useJson = parseResult.GetValue(GlobalOptions.JsonOption); + var platform = parseResult.GetValue(platformOption); + + var runtimes = appleProvider.GetRuntimes(platform, availableOnly: false); + if (useJson) + { + formatter.Write(runtimes); + } + else + { + if (!runtimes.Any()) + { + formatter.WriteWarning("No simulator runtimes found."); + return 0; + } + + if (formatter is SpectreOutputFormatter spectre) + { + spectre.WriteTable(runtimes, + ("Name", r => r.Name), + ("Platform", r => r.Platform ?? "?"), + ("Version", r => r.Version ?? "?"), + ("Available", r => r.IsAvailable ? "✓" : "✗"), + ("Bundled", r => r.IsBundled ? "Yes" : "No")); + } + } + return 0; + }); + + runtimeCommand.Add(listCommand); + return runtimeCommand; + } + + static Command CreateSimulatorCommand() + { + var simCommand = new Command("simulator", "Manage iOS simulators"); + + // maui apple simulator list + var listCommand = new Command("list", "List simulator devices"); + listCommand.SetAction((ParseResult parseResult) => + { + var formatter = Program.GetFormatter(parseResult); + + if (!PlatformDetector.IsMacOS) + { + formatter.WriteWarning("Simulators are only available on macOS."); + return 1; + } + + var appleProvider = Program.AppleProvider; + var useJson = parseResult.GetValue(GlobalOptions.JsonOption); + + var simulators = appleProvider.GetSimulators(availableOnly: false); + if (useJson) + { + formatter.Write(simulators); + } + else + { + if (!simulators.Any()) + { + formatter.WriteWarning("No simulators found."); + return 0; + } + + if (formatter is SpectreOutputFormatter spectre) + { + spectre.WriteTable(simulators, + ("Name", s => s.Name), + ("UDID", s => s.Udid), + ("OS", s => $"{s.Platform} {s.OSVersion}"), + ("State", s => s.IsBooted ? "Booted" : s.State ?? "Shutdown"), + ("Available", s => s.IsAvailable ? "✓" : "✗")); + } + } + return 0; + }); + + // maui apple simulator start + var startNameArg = new Argument("name-or-udid") { Description = "Simulator name or UDID to boot" }; + var startCommand = new Command("start", "Boot a simulator") { startNameArg }; + startCommand.SetAction((ParseResult parseResult) => + { + var formatter = Program.GetFormatter(parseResult); + + if (!PlatformDetector.IsMacOS) + { + formatter.WriteWarning("Simulators are only available on macOS."); + return 1; + } + + var appleProvider = Program.AppleProvider; + var target = parseResult.GetValue(startNameArg); + + var success = appleProvider.BootSimulator(target!); + if (success) + formatter.WriteSuccess($"Simulator '{target}' booted."); + else + formatter.WriteWarning($"Failed to boot simulator '{target}'."); + + return success ? 0 : 1; + }); + + // maui apple simulator stop + var stopNameArg = new Argument("name-or-udid") { Description = "Simulator name or UDID to shut down (or 'all')" }; + var stopCommand = new Command("stop", "Shut down a simulator") { stopNameArg }; + stopCommand.SetAction((ParseResult parseResult) => + { + var formatter = Program.GetFormatter(parseResult); + + if (!PlatformDetector.IsMacOS) + { + formatter.WriteWarning("Simulators are only available on macOS."); + return 1; + } + + var appleProvider = Program.AppleProvider; + var target = parseResult.GetValue(stopNameArg); + + var success = appleProvider.ShutdownSimulator(target!); + if (success) + formatter.WriteSuccess($"Simulator '{target}' shut down."); + else + formatter.WriteWarning($"Failed to shut down simulator '{target}'."); + + return success ? 0 : 1; + }); + + // maui apple simulator delete + var deleteNameArg = new Argument("name-or-udid") { Description = "Simulator name or UDID to delete" }; + var deleteCommand = new Command("delete", "Delete a simulator") { deleteNameArg }; + deleteCommand.SetAction((ParseResult parseResult) => + { + var formatter = Program.GetFormatter(parseResult); + + if (!PlatformDetector.IsMacOS) + { + formatter.WriteWarning("Simulators are only available on macOS."); + return 1; + } + + var appleProvider = Program.AppleProvider; + var target = parseResult.GetValue(deleteNameArg); + + var success = appleProvider.DeleteSimulator(target!); + if (success) + formatter.WriteSuccess($"Simulator '{target}' deleted."); + else + formatter.WriteWarning($"Failed to delete simulator '{target}'."); + + return success ? 0 : 1; + }); + + simCommand.Add(listCommand); + simCommand.Add(startCommand); + simCommand.Add(stopCommand); + simCommand.Add(deleteCommand); + return simCommand; + } +} diff --git a/src/Cli/Microsoft.Maui.Cli/Errors/ErrorCodes.cs b/src/Cli/Microsoft.Maui.Cli/Errors/ErrorCodes.cs index 1c463442..db9a20e5 100644 --- a/src/Cli/Microsoft.Maui.Cli/Errors/ErrorCodes.cs +++ b/src/Cli/Microsoft.Maui.Cli/Errors/ErrorCodes.cs @@ -37,6 +37,12 @@ public static class ErrorCodes public const string AndroidDeviceNotFound = "E2111"; public const string AndroidAvdDeleteFailed = "E2112"; + // Platform/SDK errors - Apple (E22xx) + public const string AppleXcodeNotFound = "E2201"; + public const string AppleCltNotFound = "E2202"; + public const string AppleSimctlFailed = "E2203"; + public const string AppleSimulatorNotFound = "E2204"; + // Platform/SDK errors - Windows (E23xx) public const string WindowsSdkNotFound = "E2301"; diff --git a/src/Cli/Microsoft.Maui.Cli/Microsoft.Maui.Cli.csproj b/src/Cli/Microsoft.Maui.Cli/Microsoft.Maui.Cli.csproj index c366bc37..65a1477b 100644 --- a/src/Cli/Microsoft.Maui.Cli/Microsoft.Maui.Cli.csproj +++ b/src/Cli/Microsoft.Maui.Cli/Microsoft.Maui.Cli.csproj @@ -27,6 +27,7 @@ + diff --git a/src/Cli/Microsoft.Maui.Cli/Program.cs b/src/Cli/Microsoft.Maui.Cli/Program.cs index cbe2c699..8ce3956f 100644 --- a/src/Cli/Microsoft.Maui.Cli/Program.cs +++ b/src/Cli/Microsoft.Maui.Cli/Program.cs @@ -8,6 +8,7 @@ using Microsoft.Maui.Cli.Commands; using Microsoft.Maui.Cli.Output; using Microsoft.Maui.Cli.Providers.Android; +using Microsoft.Maui.Cli.Providers.Apple; using Microsoft.Maui.Cli.Services; using Microsoft.Maui.Cli.Utils; @@ -37,6 +38,7 @@ public static IServiceProvider Services // Convenience accessors for services internal static IAndroidProvider AndroidProvider => Services.GetRequiredService(); + internal static IAppleProvider AppleProvider => Services.GetRequiredService(); internal static IDoctorService DoctorService => Services.GetRequiredService(); internal static IDeviceManager DeviceManager => Services.GetRequiredService(); internal static IJdkManager JdkManager => Services.GetRequiredService(); @@ -89,6 +91,7 @@ internal static RootCommand BuildRootCommand() // Platform-specific command groups rootCommand.Add(AndroidCommands.Create()); + rootCommand.Add(AppleCommands.Create()); // DevFlow automation commands (maui devflow ...) rootCommand.Add(DevFlow.DevFlowCommands.CreateDevFlowCommand(GlobalOptions.JsonOption)); diff --git a/src/Cli/Microsoft.Maui.Cli/Providers/Apple/AppleProvider.cs b/src/Cli/Microsoft.Maui.Cli/Providers/Apple/AppleProvider.cs new file mode 100644 index 00000000..c23e7262 --- /dev/null +++ b/src/Cli/Microsoft.Maui.Cli/Providers/Apple/AppleProvider.cs @@ -0,0 +1,316 @@ +// Licensed to the .NET Foundation under one or more agreements. +// The .NET Foundation licenses this file to you under the MIT license. + +using Microsoft.Maui.Cli.Errors; +using Microsoft.Maui.Cli.Models; +using Microsoft.Maui.Cli.Utils; +using Xamarin.MacDev; + +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. +/// +public class AppleProvider : IAppleProvider +{ + readonly XcodeManager? _xcodeManager; + readonly SimulatorService? _simulatorService; + readonly RuntimeService? _runtimeService; + readonly CommandLineTools? _commandLineTools; + + public AppleProvider() + { + if (!PlatformDetector.IsMacOS) + return; + + var logger = ConsoleLogger.Instance; + _xcodeManager = new XcodeManager(logger); + _simulatorService = new SimulatorService(logger); + _runtimeService = new RuntimeService(logger); + _commandLineTools = new CommandLineTools(logger); + } + + public List GetXcodeInstallations() + { + if (_xcodeManager is null) + return new List(); + + return _xcodeManager.List().Select(x => new XcodeInstallation + { + Path = x.Path, + Version = x.Version.ToString(), + Build = x.Build, + IsSelected = x.IsSelected + }).ToList(); + } + + public XcodeInstallation? GetSelectedXcode() + { + if (_xcodeManager is null) + return null; + + var selected = _xcodeManager.GetSelected(); + if (selected is null) + return null; + + return new XcodeInstallation + { + Path = selected.Path, + Version = selected.Version.ToString(), + Build = selected.Build, + IsSelected = true + }; + } + + public bool SelectXcode(string path) + { + if (_xcodeManager is null) + return false; + + return _xcodeManager.Select(path); + } + + public CommandLineToolsStatus GetCommandLineToolsStatus() + { + if (_commandLineTools is null) + return new CommandLineToolsStatus(); + + var info = _commandLineTools.Check(); + return new CommandLineToolsStatus + { + IsInstalled = info.IsInstalled, + Version = info.Version, + Path = info.Path + }; + } + + public List GetRuntimes(string? platform = null, bool availableOnly = false) + { + if (_runtimeService is null) + return new List(); + + var runtimes = string.IsNullOrEmpty(platform) + ? _runtimeService.List(availableOnly) + : _runtimeService.ListByPlatform(platform!, availableOnly); + + return runtimes.Select(r => new RuntimeInfo + { + Name = r.Name, + Identifier = r.Identifier, + Platform = r.Platform, + Version = r.Version, + BuildVersion = r.BuildVersion, + IsAvailable = r.IsAvailable, + IsBundled = r.IsBundled + }).ToList(); + } + + public List GetSimulators(bool availableOnly = false) + { + if (_simulatorService is null) + return new List(); + + return _simulatorService.List(availableOnly).Select(s => new SimulatorInfo + { + Name = s.Name, + Udid = s.Udid, + State = s.State, + Platform = s.Platform, + OSVersion = s.OSVersion, + RuntimeIdentifier = s.RuntimeIdentifier, + DeviceTypeIdentifier = s.DeviceTypeIdentifier, + IsAvailable = s.IsAvailable, + IsBooted = s.IsBooted + }).ToList(); + } + + public bool BootSimulator(string udidOrName) + { + return _simulatorService?.Boot(udidOrName) ?? false; + } + + public bool ShutdownSimulator(string udidOrName) + { + return _simulatorService?.Shutdown(udidOrName) ?? false; + } + + public bool DeleteSimulator(string udidOrName) + { + return _simulatorService?.Delete(udidOrName) ?? false; + } + + public string? CreateSimulator(string name, string deviceTypeIdentifier, string? runtimeIdentifier = null) + { + return _simulatorService?.Create(name, deviceTypeIdentifier, runtimeIdentifier); + } + + public List CheckHealth() + { + var checks = new List(); + + if (!PlatformDetector.IsMacOS) + { + checks.Add(new HealthCheck + { + Category = "apple", + Name = "Platform", + Status = CheckStatus.Skipped, + Message = "Apple checks only available on macOS" + }); + return checks; + } + + // Xcode check + var xcode = _xcodeManager?.GetBest(); + if (xcode is not null) + { + checks.Add(new HealthCheck + { + Category = "apple", + Name = "Xcode", + Status = CheckStatus.Ok, + Message = $"Xcode {xcode.Version} ({xcode.Build}) at {xcode.Path}", + Details = new Dictionary + { + ["version"] = xcode.Version.ToString(), + ["build"] = xcode.Build, + ["path"] = xcode.Path, + ["selected"] = xcode.IsSelected + } + }); + } + else + { + checks.Add(new HealthCheck + { + Category = "apple", + Name = "Xcode", + Status = CheckStatus.Error, + Message = "Xcode not found. Install Xcode from the App Store.", + 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" } + } + }); + } + + // Command Line Tools check + var clt = _commandLineTools?.Check(); + if (clt is not null && clt.IsInstalled) + { + checks.Add(new HealthCheck + { + Category = "apple", + Name = "Command Line Tools", + Status = CheckStatus.Ok, + Message = $"CLT {clt.Version ?? "installed"} at {clt.Path}" + }); + } + else + { + checks.Add(new HealthCheck + { + 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" + } + }); + } + + // Simulator runtimes check + HealthCheck iosRuntimesCheck; + try + { + var runtimes = _runtimeService?.List(availableOnly: true); + if (runtimes is { Count: > 0 }) + { + var iosRuntimes = runtimes.Where(r => r.Platform == "iOS").ToList(); + iosRuntimesCheck = 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 => r.Version).First().Name})" + : "No iOS runtimes found. Install one via Xcode." + }; + } + else + { + iosRuntimesCheck = new HealthCheck + { + Category = "apple", + Name = "iOS Runtimes", + Status = CheckStatus.Warning, + Message = "No simulator runtimes found. Install simulator runtimes via Xcode." + }; + } + } + catch + { + iosRuntimesCheck = new HealthCheck + { + Category = "apple", + Name = "iOS Runtimes", + Status = CheckStatus.Warning, + Message = "Unable to determine installed iOS simulator runtimes." + }; + } + + checks.Add(iosRuntimesCheck); + + return checks; + } + + public List GetDevices() + { + if (_simulatorService is null) + return new List(); + + var sims = _simulatorService.List(availableOnly: true); + return sims.Select(s => + { + var platform = s.Platform?.ToLowerInvariant() switch + { + "ios" => Platforms.iOS, + "tvos" or "watchos" or "visionos" => s.Platform!.ToLowerInvariant(), + _ => Platforms.iOS + }; + + return new Device + { + Id = s.Udid, + Name = s.Name, + Platforms = new[] { platform }, + Type = DeviceType.Simulator, + State = s.IsBooted ? DeviceState.Booted : DeviceState.Shutdown, + IsEmulator = true, + IsRunning = s.IsBooted, + ConnectionType = Models.ConnectionType.Local, + EmulatorId = s.Udid, + Model = s.DeviceTypeIdentifier, + Manufacturer = "Apple", + Version = s.OSVersion, + VersionName = s.Platform != null ? $"{s.Platform} {s.OSVersion}" : s.OSVersion, + Idiom = s.DeviceTypeIdentifier.Contains("iPad") ? DeviceIdiom.Tablet : DeviceIdiom.Phone, + Details = new Dictionary + { + ["runtime"] = s.RuntimeIdentifier, + ["device_type"] = s.DeviceTypeIdentifier, + ["platform"] = s.Platform ?? "" + } + }; + }).ToList(); + } +} diff --git a/src/Cli/Microsoft.Maui.Cli/Providers/Apple/IAppleProvider.cs b/src/Cli/Microsoft.Maui.Cli/Providers/Apple/IAppleProvider.cs new file mode 100644 index 00000000..b3d1413e --- /dev/null +++ b/src/Cli/Microsoft.Maui.Cli/Providers/Apple/IAppleProvider.cs @@ -0,0 +1,124 @@ +// Licensed to the .NET Foundation under one or more agreements. +// The .NET Foundation licenses this file to you under the MIT license. + +using Microsoft.Maui.Cli.Models; + +namespace Microsoft.Maui.Cli.Providers.Apple; + +/// +/// Interface for Apple platform operations (Xcode, simulators, runtimes). +/// Wraps the Xamarin.Apple.Tools.MaciOS package APIs. +/// +public interface IAppleProvider +{ + /// + /// Lists installed Xcode installations. + /// + List GetXcodeInstallations(); + + /// + /// Gets the currently selected Xcode installation. + /// + XcodeInstallation? GetSelectedXcode(); + + /// + /// Selects an Xcode installation by path. + /// + bool SelectXcode(string path); + + /// + /// Gets the Command Line Tools installation info. + /// + CommandLineToolsStatus GetCommandLineToolsStatus(); + + /// + /// Lists simulator runtimes. Optionally filter by platform (e.g., "iOS"). + /// + List GetRuntimes(string? platform = null, bool availableOnly = false); + + /// + /// Lists simulator devices. Optionally filters by availability. + /// + List GetSimulators(bool availableOnly = false); + + /// + /// Boots a simulator device. + /// + bool BootSimulator(string udidOrName); + + /// + /// Shuts down a simulator device. Pass "all" to shut down all. + /// + bool ShutdownSimulator(string udidOrName); + + /// + /// Deletes a simulator device. + /// + bool DeleteSimulator(string udidOrName); + + /// + /// Creates a new simulator device. + /// + string? CreateSimulator(string name, string deviceTypeIdentifier, string? runtimeIdentifier = null); + + /// + /// Gets the health status of Apple tooling (Xcode, CLT, simulators). + /// + List CheckHealth(); + + /// + /// Lists simulator devices as models for device manager integration. + /// + List GetDevices(); +} + +/// +/// Information about an Xcode installation. +/// +public record XcodeInstallation +{ + public required string Path { get; init; } + public string? Version { get; init; } + public string? Build { get; init; } + public bool IsSelected { get; init; } +} + +/// +/// Status of the Xcode Command Line Tools installation. +/// +public record CommandLineToolsStatus +{ + public bool IsInstalled { get; init; } + public string? Version { get; init; } + public string? Path { get; init; } +} + +/// +/// Information about a simulator runtime. +/// +public record RuntimeInfo +{ + public required string Name { get; init; } + public required string Identifier { get; init; } + public string? Platform { get; init; } + public string? Version { get; init; } + public string? BuildVersion { get; init; } + public bool IsAvailable { get; init; } + public bool IsBundled { get; init; } +} + +/// +/// Information about a simulator device. +/// +public record SimulatorInfo +{ + public required string Name { get; init; } + public required string Udid { get; init; } + public string? State { get; init; } + public string? Platform { get; init; } + public string? OSVersion { get; init; } + public string? RuntimeIdentifier { get; init; } + public string? DeviceTypeIdentifier { get; init; } + public bool IsAvailable { get; init; } + public bool IsBooted { get; init; } +} diff --git a/src/Cli/Microsoft.Maui.Cli/ServiceConfiguration.cs b/src/Cli/Microsoft.Maui.Cli/ServiceConfiguration.cs index 4b916519..93407694 100644 --- a/src/Cli/Microsoft.Maui.Cli/ServiceConfiguration.cs +++ b/src/Cli/Microsoft.Maui.Cli/ServiceConfiguration.cs @@ -5,6 +5,7 @@ using Microsoft.Maui.Cli.DevFlow; using Microsoft.Maui.Cli.Output; using Microsoft.Maui.Cli.Providers.Android; +using Microsoft.Maui.Cli.Providers.Apple; using Microsoft.Maui.Cli.Services; using Microsoft.Maui.Cli.Utils; @@ -34,6 +35,9 @@ public static void ConfigureServices(IServiceCollection services) services.AddSingleton(); services.AddSingleton(); + // Apple providers + services.AddSingleton(); + // Core services services.AddSingleton(); services.AddSingleton(); @@ -51,6 +55,7 @@ public static void ConfigureServices(IServiceCollection services) /// public static IServiceProvider CreateTestServiceProvider( IAndroidProvider? androidProvider = null, + IAppleProvider? appleProvider = null, IJdkManager? jdkManager = null, IDoctorService? doctorService = null, IDeviceManager? deviceManager = null, @@ -69,6 +74,11 @@ public static IServiceProvider CreateTestServiceProvider( else services.AddSingleton(); + if (appleProvider != null) + services.AddSingleton(appleProvider); + else + services.AddSingleton(); + if (doctorService != null) services.AddSingleton(doctorService); else diff --git a/src/Cli/Microsoft.Maui.Cli/Services/DeviceManager.cs b/src/Cli/Microsoft.Maui.Cli/Services/DeviceManager.cs index bdbb2c02..e310e4e5 100644 --- a/src/Cli/Microsoft.Maui.Cli/Services/DeviceManager.cs +++ b/src/Cli/Microsoft.Maui.Cli/Services/DeviceManager.cs @@ -4,6 +4,7 @@ using Microsoft.Maui.Cli.Errors; using Microsoft.Maui.Cli.Models; using Microsoft.Maui.Cli.Providers.Android; +using Microsoft.Maui.Cli.Providers.Apple; using Microsoft.Maui.Cli.Utils; namespace Microsoft.Maui.Cli.Services; @@ -14,10 +15,12 @@ namespace Microsoft.Maui.Cli.Services; public class DeviceManager : IDeviceManager { readonly IAndroidProvider? _androidProvider; + readonly IAppleProvider? _appleProvider; - public DeviceManager(IAndroidProvider? androidProvider = null) + public DeviceManager(IAndroidProvider? androidProvider = null, IAppleProvider? appleProvider = null) { _androidProvider = androidProvider; + _appleProvider = appleProvider; } public async Task> GetAllDevicesAsync(CancellationToken cancellationToken = default) @@ -108,8 +111,12 @@ public async Task> GetAllDevicesAsync(CancellationToken ca } } - // TODO: Get Apple devices when AppleProvider is implemented - // TODO: Get Windows devices when WindowsProvider is implemented + // Get Apple devices (simulators) when on macOS + if (_appleProvider != null) + { + var appleDevices = _appleProvider.GetDevices(); + devices.AddRange(appleDevices); + } return devices; } diff --git a/src/Cli/Microsoft.Maui.Cli/Services/DoctorService.cs b/src/Cli/Microsoft.Maui.Cli/Services/DoctorService.cs index 8aa7569b..a462d528 100644 --- a/src/Cli/Microsoft.Maui.Cli/Services/DoctorService.cs +++ b/src/Cli/Microsoft.Maui.Cli/Services/DoctorService.cs @@ -4,6 +4,7 @@ using Microsoft.Maui.Cli.Errors; using Microsoft.Maui.Cli.Models; using Microsoft.Maui.Cli.Providers.Android; +using Microsoft.Maui.Cli.Providers.Apple; using Microsoft.Maui.Cli.Utils; namespace Microsoft.Maui.Cli.Services; @@ -14,10 +15,12 @@ namespace Microsoft.Maui.Cli.Services; public class DoctorService : IDoctorService { readonly IAndroidProvider? _androidProvider; + readonly IAppleProvider? _appleProvider; - public DoctorService(IAndroidProvider? androidProvider = null) + public DoctorService(IAndroidProvider? androidProvider = null, IAppleProvider? appleProvider = null) { _androidProvider = androidProvider; + _appleProvider = appleProvider; } public async Task RunAllChecksAsync(CancellationToken cancellationToken = default) @@ -37,6 +40,13 @@ public async Task RunAllChecksAsync(CancellationToken cancellation checks.AddRange(androidChecks); } + // Apple checks (macOS only) + if (_appleProvider != null) + { + var appleChecks = _appleProvider.CheckHealth(); + checks.AddRange(appleChecks); + } + // Windows checks (Windows only) if (PlatformDetector.IsWindows) { @@ -65,6 +75,23 @@ public async Task RunCategoryChecksAsync(string category, Cancella } break; + case "apple": + if (_appleProvider != null) + { + checks.AddRange(_appleProvider.CheckHealth()); + } + else if (!PlatformDetector.IsMacOS) + { + checks.Add(new HealthCheck + { + Category = "apple", + Name = "Platform", + Status = CheckStatus.Skipped, + Message = "Apple checks only available on macOS" + }); + } + break; + case "windows": if (PlatformDetector.IsWindows) { @@ -85,7 +112,7 @@ public async Task RunCategoryChecksAsync(string category, Cancella default: throw new MauiToolException( ErrorCodes.InvalidArgument, - $"Unknown category: {category}. Valid categories: dotnet, android, windows"); + $"Unknown category: {category}. Valid categories: dotnet, android, apple, windows"); } return CreateReport(checks);