From cb74523e2297c6a6a52115e2aab0362449c8de5f Mon Sep 17 00:00:00 2001 From: Rui Marinho Date: Fri, 27 Mar 2026 13:05:27 +0000 Subject: [PATCH 1/7] Add Microsoft.Maui.Client CLI tool from dotnet/maui Move the 'maui' CLI tool (Android SDK setup, JDK management, doctor, devices) from dotnet/maui PR #34498 into maui-labs as a separate project alongside the existing maui-devflow CLI. - Add src/DevFlow/Microsoft.Maui.Client/ (29 source files) - Add src/DevFlow/Microsoft.Maui.Client.UnitTests/ (11 test files) - Adapt to Arcade SDK build system (versioning, central package mgmt) - System.CommandLine 2.0.5 stable (central default); DevFlow keeps beta4 via VersionOverride - Add Xamarin.Android.Tools.AndroidSdk 1.0.143-preview.8 dependency - Add dotnet11 NuGet feed, Version.Details.xml darc entry - 118 tests pass Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> --- Directory.Packages.props | 9 +- MauiLabs.sln | 32 ++ NuGet.config | 1 + eng/Version.Details.xml | 5 + eng/Versions.props | 7 + global.json | 4 +- .../AndroidCommandsTests.cs | 204 +++++++ .../AndroidProviderTests.cs | 278 +++++++++ .../DeviceManagerTests.cs | 197 +++++++ .../DoctorServiceTests.cs | 128 +++++ .../ErrorCodesTests.cs | 35 ++ .../Fakes/FakeAndroidProvider.cs | 179 ++++++ .../MauiToolExceptionTests.cs | 108 ++++ .../Microsoft.Maui.Client.UnitTests.csproj | 23 + .../OutputFormatterTests.cs | 220 +++++++ .../PlatformsTests.cs | 48 ++ .../ProcessRunnerTests.cs | 170 ++++++ .../ServiceConfigurationTests.cs | 90 +++ .../Commands/AndroidCommands.Emulator.cs | 496 ++++++++++++++++ .../Commands/AndroidCommands.Install.cs | 189 ++++++ .../Commands/AndroidCommands.Jdk.cs | 120 ++++ .../Commands/AndroidCommands.Sdk.cs | 538 ++++++++++++++++++ .../Commands/AndroidCommands.cs | 193 +++++++ .../Commands/CommandExtensions.cs | 27 + .../Commands/DeviceCommand.cs | 72 +++ .../Commands/DoctorCommand.cs | 108 ++++ .../Commands/VersionCommand.cs | 48 ++ .../Errors/ErrorCodes.cs | 46 ++ .../Errors/MauiToolException.cs | 95 ++++ .../Microsoft.Maui.Client.csproj | 28 + .../Microsoft.Maui.Client/Models/Device.cs | 211 +++++++ .../Models/DoctorReport.cs | 116 ++++ .../Models/ErrorResult.cs | 110 ++++ .../Microsoft.Maui.Client/Models/Platforms.cs | 30 + .../Output/IOutputFormatter.cs | 62 ++ .../Output/JsonOutputFormatter.cs | 92 +++ .../Output/SpectreHelpBuilder.cs | 156 +++++ .../Output/SpectreOutputFormatter.cs | 415 ++++++++++++++ src/DevFlow/Microsoft.Maui.Client/Program.cs | 191 +++++++ .../Providers/Android/Adb.cs | 109 ++++ .../Providers/Android/AndroidEnvironment.cs | 91 +++ .../Providers/Android/AndroidProvider.cs | 417 ++++++++++++++ .../Providers/Android/AvdManager.cs | 280 +++++++++ .../Providers/Android/IAndroidProvider.cs | 164 ++++++ .../Providers/Android/IJdkManager.cs | 47 ++ .../Providers/Android/JdkManager.cs | 339 +++++++++++ .../Providers/Android/SdkManager.cs | 234 ++++++++ .../Microsoft.Maui.Client/Scripts/demo-all.sh | 49 ++ .../Scripts/demo-android-emulator.sh | 45 ++ .../Scripts/demo-android-sdk.sh | 54 ++ .../Scripts/demo-apple.sh | 39 ++ .../Scripts/demo-interactive-install.sh | 56 ++ .../Scripts/demo-json-scripting.sh | 64 +++ .../Scripts/demo-overview.sh | 40 ++ .../Scripts/demo-simulator-boot.sh | 101 ++++ .../ServiceConfiguration.cs | 79 +++ .../Services/DeviceManager.cs | 170 ++++++ .../Services/DoctorService.cs | 318 +++++++++++ .../Services/IDeviceManager.cs | 17 + .../Services/IDoctorService.cs | 17 + .../Utils/PlatformDetector.cs | 214 +++++++ .../Utils/ProcessRunner.cs | 380 +++++++++++++ .../docs/images/android-install.png | Bin 0 -> 46769 bytes .../docs/images/device-list.png | Bin 0 -> 29388 bytes .../docs/images/doctor.png | Bin 0 -> 39656 bytes .../docs/images/help.png | Bin 0 -> 49157 bytes .../Microsoft.Maui.DevFlow.CLI.csproj | 2 +- 67 files changed, 8403 insertions(+), 4 deletions(-) create mode 100644 src/DevFlow/Microsoft.Maui.Client.UnitTests/AndroidCommandsTests.cs create mode 100644 src/DevFlow/Microsoft.Maui.Client.UnitTests/AndroidProviderTests.cs create mode 100644 src/DevFlow/Microsoft.Maui.Client.UnitTests/DeviceManagerTests.cs create mode 100644 src/DevFlow/Microsoft.Maui.Client.UnitTests/DoctorServiceTests.cs create mode 100644 src/DevFlow/Microsoft.Maui.Client.UnitTests/ErrorCodesTests.cs create mode 100644 src/DevFlow/Microsoft.Maui.Client.UnitTests/Fakes/FakeAndroidProvider.cs create mode 100644 src/DevFlow/Microsoft.Maui.Client.UnitTests/MauiToolExceptionTests.cs create mode 100644 src/DevFlow/Microsoft.Maui.Client.UnitTests/Microsoft.Maui.Client.UnitTests.csproj create mode 100644 src/DevFlow/Microsoft.Maui.Client.UnitTests/OutputFormatterTests.cs create mode 100644 src/DevFlow/Microsoft.Maui.Client.UnitTests/PlatformsTests.cs create mode 100644 src/DevFlow/Microsoft.Maui.Client.UnitTests/ProcessRunnerTests.cs create mode 100644 src/DevFlow/Microsoft.Maui.Client.UnitTests/ServiceConfigurationTests.cs create mode 100644 src/DevFlow/Microsoft.Maui.Client/Commands/AndroidCommands.Emulator.cs create mode 100644 src/DevFlow/Microsoft.Maui.Client/Commands/AndroidCommands.Install.cs create mode 100644 src/DevFlow/Microsoft.Maui.Client/Commands/AndroidCommands.Jdk.cs create mode 100644 src/DevFlow/Microsoft.Maui.Client/Commands/AndroidCommands.Sdk.cs create mode 100644 src/DevFlow/Microsoft.Maui.Client/Commands/AndroidCommands.cs create mode 100644 src/DevFlow/Microsoft.Maui.Client/Commands/CommandExtensions.cs create mode 100644 src/DevFlow/Microsoft.Maui.Client/Commands/DeviceCommand.cs create mode 100644 src/DevFlow/Microsoft.Maui.Client/Commands/DoctorCommand.cs create mode 100644 src/DevFlow/Microsoft.Maui.Client/Commands/VersionCommand.cs create mode 100644 src/DevFlow/Microsoft.Maui.Client/Errors/ErrorCodes.cs create mode 100644 src/DevFlow/Microsoft.Maui.Client/Errors/MauiToolException.cs create mode 100644 src/DevFlow/Microsoft.Maui.Client/Microsoft.Maui.Client.csproj create mode 100644 src/DevFlow/Microsoft.Maui.Client/Models/Device.cs create mode 100644 src/DevFlow/Microsoft.Maui.Client/Models/DoctorReport.cs create mode 100644 src/DevFlow/Microsoft.Maui.Client/Models/ErrorResult.cs create mode 100644 src/DevFlow/Microsoft.Maui.Client/Models/Platforms.cs create mode 100644 src/DevFlow/Microsoft.Maui.Client/Output/IOutputFormatter.cs create mode 100644 src/DevFlow/Microsoft.Maui.Client/Output/JsonOutputFormatter.cs create mode 100644 src/DevFlow/Microsoft.Maui.Client/Output/SpectreHelpBuilder.cs create mode 100644 src/DevFlow/Microsoft.Maui.Client/Output/SpectreOutputFormatter.cs create mode 100644 src/DevFlow/Microsoft.Maui.Client/Program.cs create mode 100644 src/DevFlow/Microsoft.Maui.Client/Providers/Android/Adb.cs create mode 100644 src/DevFlow/Microsoft.Maui.Client/Providers/Android/AndroidEnvironment.cs create mode 100644 src/DevFlow/Microsoft.Maui.Client/Providers/Android/AndroidProvider.cs create mode 100644 src/DevFlow/Microsoft.Maui.Client/Providers/Android/AvdManager.cs create mode 100644 src/DevFlow/Microsoft.Maui.Client/Providers/Android/IAndroidProvider.cs create mode 100644 src/DevFlow/Microsoft.Maui.Client/Providers/Android/IJdkManager.cs create mode 100644 src/DevFlow/Microsoft.Maui.Client/Providers/Android/JdkManager.cs create mode 100644 src/DevFlow/Microsoft.Maui.Client/Providers/Android/SdkManager.cs create mode 100755 src/DevFlow/Microsoft.Maui.Client/Scripts/demo-all.sh create mode 100755 src/DevFlow/Microsoft.Maui.Client/Scripts/demo-android-emulator.sh create mode 100755 src/DevFlow/Microsoft.Maui.Client/Scripts/demo-android-sdk.sh create mode 100755 src/DevFlow/Microsoft.Maui.Client/Scripts/demo-apple.sh create mode 100755 src/DevFlow/Microsoft.Maui.Client/Scripts/demo-interactive-install.sh create mode 100755 src/DevFlow/Microsoft.Maui.Client/Scripts/demo-json-scripting.sh create mode 100755 src/DevFlow/Microsoft.Maui.Client/Scripts/demo-overview.sh create mode 100755 src/DevFlow/Microsoft.Maui.Client/Scripts/demo-simulator-boot.sh create mode 100644 src/DevFlow/Microsoft.Maui.Client/ServiceConfiguration.cs create mode 100644 src/DevFlow/Microsoft.Maui.Client/Services/DeviceManager.cs create mode 100644 src/DevFlow/Microsoft.Maui.Client/Services/DoctorService.cs create mode 100644 src/DevFlow/Microsoft.Maui.Client/Services/IDeviceManager.cs create mode 100644 src/DevFlow/Microsoft.Maui.Client/Services/IDoctorService.cs create mode 100644 src/DevFlow/Microsoft.Maui.Client/Utils/PlatformDetector.cs create mode 100644 src/DevFlow/Microsoft.Maui.Client/Utils/ProcessRunner.cs create mode 100644 src/DevFlow/Microsoft.Maui.Client/docs/images/android-install.png create mode 100644 src/DevFlow/Microsoft.Maui.Client/docs/images/device-list.png create mode 100644 src/DevFlow/Microsoft.Maui.Client/docs/images/doctor.png create mode 100644 src/DevFlow/Microsoft.Maui.Client/docs/images/help.png diff --git a/Directory.Packages.props b/Directory.Packages.props index a7f531f0..35838364 100644 --- a/Directory.Packages.props +++ b/Directory.Packages.props @@ -26,7 +26,8 @@ - + + @@ -34,6 +35,12 @@ + + + + + + diff --git a/MauiLabs.sln b/MauiLabs.sln index 876e31b3..fc128713 100644 --- a/MauiLabs.sln +++ b/MauiLabs.sln @@ -41,6 +41,12 @@ Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "DevFlow.Sample", "samples\D EndProject Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "DevFlow.Sample.MacOS", "samples\DevFlow.Sample.MacOS\DevFlow.Sample.MacOS.csproj", "{AFD6683E-10FC-445C-9139-7F4FDFBFA232}" EndProject +Project("{2150E333-8FDC-42A3-9474-1A3956D46DE8}") = "DevFlow", "DevFlow", "{79F3B28B-5908-7582-5384-B77713FD2B1E}" +EndProject +Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "Microsoft.Maui.Client", "src\DevFlow\Microsoft.Maui.Client\Microsoft.Maui.Client.csproj", "{C48C1D82-906D-4626-96BD-43D9D49B7A79}" +EndProject +Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "Microsoft.Maui.Client.UnitTests", "src\DevFlow\Microsoft.Maui.Client.UnitTests\Microsoft.Maui.Client.UnitTests.csproj", "{74377069-050C-4C39-A34D-88307E0C19B8}" +EndProject Global GlobalSection(SolutionConfigurationPlatforms) = preSolution Debug|Any CPU = Debug|Any CPU @@ -207,6 +213,30 @@ Global {AFD6683E-10FC-445C-9139-7F4FDFBFA232}.Release|x64.Build.0 = Release|Any CPU {AFD6683E-10FC-445C-9139-7F4FDFBFA232}.Release|x86.ActiveCfg = Release|Any CPU {AFD6683E-10FC-445C-9139-7F4FDFBFA232}.Release|x86.Build.0 = Release|Any CPU + {C48C1D82-906D-4626-96BD-43D9D49B7A79}.Debug|Any CPU.ActiveCfg = Debug|Any CPU + {C48C1D82-906D-4626-96BD-43D9D49B7A79}.Debug|Any CPU.Build.0 = Debug|Any CPU + {C48C1D82-906D-4626-96BD-43D9D49B7A79}.Debug|x64.ActiveCfg = Debug|Any CPU + {C48C1D82-906D-4626-96BD-43D9D49B7A79}.Debug|x64.Build.0 = Debug|Any CPU + {C48C1D82-906D-4626-96BD-43D9D49B7A79}.Debug|x86.ActiveCfg = Debug|Any CPU + {C48C1D82-906D-4626-96BD-43D9D49B7A79}.Debug|x86.Build.0 = Debug|Any CPU + {C48C1D82-906D-4626-96BD-43D9D49B7A79}.Release|Any CPU.ActiveCfg = Release|Any CPU + {C48C1D82-906D-4626-96BD-43D9D49B7A79}.Release|Any CPU.Build.0 = Release|Any CPU + {C48C1D82-906D-4626-96BD-43D9D49B7A79}.Release|x64.ActiveCfg = Release|Any CPU + {C48C1D82-906D-4626-96BD-43D9D49B7A79}.Release|x64.Build.0 = Release|Any CPU + {C48C1D82-906D-4626-96BD-43D9D49B7A79}.Release|x86.ActiveCfg = Release|Any CPU + {C48C1D82-906D-4626-96BD-43D9D49B7A79}.Release|x86.Build.0 = Release|Any CPU + {74377069-050C-4C39-A34D-88307E0C19B8}.Debug|Any CPU.ActiveCfg = Debug|Any CPU + {74377069-050C-4C39-A34D-88307E0C19B8}.Debug|Any CPU.Build.0 = Debug|Any CPU + {74377069-050C-4C39-A34D-88307E0C19B8}.Debug|x64.ActiveCfg = Debug|Any CPU + {74377069-050C-4C39-A34D-88307E0C19B8}.Debug|x64.Build.0 = Debug|Any CPU + {74377069-050C-4C39-A34D-88307E0C19B8}.Debug|x86.ActiveCfg = Debug|Any CPU + {74377069-050C-4C39-A34D-88307E0C19B8}.Debug|x86.Build.0 = Debug|Any CPU + {74377069-050C-4C39-A34D-88307E0C19B8}.Release|Any CPU.ActiveCfg = Release|Any CPU + {74377069-050C-4C39-A34D-88307E0C19B8}.Release|Any CPU.Build.0 = Release|Any CPU + {74377069-050C-4C39-A34D-88307E0C19B8}.Release|x64.ActiveCfg = Release|Any CPU + {74377069-050C-4C39-A34D-88307E0C19B8}.Release|x64.Build.0 = Release|Any CPU + {74377069-050C-4C39-A34D-88307E0C19B8}.Release|x86.ActiveCfg = Release|Any CPU + {74377069-050C-4C39-A34D-88307E0C19B8}.Release|x86.Build.0 = Release|Any CPU EndGlobalSection GlobalSection(SolutionProperties) = preSolution HideSolutionNode = FALSE @@ -227,5 +257,7 @@ Global {13978349-2CD3-4F84-B9C8-EDB5F9B1CB18} = {12E5C810-18C6-BE57-4EA6-3F3BE821652E} {B843C880-C1FE-4A2B-A41E-5A6D251929DD} = {5D20AA90-6969-D8BD-9DCD-8634F4692FDA} {AFD6683E-10FC-445C-9139-7F4FDFBFA232} = {5D20AA90-6969-D8BD-9DCD-8634F4692FDA} + {C48C1D82-906D-4626-96BD-43D9D49B7A79} = {79F3B28B-5908-7582-5384-B77713FD2B1E} + {74377069-050C-4C39-A34D-88307E0C19B8} = {79F3B28B-5908-7582-5384-B77713FD2B1E} EndGlobalSection EndGlobal diff --git a/NuGet.config b/NuGet.config index 6f28308b..35a81656 100644 --- a/NuGet.config +++ b/NuGet.config @@ -13,6 +13,7 @@ + diff --git a/eng/Version.Details.xml b/eng/Version.Details.xml index 1ee34416..b76dae11 100644 --- a/eng/Version.Details.xml +++ b/eng/Version.Details.xml @@ -1,6 +1,11 @@ + + + https://github.com/dotnet/android-tools + ec5040ad5158f240a67d887133dd56cfa5eb74ba + https://github.com/dotnet/maui diff --git a/eng/Versions.props b/eng/Versions.props index ad0ea252..3c180606 100644 --- a/eng/Versions.props +++ b/eng/Versions.props @@ -22,6 +22,7 @@ $(MicrosoftMauiControlsVersion) + 10.0.5 10.0.5 10.0.5 10.0.5 @@ -33,12 +34,18 @@ 1.3.1 3.119.2 0.54.0 + $(SpectreConsoleVersion) 2.0.0-beta4.22272.1 + 2.0.5 5.3.0 1.7.1 1.1.0 10.19041.0 + + + 1.0.143-preview.8 + 0.3.0 $(PlatformMauiMacOSVersion) diff --git a/global.json b/global.json index 6c85f7c3..da74e394 100644 --- a/global.json +++ b/global.json @@ -1,11 +1,11 @@ { "sdk": { - "version": "10.0.105", + "version": "10.0.103", "rollForward": "latestMinor", "allowPrerelease": false }, "tools": { - "dotnet": "10.0.105" + "dotnet": "10.0.103" }, "msbuild-sdks": { "Microsoft.DotNet.Arcade.Sdk": "10.0.0-beta.26168.104" diff --git a/src/DevFlow/Microsoft.Maui.Client.UnitTests/AndroidCommandsTests.cs b/src/DevFlow/Microsoft.Maui.Client.UnitTests/AndroidCommandsTests.cs new file mode 100644 index 00000000..a27d235d --- /dev/null +++ b/src/DevFlow/Microsoft.Maui.Client.UnitTests/AndroidCommandsTests.cs @@ -0,0 +1,204 @@ +// 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.Client.Commands; +using Xunit; + +namespace Microsoft.Maui.Client.UnitTests; + +public class AndroidCommandsTests +{ + [Fact] + public void InstallCommand_ParsesCommaSeparatedPackages() + { + // Arrange + var androidCommand = AndroidCommands.Create(); + var installCommand = androidCommand.Subcommands.First(c => c.Name == "install"); + var packagesOption = installCommand.Options.First(o => o.Name == "--packages"); + + // Act + var parseResult = installCommand.Parse("install --packages platform-tools,build-tools;35.0.0,platforms;android-35"); + + // Assert + Assert.Empty(parseResult.Errors); + var packages = parseResult.GetValue((Option)packagesOption); + Assert.NotNull(packages); + // The raw value will be a single string with commas - the handler splits it + Assert.Single(packages); + Assert.Equal("platform-tools,build-tools;35.0.0,platforms;android-35", packages[0]); + } + + [Fact] + public void InstallCommand_ParsesMultiplePackageFlags() + { + // Arrange + var androidCommand = AndroidCommands.Create(); + var installCommand = androidCommand.Subcommands.First(c => c.Name == "install"); + var packagesOption = installCommand.Options.First(o => o.Name == "--packages"); + + // Act + var parseResult = installCommand.Parse("install --packages platform-tools --packages build-tools;35.0.0"); + + // Assert + Assert.Empty(parseResult.Errors); + var packages = parseResult.GetValue((Option)packagesOption); + Assert.NotNull(packages); + Assert.Equal(2, packages.Length); + } + + [Fact] + public void InstallCommand_HasCorrectOptions() + { + // Arrange + var androidCommand = AndroidCommands.Create(); + var installCommand = androidCommand.Subcommands.First(c => c.Name == "install"); + + // Assert + Assert.Contains(installCommand.Options, o => o.Name == "--sdk-path"); + Assert.Contains(installCommand.Options, o => o.Name == "--jdk-path"); + Assert.Contains(installCommand.Options, o => o.Name == "--jdk-version"); + Assert.Contains(installCommand.Options, o => o.Name == "--packages"); + } + + [Fact] + public void EmulatorCreateCommand_PackageIsOptional() + { + // Arrange + var androidCommand = AndroidCommands.Create(); + var emulatorCommand = androidCommand.Subcommands.First(c => c.Name == "emulator"); + var createCommand = emulatorCommand.Subcommands.First(c => c.Name == "create"); + var packageOption = createCommand.Options.First(o => o.Name == "--package"); + + // Assert + Assert.False(packageOption.Required); + } + + [Fact] + public void EmulatorCreateCommand_HasRequiredNameArgument() + { + // Arrange + var androidCommand = AndroidCommands.Create(); + var emulatorCommand = androidCommand.Subcommands.First(c => c.Name == "emulator"); + var createCommand = emulatorCommand.Subcommands.First(c => c.Name == "create"); + + // Assert + Assert.Single(createCommand.Arguments); + Assert.Equal("name", createCommand.Arguments.First().Name); + } + + [Fact] + public void EmulatorDeleteCommand_Exists() + { + // Arrange + var androidCommand = AndroidCommands.Create(); + var emulatorCommand = androidCommand.Subcommands.First(c => c.Name == "emulator"); + + // Assert + Assert.Contains(emulatorCommand.Subcommands, c => c.Name == "delete"); + } + + [Fact] + public void EmulatorCommand_HasStopSubcommand() + { + // Arrange + var androidCommand = AndroidCommands.Create(); + var emulatorCommand = androidCommand.Subcommands.First(c => c.Name == "emulator"); + + // Assert + Assert.Contains(emulatorCommand.Subcommands, c => c.Name == "stop"); + } + + [Fact] + public void EmulatorStopCommand_HasRequiredNameArgument() + { + // Arrange + var androidCommand = AndroidCommands.Create(); + var emulatorCommand = androidCommand.Subcommands.First(c => c.Name == "emulator"); + var stopCommand = emulatorCommand.Subcommands.First(c => c.Name == "stop"); + + // Assert + Assert.Single(stopCommand.Arguments); + Assert.Equal("name", stopCommand.Arguments.First().Name); + } + + [Fact] + public void EmulatorDeleteCommand_HasRequiredNameArgument() + { + // Arrange + var androidCommand = AndroidCommands.Create(); + var emulatorCommand = androidCommand.Subcommands.First(c => c.Name == "emulator"); + var deleteCommand = emulatorCommand.Subcommands.First(c => c.Name == "delete"); + + // Assert + Assert.Single(deleteCommand.Arguments); + Assert.Equal("name", deleteCommand.Arguments.First().Name); + } + + [Fact] + public void EmulatorStartCommand_HasColdBootOption() + { + // Arrange + var androidCommand = AndroidCommands.Create(); + var emulatorCommand = androidCommand.Subcommands.First(c => c.Name == "emulator"); + var startCommand = emulatorCommand.Subcommands.First(c => c.Name == "start"); + + // Assert + Assert.Contains(startCommand.Options, o => o.Name == "--cold-boot"); + Assert.Contains(startCommand.Options, o => o.Name == "--wait"); + } + + [Fact] + public void JdkCommand_HasAllSubcommands() + { + // Arrange + var androidCommand = AndroidCommands.Create(); + var jdkCommand = androidCommand.Subcommands.First(c => c.Name == "jdk"); + + // Assert + Assert.Contains(jdkCommand.Subcommands, c => c.Name == "check"); + Assert.Contains(jdkCommand.Subcommands, c => c.Name == "install"); + Assert.Contains(jdkCommand.Subcommands, c => c.Name == "list"); + } + + [Fact] + public void SdkCommand_HasAllSubcommands() + { + // Arrange + var androidCommand = AndroidCommands.Create(); + var sdkCommand = androidCommand.Subcommands.First(c => c.Name == "sdk"); + + // Assert + Assert.Contains(sdkCommand.Subcommands, c => c.Name == "check"); + Assert.Contains(sdkCommand.Subcommands, c => c.Name == "install"); + Assert.Contains(sdkCommand.Subcommands, c => c.Name == "list"); + Assert.Contains(sdkCommand.Subcommands, c => c.Name == "accept-licenses"); + } + + [Fact] + public void SdkListCommand_HasAvailableAndAllOptions() + { + // Arrange + var androidCommand = AndroidCommands.Create(); + var sdkCommand = androidCommand.Subcommands.First(c => c.Name == "sdk"); + var listCommand = sdkCommand.Subcommands.First(c => c.Name == "list"); + + // Assert + Assert.Contains(listCommand.Options, o => o.Name == "--available"); + Assert.Contains(listCommand.Options, o => o.Name == "--all"); + } + + [Fact] + public void AndroidCommand_HasAllSubcommands() + { + // Arrange + var androidCommand = AndroidCommands.Create(); + + // Assert + Assert.Contains(androidCommand.Subcommands, c => c.Name == "install"); + Assert.Contains(androidCommand.Subcommands, c => c.Name == "jdk"); + Assert.Contains(androidCommand.Subcommands, c => c.Name == "sdk"); + Assert.Contains(androidCommand.Subcommands, c => c.Name == "emulator"); + } +} diff --git a/src/DevFlow/Microsoft.Maui.Client.UnitTests/AndroidProviderTests.cs b/src/DevFlow/Microsoft.Maui.Client.UnitTests/AndroidProviderTests.cs new file mode 100644 index 00000000..74d542fd --- /dev/null +++ b/src/DevFlow/Microsoft.Maui.Client.UnitTests/AndroidProviderTests.cs @@ -0,0 +1,278 @@ +// 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.Client.Models; +using Microsoft.Maui.Client.Providers.Android; +using Microsoft.Maui.Client.UnitTests.Fakes; +using Xunit; + +namespace Microsoft.Maui.Client.UnitTests; + +public class AndroidProviderTests +{ + [Fact] + public async Task GetMostRecentSystemImageAsync_ReturnsHighestApiLevel() + { + // Arrange + var packages = new List + { + new SdkPackage { Path = "system-images;android-33;google_apis;arm64-v8a" }, + new SdkPackage { Path = "system-images;android-35;google_apis;arm64-v8a" }, + new SdkPackage { Path = "system-images;android-34;google_apis;arm64-v8a" }, + new SdkPackage { Path = "platform-tools" }, // Not a system image + new SdkPackage { Path = "build-tools;34.0.0" } // Not a system image + }; + + var provider = new FakeAndroidProvider + { + InstalledPackages = packages, + GetMostRecentSystemImageFunc = async ct => + { + var pkgs = packages; + return pkgs + .Where(p => p.Path.StartsWith("system-images;android-", StringComparison.OrdinalIgnoreCase)) + .Select(p => new { Package = p, ApiLevel = ExtractApiLevel(p.Path) }) + .Where(x => x.ApiLevel > 0) + .OrderByDescending(x => x.ApiLevel) + .FirstOrDefault()?.Package.Path; + } + }; + + // Act + var result = await provider.GetMostRecentSystemImageAsync(); + + // Assert + Assert.Equal("system-images;android-35;google_apis;arm64-v8a", result); + } + + [Fact] + public async Task GetMostRecentSystemImageAsync_ReturnsNull_WhenNoSystemImages() + { + // Arrange + var provider = new FakeAndroidProvider + { + InstalledPackages = new List + { + new SdkPackage { Path = "platform-tools" }, + new SdkPackage { Path = "build-tools;34.0.0" } + }, + MostRecentSystemImage = null + }; + + // Act + var result = await provider.GetMostRecentSystemImageAsync(); + + // Assert + Assert.Null(result); + } + + [Fact] + public async Task CreateAvdAsync_CreatesAvdWithCorrectParameters() + { + // Arrange + var provider = new FakeAndroidProvider(); + + // Act + var result = await provider.CreateAvdAsync( + "TestEmulator", + "pixel_6", + "system-images;android-35;google_apis;arm64-v8a"); + + // Assert + Assert.Equal("TestEmulator", result.Name); + Assert.Equal("pixel_6", result.DeviceProfile); + Assert.Equal("system-images;android-35;google_apis;arm64-v8a", result.SystemImage); + } + + [Fact] + public async Task DeleteAvdAsync_CallsDeleteWithCorrectName() + { + // Arrange + var provider = new FakeAndroidProvider(); + + // Act + await provider.DeleteAvdAsync("MyEmulator"); + + // Assert + Assert.Single(provider.DeletedAvds); + Assert.Equal("MyEmulator", provider.DeletedAvds[0]); + } + + [Fact] + public async Task StartAvdAsync_StartsWithCorrectOptions() + { + // Arrange + var provider = new FakeAndroidProvider(); + + // Act + await provider.StartAvdAsync("TestEmulator", coldBoot: true, wait: true); + + // Assert + Assert.Single(provider.StartedAvds); + Assert.Equal(("TestEmulator", true, true), provider.StartedAvds[0]); + } + + [Fact] + public async Task GetAvdsAsync_ReturnsAvdList() + { + // Arrange + var provider = new FakeAndroidProvider + { + Avds = new List + { + new AvdInfo { Name = "Pixel_6_API_35", Target = "android-35", DeviceProfile = "pixel_6" }, + new AvdInfo { Name = "Pixel_7_API_34", Target = "android-34", DeviceProfile = "pixel_7" } + } + }; + + // Act + var result = await provider.GetAvdsAsync(); + + // Assert + Assert.Equal(2, result.Count); + Assert.Contains(result, a => a.Name == "Pixel_6_API_35"); + Assert.Contains(result, a => a.Name == "Pixel_7_API_34"); + } + + [Fact] + public async Task InstallAsync_ReportsProgress() + { + // Arrange + var progressMessages = new List(); + var progress = new Progress(msg => progressMessages.Add(msg)); + + var provider = new FakeAndroidProvider + { + InstallCallback = (sdk, jdk, ver, pkgs, prog, ct) => + { + prog?.Report("Step 1/4: Installing JDK..."); + prog?.Report("Step 2/4: Installing Android SDK..."); + prog?.Report("Step 3/4: Accepting licenses..."); + prog?.Report("Step 4/4: Installing packages..."); + } + }; + + // Act + await provider.InstallAsync(progress: progress); + + // Allow progress callbacks to complete + await Task.Delay(100); + + // Assert + Assert.Contains(progressMessages, m => m.Contains("Step 1/4")); + Assert.Contains(progressMessages, m => m.Contains("Step 2/4")); + Assert.Contains(progressMessages, m => m.Contains("Step 3/4")); + Assert.Contains(progressMessages, m => m.Contains("Step 4/4")); + } + + [Fact] + public async Task InstallPackagesAsync_InstallsMultiplePackages() + { + // Arrange + var provider = new FakeAndroidProvider(); + var packages = new[] { "platform-tools", "build-tools;35.0.0", "platforms;android-35" }; + + // Act + await provider.InstallPackagesAsync(packages, acceptLicenses: true); + + // Assert + Assert.Single(provider.InstalledPackageSets); + var installedPackages = provider.InstalledPackageSets[0]; + Assert.Equal(3, installedPackages.Count); + Assert.Contains("platform-tools", installedPackages); + Assert.Contains("build-tools;35.0.0", installedPackages); + Assert.Contains("platforms;android-35", installedPackages); + } + + [Fact] + public async Task GetAvailablePackagesAsync_ReturnsAvailablePackages() + { + // Arrange + var provider = new FakeAndroidProvider + { + AvailablePackages = new List + { + new SdkPackage { Path = "platforms;android-36", Version = "1", Description = "Android SDK Platform 36", IsInstalled = false }, + new SdkPackage { Path = "system-images;android-36;google_apis;arm64-v8a", Version = "1", IsInstalled = false }, + new SdkPackage { Path = "build-tools;36.0.0", Version = "36.0.0", IsInstalled = false } + } + }; + + // Act + var result = await provider.GetAvailablePackagesAsync(); + + // Assert + Assert.Equal(3, result.Count); + Assert.All(result, pkg => Assert.False(pkg.IsInstalled)); + Assert.Contains(result, p => p.Path == "platforms;android-36"); + Assert.Contains(result, p => p.Path == "system-images;android-36;google_apis;arm64-v8a"); + } + + [Fact] + public async Task GetInstalledPackagesAsync_SetsIsInstalledToTrue() + { + // Arrange + var provider = new FakeAndroidProvider + { + InstalledPackages = new List + { + new SdkPackage { Path = "platforms;android-35", Version = "3", IsInstalled = true }, + new SdkPackage { Path = "build-tools;35.0.0", Version = "35.0.0", IsInstalled = true }, + new SdkPackage { Path = "platform-tools", Version = "35.0.2", IsInstalled = true } + } + }; + + // Act + var result = await provider.GetInstalledPackagesAsync(); + + // Assert + Assert.Equal(3, result.Count); + Assert.All(result, pkg => Assert.True(pkg.IsInstalled)); + } + + [Fact] + public async Task GetAvailableAndInstalledPackages_CanBeCombined() + { + // Arrange + var provider = new FakeAndroidProvider + { + InstalledPackages = new List + { + new SdkPackage { Path = "platforms;android-35", Version = "3", IsInstalled = true }, + new SdkPackage { Path = "build-tools;35.0.0", Version = "35.0.0", IsInstalled = true } + }, + AvailablePackages = new List + { + new SdkPackage { Path = "platforms;android-36", Version = "1", IsInstalled = false }, + new SdkPackage { Path = "build-tools;36.0.0", Version = "36.0.0", IsInstalled = false } + } + }; + + // Act + var installed = await provider.GetInstalledPackagesAsync(); + var available = await provider.GetAvailablePackagesAsync(); + var allPackages = installed.Concat(available).ToList(); + + // Assert + Assert.Equal(4, allPackages.Count); + Assert.Equal(2, allPackages.Count(p => p.IsInstalled)); + Assert.Equal(2, allPackages.Count(p => !p.IsInstalled)); + } + + // Helper method to extract API level from system image path + private static int ExtractApiLevel(string systemImagePath) + { + var parts = systemImagePath.Split(';'); + if (parts.Length >= 2) + { + var androidPart = parts[1]; + if (androidPart.StartsWith("android-", StringComparison.OrdinalIgnoreCase)) + { + var levelStr = androidPart.Substring(8); + if (int.TryParse(levelStr, out var level)) + return level; + } + } + return 0; + } +} diff --git a/src/DevFlow/Microsoft.Maui.Client.UnitTests/DeviceManagerTests.cs b/src/DevFlow/Microsoft.Maui.Client.UnitTests/DeviceManagerTests.cs new file mode 100644 index 00000000..ad36071e --- /dev/null +++ b/src/DevFlow/Microsoft.Maui.Client.UnitTests/DeviceManagerTests.cs @@ -0,0 +1,197 @@ +// 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.Client.Models; +using Microsoft.Maui.Client.Providers.Android; +using Microsoft.Maui.Client.Services; +using Microsoft.Maui.Client.UnitTests.Fakes; +using Xunit; + +namespace Microsoft.Maui.Client.UnitTests; + +public class DeviceManagerTests +{ + [Fact] + public async Task GetAllDevicesAsync_ReturnsAndroidDevices() + { + // 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 manager = new DeviceManager(fakeAndroid); + + // Act + var devices = await manager.GetAllDevicesAsync(); + + // Assert + Assert.Single(devices); + Assert.Contains(devices, d => d.Platforms.Contains("android")); + } + + [Fact] + public async Task GetDevicesByPlatformAsync_FiltersCorrectly() + { + // 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 manager = new DeviceManager(fakeAndroid); + + // Act + var androidOnly = await manager.GetDevicesByPlatformAsync("android"); + + // Assert + Assert.Single(androidOnly); + Assert.All(androidOnly, d => Assert.Contains("android", d.Platforms)); + } + + [Fact] + public async Task GetDeviceByIdAsync_FindsCorrectDevice() + { + // Arrange + var fakeAndroid = new FakeAndroidProvider + { + Devices = new List + { + new Device { Id = "device-1", Name = "Device 1", Platforms = new[] { "android" }, Type = DeviceType.Physical, State = DeviceState.Booted, IsEmulator = false, IsRunning = true }, + new Device { Id = "device-2", Name = "Device 2", Platforms = new[] { "android" }, Type = DeviceType.Emulator, State = DeviceState.Shutdown, IsEmulator = true, IsRunning = false } + } + }; + + var manager = new DeviceManager(fakeAndroid); + + // Act + var device = await manager.GetDeviceByIdAsync("device-2"); + + // Assert + Assert.NotNull(device); + Assert.Equal("device-2", device.Id); + Assert.Equal("Device 2", device.Name); + } + + [Fact] + public async Task GetDeviceByIdAsync_ReturnsNull_WhenNotFound() + { + // Arrange + var fakeAndroid = new FakeAndroidProvider(); + var manager = new DeviceManager(fakeAndroid); + + // Act + var device = await manager.GetDeviceByIdAsync("nonexistent"); + + // Assert + Assert.Null(device); + } + + [Fact] + public async Task GetAllDevicesAsync_IncludesShutdownAvds() + { + // Arrange + var fakeAndroid = new FakeAndroidProvider + { + Avds = new List + { + new AvdInfo { Name = "Pixel_6_API_35", Target = "android-35" } + } + }; + + var manager = new DeviceManager(fakeAndroid); + + // Act + var devices = await manager.GetAllDevicesAsync(); + + // Assert + Assert.Single(devices); + Assert.Equal("Pixel_6_API_35", devices[0].Id); + Assert.Equal(DeviceState.Shutdown, devices[0].State); + Assert.Equal(DeviceType.Emulator, devices[0].Type); + } + + [Fact] + public async Task GetAllDevicesAsync_MergesRunningEmulatorWithAvd() + { + // Arrange: ADB returns a running emulator with AVD name in details + var fakeAndroid = new FakeAndroidProvider + { + Devices = new List + { + new Device + { + Id = "emulator-5554", + Name = "Google sdk_gphone64_arm64", + Platforms = new[] { "android" }, + Type = DeviceType.Emulator, + State = DeviceState.Booted, + IsEmulator = true, + IsRunning = true, + EmulatorId = "Pixel_6_API_35", + Details = new Dictionary { ["avd"] = "Pixel_6_API_35" } + } + }, + Avds = new List + { + new AvdInfo { Name = "Pixel_6_API_35", Target = "android-35", DeviceProfile = "pixel_6" } + } + }; + + var manager = new DeviceManager(fakeAndroid); + + // Act + var devices = await manager.GetAllDevicesAsync(); + + // Assert: should be merged into a single entry, not two + Assert.Single(devices); + Assert.Equal("emulator-5554", devices[0].Id); + Assert.Equal("Pixel_6_API_35", devices[0].EmulatorId); + Assert.True(devices[0].IsRunning); + } + + [Fact] + public async Task GetAllDevicesAsync_MergesRunningEmulatorWithAvd_ByEmulatorId() + { + // Arrange: ADB returns a running emulator with EmulatorId set but no "avd" in Details + var fakeAndroid = new FakeAndroidProvider + { + Devices = new List + { + new Device + { + Id = "emulator-5554", + Name = "Google sdk_gphone64_arm64", + Platforms = new[] { "android" }, + Type = DeviceType.Emulator, + State = DeviceState.Booted, + IsEmulator = true, + IsRunning = true, + EmulatorId = "Pixel_6_API_35", + Details = new Dictionary() + } + }, + Avds = new List + { + new AvdInfo { Name = "Pixel_6_API_35", Target = "android-35", DeviceProfile = "pixel_6" } + } + }; + + var manager = new DeviceManager(fakeAndroid); + + // Act + var devices = await manager.GetAllDevicesAsync(); + + // Assert: should still merge via EmulatorId fallback + Assert.Single(devices); + Assert.Equal("emulator-5554", devices[0].Id); + Assert.Equal("Pixel_6_API_35", devices[0].EmulatorId); + Assert.True(devices[0].IsRunning); + } +} diff --git a/src/DevFlow/Microsoft.Maui.Client.UnitTests/DoctorServiceTests.cs b/src/DevFlow/Microsoft.Maui.Client.UnitTests/DoctorServiceTests.cs new file mode 100644 index 00000000..593d1778 --- /dev/null +++ b/src/DevFlow/Microsoft.Maui.Client.UnitTests/DoctorServiceTests.cs @@ -0,0 +1,128 @@ +// 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.Client.Models; +using Microsoft.Maui.Client.Services; +using Microsoft.Maui.Client.UnitTests.Fakes; +using Xunit; + +namespace Microsoft.Maui.Client.UnitTests; + +public class DoctorServiceTests +{ + [Fact] + public async Task RunAllChecksAsync_IncludesDotNetChecks() + { + // Arrange + var fakeAndroid = new FakeAndroidProvider(); + var service = new DoctorService(fakeAndroid); + + // Act + var report = await service.RunAllChecksAsync(); + + // Assert + Assert.NotNull(report); + Assert.True(report.Checks.Any(c => c.Category == "dotnet")); + } + + [Fact] + public async Task RunAllChecksAsync_IncludesAndroidChecks_WhenProviderReturnsChecks() + { + // Arrange + var fakeAndroid = new FakeAndroidProvider + { + HealthChecks = new List + { + new HealthCheck + { + Category = "android", + Name = "JDK", + Status = CheckStatus.Ok, + Message = "JDK 17" + }, + new HealthCheck + { + Category = "android", + Name = "Android SDK", + Status = CheckStatus.Ok + } + } + }; + + var service = new DoctorService(fakeAndroid); + + // Act + var report = await service.RunAllChecksAsync(); + + // Assert + Assert.Contains(report.Checks, c => c.Category == "android" && c.Name == "JDK"); + Assert.Contains(report.Checks, c => c.Category == "android" && c.Name == "Android SDK"); + } + + [Fact] + public async Task RunAllChecksAsync_CalculatesCorrectSummary() + { + // Arrange + var fakeAndroid = new FakeAndroidProvider + { + HealthChecks = new List + { + new HealthCheck { Category = "android", Name = "JDK", Status = CheckStatus.Ok }, + new HealthCheck { Category = "android", Name = "SDK", Status = CheckStatus.Warning }, + new HealthCheck { Category = "android", Name = "AVD", Status = CheckStatus.Error } + } + }; + + var service = new DoctorService(fakeAndroid); + + // Act + var report = await service.RunAllChecksAsync(); + + // Assert - should have dotnet checks + android checks + Assert.True(report.Summary.Total >= 3); // At least 3 checks + Assert.True(report.Summary.Warning >= 1); // At least 1 warning + Assert.True(report.Summary.Error >= 1); // At least 1 error + } + + [Fact] + public async Task RunCategoryChecksAsync_SetsStatusBasedOnChecks() + { + // Arrange - all OK android checks only (avoids environment-dependent dotnet checks) + var fakeAndroid = new FakeAndroidProvider + { + HealthChecks = new List + { + new HealthCheck { Category = "android", Name = "JDK", Status = CheckStatus.Ok } + } + }; + + var service = new DoctorService(fakeAndroid); + + // Act - use category check to isolate from dotnet/workload environment checks + var report = await service.RunCategoryChecksAsync("android"); + + // Assert - status should reflect worst check (all OK → not unhealthy) + Assert.NotEqual(HealthStatus.Unhealthy, report.Status); + } + + [Fact] + public async Task RunAllChecksAsync_IncludesAndroidChecks_WhenProviderReturnsAndroidOnly() + { + // Arrange + var fakeAndroid = new FakeAndroidProvider + { + HealthChecks = new List + { + new HealthCheck { Category = "android", Name = "JDK", Status = CheckStatus.Ok } + } + }; + + var service = new DoctorService(fakeAndroid); + + // Act + var report = await service.RunAllChecksAsync(); + + // Assert - android checks should be present + Assert.Contains(report.Checks, c => c.Category == "android" && c.Name == "JDK"); + } +} diff --git a/src/DevFlow/Microsoft.Maui.Client.UnitTests/ErrorCodesTests.cs b/src/DevFlow/Microsoft.Maui.Client.UnitTests/ErrorCodesTests.cs new file mode 100644 index 00000000..7c0dfdd8 --- /dev/null +++ b/src/DevFlow/Microsoft.Maui.Client.UnitTests/ErrorCodesTests.cs @@ -0,0 +1,35 @@ +// 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.Client.Errors; +using Xunit; + +namespace Microsoft.Maui.Client.UnitTests; + +public class ErrorCodesTests +{ + [Fact] + public void ErrorCodes_HaveCorrectFormat() + { + // All error codes should start with 'E' followed by digits + Assert.StartsWith("E", ErrorCodes.InternalError); + Assert.StartsWith("E", ErrorCodes.JdkNotFound); + Assert.StartsWith("E", ErrorCodes.AndroidSdkNotFound); + } + + [Fact] + public void ErrorCodes_Categories_AreCorrectlyGrouped() + { + // Tool errors: E1xxx + Assert.StartsWith("E1", ErrorCodes.InternalError); + Assert.StartsWith("E1", ErrorCodes.DeviceNotFound); + + // JDK errors: E20xx + Assert.StartsWith("E20", ErrorCodes.JdkNotFound); + Assert.StartsWith("E20", ErrorCodes.JdkVersionUnsupported); + + // Android errors: E21xx + Assert.StartsWith("E21", ErrorCodes.AndroidSdkNotFound); + Assert.StartsWith("E21", ErrorCodes.AndroidSdkManagerNotFound); + } +} diff --git a/src/DevFlow/Microsoft.Maui.Client.UnitTests/Fakes/FakeAndroidProvider.cs b/src/DevFlow/Microsoft.Maui.Client.UnitTests/Fakes/FakeAndroidProvider.cs new file mode 100644 index 00000000..81f29b9c --- /dev/null +++ b/src/DevFlow/Microsoft.Maui.Client.UnitTests/Fakes/FakeAndroidProvider.cs @@ -0,0 +1,179 @@ +// 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.Client.Models; +using Microsoft.Maui.Client.Providers.Android; + +namespace Microsoft.Maui.Client.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 FakeAndroidProvider : IAndroidProvider +{ + // --- Configurable return values --- + + public string? SdkPath { get; set; } + public string? JdkPath { get; set; } + public bool IsSdkInstalled { get; set; } + public bool IsJdkInstalled { get; set; } + public bool SdkPathRequiresElevation { get; set; } + + public List HealthChecks { get; set; } = new(); + public List Devices { get; set; } = new(); + public List Avds { get; set; } = new(); + public List InstalledPackages { get; set; } = new(); + public List AvailablePackages { get; set; } = new(); + public string? MostRecentSystemImage { get; set; } + public bool LicensesAccepted { get; set; } + public (string Command, string Arguments)? LicenseAcceptanceCommand { get; set; } + + /// + /// Optional delegate for . When set, the delegate + /// produces the return value; otherwise a default is built + /// from the supplied arguments. + /// + public Func? CreateAvdFunc { get; set; } + + /// + /// Optional delegate for . When set + /// it is called instead of returning . + /// + public Func>? GetMostRecentSystemImageFunc { get; set; } + + /// + /// Optional delegate invoked by so tests can simulate + /// progress reporting. + /// + public Action?, IProgress?, CancellationToken>? InstallCallback { get; set; } + + // --- Call tracking --- + + public List DeletedAvds { get; } = new(); + public List<(string Name, bool ColdBoot, bool Wait)> StartedAvds { get; } = new(); + public List<(string Name, string DeviceProfile, string SystemImage, bool Force)> CreatedAvds { get; } = new(); + public List> InstalledPackageSets { get; } = new(); + public List StoppedEmulators { get; } = new(); + public List> UninstalledPackageSets { get; } = new(); + public int AcceptLicensesCalled { get; private set; } + public List<(string? SdkPath, string? JdkPath, int JdkVersion, List? AdditionalPackages)> InstallCalls { get; } = new(); + public List InstallSdkToolsCalls { get; } = new(); + public List InstallJdkCalls { get; } = new(); + public bool Disposed { get; private set; } + + // --- IAndroidProvider implementation --- + + public Task> CheckHealthAsync(CancellationToken cancellationToken = default) + => Task.FromResult(HealthChecks); + + public Task> GetDevicesAsync(CancellationToken cancellationToken = default) + => Task.FromResult(Devices); + + public Task> GetAvdsAsync(CancellationToken cancellationToken = default) + => Task.FromResult(Avds); + + public Task CreateAvdAsync(string name, string deviceProfile, string systemImage, bool force = false, CancellationToken cancellationToken = default) + { + CreatedAvds.Add((name, deviceProfile, systemImage, force)); + + var result = CreateAvdFunc != null + ? CreateAvdFunc(name, deviceProfile, systemImage, force) + : new AvdInfo { Name = name, DeviceProfile = deviceProfile, SystemImage = systemImage }; + + return Task.FromResult(result); + } + + public Task DeleteAvdAsync(string name, CancellationToken cancellationToken = default) + { + DeletedAvds.Add(name); + return Task.CompletedTask; + } + + public Task StartAvdAsync(string name, bool coldBoot = false, bool wait = false, CancellationToken cancellationToken = default) + { + StartedAvds.Add((name, coldBoot, wait)); + return Task.CompletedTask; + } + + public Task StopEmulatorAsync(string deviceSerial, CancellationToken cancellationToken = default) + { + StoppedEmulators.Add(deviceSerial); + return Task.CompletedTask; + } + + public Task> GetInstalledPackagesAsync(CancellationToken cancellationToken = default) + => Task.FromResult(InstalledPackages); + + public Task> GetAvailablePackagesAsync(CancellationToken cancellationToken = default) + => Task.FromResult(AvailablePackages); + + public Task GetMostRecentSystemImageAsync(CancellationToken cancellationToken = default) + { + if (GetMostRecentSystemImageFunc != null) + return GetMostRecentSystemImageFunc(cancellationToken); + + return Task.FromResult(MostRecentSystemImage); + } + + public Task InstallPackagesAsync(IEnumerable packages, bool acceptLicenses = false, CancellationToken cancellationToken = default) + { + InstalledPackageSets.Add(packages.ToList()); + return Task.CompletedTask; + } + + public Task InstallPackagesAsync(IEnumerable packages, bool acceptLicenses, Action? onProgress, CancellationToken cancellationToken = default) + { + InstalledPackageSets.Add(packages.ToList()); + return Task.CompletedTask; + } + + public Task UninstallPackagesAsync(IEnumerable packages, CancellationToken cancellationToken = default) + { + UninstalledPackageSets.Add(packages.ToList()); + return Task.CompletedTask; + } + + public Task AcceptLicensesAsync(CancellationToken cancellationToken = default) + { + AcceptLicensesCalled++; + return Task.CompletedTask; + } + + public Task AcceptLicensesAsync(Action? onProgress, CancellationToken cancellationToken = default) + { + AcceptLicensesCalled++; + return Task.CompletedTask; + } + + public Task AreLicensesAcceptedAsync(CancellationToken cancellationToken = default) + => Task.FromResult(LicensesAccepted); + + public (string Command, string Arguments)? GetLicenseAcceptanceCommand() + => LicenseAcceptanceCommand; + + public Task InstallJdkAsync(int version = 17, string? installPath = null, IProgress? progress = null, CancellationToken cancellationToken = default) + { + InstallJdkCalls.Add(version); + return Task.CompletedTask; + } + + public Task InstallAsync(string? sdkPath = null, string? jdkPath = null, int jdkVersion = 17, IEnumerable? additionalPackages = null, bool acceptLicenses = false, IProgress? progress = null, CancellationToken cancellationToken = default) + { + InstallCalls.Add((sdkPath, jdkPath, jdkVersion, additionalPackages?.ToList())); + InstallCallback?.Invoke(sdkPath, jdkPath, jdkVersion, additionalPackages, progress, cancellationToken); + return Task.CompletedTask; + } + + public Task InstallSdkToolsAsync(string targetPath, Action? onProgress = null, CancellationToken cancellationToken = default) + { + InstallSdkToolsCalls.Add(targetPath); + return Task.CompletedTask; + } + + public void Dispose() + { + Disposed = true; + } +} diff --git a/src/DevFlow/Microsoft.Maui.Client.UnitTests/MauiToolExceptionTests.cs b/src/DevFlow/Microsoft.Maui.Client.UnitTests/MauiToolExceptionTests.cs new file mode 100644 index 00000000..56089f2f --- /dev/null +++ b/src/DevFlow/Microsoft.Maui.Client.UnitTests/MauiToolExceptionTests.cs @@ -0,0 +1,108 @@ +// 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.Client.Errors; +using Xunit; + +namespace Microsoft.Maui.Client.UnitTests; + +public class MauiToolExceptionTests +{ + [Fact] + public void Constructor_WithCodeAndMessage_SetsProperties() + { + var ex = new MauiToolException(ErrorCodes.JdkNotFound, "JDK not installed"); + + Assert.Equal(ErrorCodes.JdkNotFound, ex.Code); + Assert.Equal("JDK not installed", ex.Message); + Assert.Null(ex.Remediation); + Assert.Null(ex.NativeError); + } + + [Fact] + public void Constructor_WithRemediation_SetsRemediationInfo() + { + var remediation = new RemediationInfo( + Type: RemediationType.AutoFixable, + Command: "maui android jdk install" + ); + + var ex = new MauiToolException( + ErrorCodes.JdkNotFound, + "JDK not installed", + remediation: remediation); + + Assert.NotNull(ex.Remediation); + Assert.Equal(RemediationType.AutoFixable, ex.Remediation.Type); + Assert.Equal("maui android jdk install", ex.Remediation.Command); + } + + [Fact] + public void Constructor_WithNativeError_SetsNativeError() + { + var ex = new MauiToolException( + ErrorCodes.AndroidSdkManagerNotFound, + "sdkmanager not found", + nativeError: "Command 'sdkmanager' not found in PATH"); + + Assert.Equal("Command 'sdkmanager' not found in PATH", ex.NativeError); + } + + [Fact] + public void Constructor_WithContext_SetsContext() + { + var context = new Dictionary + { + ["sdk_path"] = "/path/to/sdk", + ["expected_version"] = 35 + }; + + var ex = new MauiToolException( + ErrorCodes.AndroidPackageInstallFailed, + "Package not found", + context: context); + + Assert.NotNull(ex.Context); + Assert.Equal("/path/to/sdk", ex.Context["sdk_path"]); + Assert.Equal(35, ex.Context["expected_version"]); + } + + [Fact] + public void AutoFixable_CreatesExceptionWithAutoFixableRemediation() + { + var ex = MauiToolException.AutoFixable( + ErrorCodes.JdkNotFound, + "JDK not found", + "maui android jdk install"); + + Assert.Equal(ErrorCodes.JdkNotFound, ex.Code); + Assert.NotNull(ex.Remediation); + Assert.Equal(RemediationType.AutoFixable, ex.Remediation.Type); + Assert.Equal("maui android jdk install", ex.Remediation.Command); + } + + [Fact] + public void UserActionRequired_CreatesExceptionWithManualSteps() + { + var steps = new[] { "Download Android Studio", "Install SDK", "Accept licenses" }; + var ex = MauiToolException.UserActionRequired( + ErrorCodes.AndroidSdkNotFound, + "Android SDK not found", + steps); + + Assert.NotNull(ex.Remediation); + Assert.Equal(RemediationType.UserAction, ex.Remediation.Type); + Assert.Equal(steps, ex.Remediation.ManualSteps); + } + + [Fact] + public void Terminal_CreatesNonFixableException() + { + var ex = MauiToolException.Terminal( + ErrorCodes.PlatformNotSupported, + "Platform not supported"); + + Assert.NotNull(ex.Remediation); + Assert.Equal(RemediationType.Terminal, ex.Remediation.Type); + } +} diff --git a/src/DevFlow/Microsoft.Maui.Client.UnitTests/Microsoft.Maui.Client.UnitTests.csproj b/src/DevFlow/Microsoft.Maui.Client.UnitTests/Microsoft.Maui.Client.UnitTests.csproj new file mode 100644 index 00000000..7c0780bc --- /dev/null +++ b/src/DevFlow/Microsoft.Maui.Client.UnitTests/Microsoft.Maui.Client.UnitTests.csproj @@ -0,0 +1,23 @@ + + + + net10.0 + false + true + $(NoWarn);CA1307;CA1310;xUnit2012 + + + + + runtime; build; native; contentfiles; analyzers; buildtransitive + all + + + + + + + + + + diff --git a/src/DevFlow/Microsoft.Maui.Client.UnitTests/OutputFormatterTests.cs b/src/DevFlow/Microsoft.Maui.Client.UnitTests/OutputFormatterTests.cs new file mode 100644 index 00000000..1662564e --- /dev/null +++ b/src/DevFlow/Microsoft.Maui.Client.UnitTests/OutputFormatterTests.cs @@ -0,0 +1,220 @@ +// Licensed to the .NET Foundation under one or more agreements. +// The .NET Foundation licenses this file to you under the MIT license. + +using System.Text; +using Microsoft.Maui.Client.Errors; +using Microsoft.Maui.Client.Models; +using Microsoft.Maui.Client.Output; +using Spectre.Console; +using Spectre.Console.Testing; +using Xunit; + +namespace Microsoft.Maui.Client.UnitTests; + +public class OutputFormatterTests +{ + [Fact] + public void JsonOutputFormatter_WriteError_ProducesValidJson() + { + var sb = new StringBuilder(); + using var writer = new StringWriter(sb); + var formatter = new JsonOutputFormatter(writer); + + var error = new ErrorResult + { + Code = ErrorCodes.JdkNotFound, + Category = "platform", + Message = "JDK not found" + }; + + formatter.WriteError(error); + var output = sb.ToString(); + + Assert.Contains("\"code\":", output); + Assert.Contains("\"E2001\"", output); + Assert.Contains("\"message\":", output); + Assert.Contains("JDK not found", output); + } + + [Fact] + public void JsonOutputFormatter_Write_ProducesValidJson() + { + var sb = new StringBuilder(); + using var writer = new StringWriter(sb); + var formatter = new JsonOutputFormatter(writer); + + var data = new { name = "test", value = 42 }; + formatter.Write(data); + + var output = sb.ToString(); + Assert.Contains("\"name\":", output); + Assert.Contains("\"test\"", output); + Assert.Contains("\"value\":", output); + Assert.Contains("42", output); + } + + [Fact] + public void JsonOutputFormatter_WriteException_ConvertsToErrorResult() + { + var sb = new StringBuilder(); + using var writer = new StringWriter(sb); + var formatter = new JsonOutputFormatter(writer); + + var exception = new MauiToolException( + ErrorCodes.AndroidSdkNotFound, + "Android SDK not found"); + + formatter.WriteError(exception); + var output = sb.ToString(); + + Assert.Contains("\"E2101\"", output); + Assert.Contains("Android SDK not found", output); + } + + private static (SpectreOutputFormatter formatter, TestConsole console) CreateTestFormatter(bool verbose = false) + { + var console = new TestConsole(); + var formatter = new SpectreOutputFormatter(console, verbose); + return (formatter, console); + } + + [Fact] + public void SpectreOutputFormatter_WriteSuccess_OutputsMessage() + { + var (formatter, console) = CreateTestFormatter(); + + formatter.WriteSuccess("Operation completed"); + var output = console.Output; + + Assert.Contains("Operation completed", output); + Assert.Contains("✓", output); + } + + [Fact] + public void SpectreOutputFormatter_WriteWarning_OutputsMessage() + { + var (formatter, console) = CreateTestFormatter(); + + formatter.WriteWarning("This is a warning"); + var output = console.Output; + + Assert.Contains("This is a warning", output); + Assert.Contains("⚠", output); + } + + [Fact] + public void SpectreOutputFormatter_WriteInfo_OutputsMessage() + { + var (formatter, console) = CreateTestFormatter(); + + formatter.WriteInfo("Information message"); + var output = console.Output; + + Assert.Contains("Information message", output); + Assert.Contains("ℹ", output); + } + + [Fact] + public void SpectreOutputFormatter_WriteError_IncludesErrorCode() + { + var (formatter, console) = CreateTestFormatter(); + + var error = new ErrorResult + { + Code = ErrorCodes.DeviceNotFound, + Category = "tool", + Message = "Device not found" + }; + + formatter.WriteError(error); + var output = console.Output; + + Assert.Contains("E1006", output); + Assert.Contains("Device not found", output); + } + + [Fact] + public void SpectreOutputFormatter_WriteDoctorReport_FormatsCorrectly() + { + var (formatter, console) = CreateTestFormatter(); + + var report = new DoctorReport + { + CorrelationId = "test123", + Timestamp = DateTime.UtcNow, + Status = HealthStatus.Healthy, + Checks = new List + { + new HealthCheck + { + Category = "dotnet", + Name = ".NET SDK", + Status = CheckStatus.Ok, + Message = "8.0.100" + }, + new HealthCheck + { + Category = "android", + Name = "JDK", + Status = CheckStatus.Warning, + Message = "JDK found but outdated" + } + }, + Summary = new DoctorSummary { Total = 2, Ok = 1, Warning = 1, Error = 0 } + }; + + formatter.WriteResult(report); + var output = console.Output; + + Assert.Contains(".NET SDK", output); + Assert.Contains("JDK", output); + } + + [Fact] + public void SpectreOutputFormatter_WriteDeviceList_FormatsTable() + { + var (formatter, console) = CreateTestFormatter(); + + var result = new DeviceListResult + { + Devices = new List + { + new Device + { + Name = "Pixel 6", + Id = "emulator-5554", + Platforms = new[] { "android" }, + Type = DeviceType.Emulator, + State = DeviceState.Booted, + IsEmulator = true, + IsRunning = true + } + } + }; + + formatter.WriteResult(result); + var output = console.Output; + + Assert.Contains("Pixel 6", output); + Assert.Contains("emulator-5554", output); + Assert.Contains("android", output); + } + + [Fact] + public void SpectreOutputFormatter_WriteTable_FormatsColumns() + { + var (formatter, console) = CreateTestFormatter(); + + var items = new[] { ("Apple", "Fruit"), ("Carrot", "Vegetable") }; + formatter.WriteTable(items, + ("Name", i => i.Item1), + ("Category", i => i.Item2)); + + var output = console.Output; + + Assert.Contains("Name", output); + Assert.Contains("Category", output); + Assert.Contains("Apple", output); + Assert.Contains("Carrot", output); + } +} diff --git a/src/DevFlow/Microsoft.Maui.Client.UnitTests/PlatformsTests.cs b/src/DevFlow/Microsoft.Maui.Client.UnitTests/PlatformsTests.cs new file mode 100644 index 00000000..e72c48ee --- /dev/null +++ b/src/DevFlow/Microsoft.Maui.Client.UnitTests/PlatformsTests.cs @@ -0,0 +1,48 @@ +// 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.Client.Models; +using Xunit; + +namespace Microsoft.Maui.Client.UnitTests; + +public class PlatformsTests +{ + [Theory] + [InlineData("android", true)] + [InlineData("ios", true)] + [InlineData("maccatalyst", true)] + [InlineData("windows", true)] + [InlineData("all", true)] + [InlineData("ANDROID", true)] + [InlineData("IOS", true)] + [InlineData("invalid", false)] + [InlineData("", false)] + [InlineData(null, false)] + public void IsValid_ReturnsCorrectResult(string? platform, bool expected) + { + Assert.Equal(expected, Platforms.IsValid(platform)); + } + + [Theory] + [InlineData("android", "android")] + [InlineData("ANDROID", "android")] + [InlineData("ios", "ios")] + [InlineData("apple", "ios")] + [InlineData("iphone", "ios")] + [InlineData("ipad", "ios")] + [InlineData("mac", "maccatalyst")] + [InlineData("macos", "maccatalyst")] + [InlineData("catalyst", "maccatalyst")] + [InlineData("maccatalyst", "maccatalyst")] + [InlineData("windows", "windows")] + [InlineData("win", "windows")] + [InlineData("win32", "windows")] + [InlineData("win64", "windows")] + [InlineData(null, "all")] + [InlineData("unknown", "unknown")] + public void Normalize_ReturnsCorrectResult(string? input, string expected) + { + Assert.Equal(expected, Platforms.Normalize(input)); + } +} diff --git a/src/DevFlow/Microsoft.Maui.Client.UnitTests/ProcessRunnerTests.cs b/src/DevFlow/Microsoft.Maui.Client.UnitTests/ProcessRunnerTests.cs new file mode 100644 index 00000000..7501adb2 --- /dev/null +++ b/src/DevFlow/Microsoft.Maui.Client.UnitTests/ProcessRunnerTests.cs @@ -0,0 +1,170 @@ +// 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.Client.Errors; +using Microsoft.Maui.Client.Providers.Android; +using Microsoft.Maui.Client.Services; +using Microsoft.Maui.Client.Utils; +using Xunit; + +namespace Microsoft.Maui.Client.UnitTests; + +public class ProcessRunnerTests +{ + [Fact] + public void SanitizeArg_NullInput_ThrowsArgumentNullException() + { + Assert.Throws(() => ProcessRunner.SanitizeArg(null!)); + } + + [Theory] + [InlineData(";")] + [InlineData("&")] + [InlineData("|")] + [InlineData("`")] + [InlineData("$")] + [InlineData("\n")] + [InlineData("\r")] + [InlineData("\0")] + public void SanitizeArg_ForbiddenCharacter_ThrowsArgumentException(string forbidden) + { + var ex = Assert.Throws(() => ProcessRunner.SanitizeArg($"test{forbidden}value")); + Assert.Contains("forbidden characters", ex.Message); + } + + [Fact] + public void SanitizeArg_CommandInjection_ThrowsArgumentException() + { + Assert.Throws(() => ProcessRunner.SanitizeArg("test; rm -rf /")); + Assert.Throws(() => ProcessRunner.SanitizeArg("test && evil")); + Assert.Throws(() => ProcessRunner.SanitizeArg("test | cat /etc/passwd")); + Assert.Throws(() => ProcessRunner.SanitizeArg("$(whoami)")); + Assert.Throws(() => ProcessRunner.SanitizeArg("`whoami`")); + } + + [Fact] + public void SanitizeArg_ValueWithSpaces_ReturnsUnchanged() + { + var result = ProcessRunner.SanitizeArg("hello world"); + Assert.Equal("hello world", result); + } + + [Fact] + public void SanitizeArg_SimpleValue_ReturnsUnchanged() + { + Assert.Equal("emulator-5554", ProcessRunner.SanitizeArg("emulator-5554")); + Assert.Equal("ActivityManager:I", ProcessRunner.SanitizeArg("ActivityManager:I")); + Assert.Equal("test123", ProcessRunner.SanitizeArg("test123")); + } + + [Fact] + public void SanitizeArg_EmptyString_ReturnsEmpty() + { + Assert.Equal("", ProcessRunner.SanitizeArg("")); + } +} + +public class DoctorServiceParseCommandTests +{ + [Fact] + public void ParseCommand_SingleWord_ReturnsFileNameOnly() + { + var (fileName, args) = DoctorService.ParseCommand("dotnet"); + Assert.Equal("dotnet", fileName); + Assert.Empty(args); + } + + [Fact] + public void ParseCommand_MultipleWords_SplitsCorrectly() + { + var (fileName, args) = DoctorService.ParseCommand("dotnet workload install maui"); + Assert.Equal("dotnet", fileName); + Assert.Equal(new[] { "workload", "install", "maui" }, args); + } + + [Fact] + public void ParseCommand_QuotedExecutable_SplitsCorrectly() + { + var (fileName, args) = DoctorService.ParseCommand("\"C:\\Program Files\\tool.exe\" --flag value"); + Assert.Equal("C:\\Program Files\\tool.exe", fileName); + Assert.Equal(new[] { "--flag", "value" }, args); + } + + [Fact] + public void ParseCommand_QuotedExecutableOnly_ReturnsEmptyArgs() + { + var (fileName, args) = DoctorService.ParseCommand("\"my tool\""); + Assert.Equal("my tool", fileName); + Assert.Empty(args); + } + + [Theory] + [InlineData("maui android jdk install", "maui", "android,jdk,install")] + [InlineData("maui android install --accept-licenses", "maui", "android,install,--accept-licenses")] + [InlineData("maui android sdk install platform-tools", "maui", "android,sdk,install,platform-tools")] + [InlineData("maui android sdk accept-licenses", "maui", "android,sdk,accept-licenses")] + [InlineData("xcode-select --install", "xcode-select", "--install")] + public void ParseCommand_RealFixCommands_SplitsCorrectly(string command, string expectedFile, string expectedArgs) + { + var (fileName, args) = DoctorService.ParseCommand(command); + Assert.Equal(expectedFile, fileName); + Assert.Equal(expectedArgs.Split(','), args); + } + + [Fact] + public void ParseCommand_WhitespaceAroundCommand_TrimsCorrectly() + { + var (fileName, args) = DoctorService.ParseCommand(" dotnet build "); + Assert.Equal("dotnet", fileName); + Assert.Equal(new[] { "build" }, args); + } +} + +public class JdkManagerValidateInstallPathTests +{ + [Fact] + public void ValidateInstallPath_HomeDirectory_Throws() + { + var homePath = Environment.GetFolderPath(Environment.SpecialFolder.UserProfile); + var ex = Assert.Throws(() => JdkManager.ValidateInstallPath(homePath)); + Assert.Contains("home directory", ex.Message); + } + + [Theory] + [InlineData("/")] + [InlineData("/usr")] + [InlineData("/bin")] + [InlineData("/etc")] + [InlineData("/tmp")] + public void ValidateInstallPath_SystemDirectories_Throws(string path) + { + if (OperatingSystem.IsWindows()) + return; // Unix paths on Windows resolve differently + + var ex = Assert.Throws(() => JdkManager.ValidateInstallPath(path)); + Assert.Contains("system directory", ex.Message, StringComparison.OrdinalIgnoreCase); + } + + [Fact] + public void ValidateInstallPath_ShallowPath_Throws() + { + // A 2-segment path like "/Users/foo" or "C:\Users" should be rejected + // On Windows, "C:\Users" matches a known system directory; on Unix, "/Users" is too shallow + var shallowPath = OperatingSystem.IsWindows() ? "C:\\Users" : "/Users"; + var ex = Assert.Throws(() => JdkManager.ValidateInstallPath(shallowPath)); + Assert.True( + ex.Message.Contains("too shallow", StringComparison.OrdinalIgnoreCase) || + ex.Message.Contains("system directory", StringComparison.OrdinalIgnoreCase), + $"Expected 'too shallow' or 'system directory' in: {ex.Message}"); + } + + [Fact] + public void ValidateInstallPath_DeepEnoughPath_DoesNotThrow() + { + // A 3+ segment path should be accepted + var validPath = OperatingSystem.IsWindows() + ? "C:\\Users\\test\\jdk-17" + : "/Users/test/jdk-17"; + JdkManager.ValidateInstallPath(validPath); // Should not throw + } +} diff --git a/src/DevFlow/Microsoft.Maui.Client.UnitTests/ServiceConfigurationTests.cs b/src/DevFlow/Microsoft.Maui.Client.UnitTests/ServiceConfigurationTests.cs new file mode 100644 index 00000000..2cabc324 --- /dev/null +++ b/src/DevFlow/Microsoft.Maui.Client.UnitTests/ServiceConfigurationTests.cs @@ -0,0 +1,90 @@ +// Licensed to the .NET Foundation under one or more agreements. +// The .NET Foundation licenses this file to you under the MIT license. + +using Microsoft.Extensions.DependencyInjection; +using Microsoft.Maui.Client.Providers.Android; +using Microsoft.Maui.Client.Services; +using Microsoft.Maui.Client.UnitTests.Fakes; +using Xunit; + +namespace Microsoft.Maui.Client.UnitTests; + +public class ServiceConfigurationTests +{ + [Fact] + public void CreateServiceProvider_RegistersAllServices() + { + // Act + var provider = ServiceConfiguration.CreateServiceProvider(); + + // Assert + Assert.NotNull(provider.GetService()); + Assert.NotNull(provider.GetService()); + Assert.NotNull(provider.GetService()); + Assert.NotNull(provider.GetService()); + } + + [Fact] + public void CreateServiceProvider_ReturnsSingletonForProviders() + { + // Act + var provider = ServiceConfiguration.CreateServiceProvider(); + var android1 = provider.GetService(); + var android2 = provider.GetService(); + + // Assert + Assert.Same(android1, android2); + } + + [Fact] + public void CreateTestServiceProvider_UsesProvidedFakes() + { + // Arrange + var fakeAndroid = new FakeAndroidProvider(); + + // Act + var provider = ServiceConfiguration.CreateTestServiceProvider( + androidProvider: fakeAndroid); + + // Assert + Assert.Same(fakeAndroid, provider.GetService()); + } + + [Fact] + public void CreateTestServiceProvider_CreatesMissingServices() + { + // Arrange - only provide android fake + var fakeAndroid = new FakeAndroidProvider(); + + // Act + var provider = ServiceConfiguration.CreateTestServiceProvider( + androidProvider: fakeAndroid); + + // Assert - should create real services for everything else + Assert.Same(fakeAndroid, provider.GetService()); + Assert.NotNull(provider.GetService()); + } + + [Fact] + public void Program_Services_CanBeOverridden() + { + try + { + // Arrange + var fakeAndroid = new FakeAndroidProvider(); + var testProvider = ServiceConfiguration.CreateTestServiceProvider( + androidProvider: fakeAndroid); + + // Act + Program.Services = testProvider; + + // Assert + Assert.Same(fakeAndroid, Program.AndroidProvider); + } + finally + { + // Cleanup + Program.ResetServices(); + } + } +} diff --git a/src/DevFlow/Microsoft.Maui.Client/Commands/AndroidCommands.Emulator.cs b/src/DevFlow/Microsoft.Maui.Client/Commands/AndroidCommands.Emulator.cs new file mode 100644 index 00000000..9ca4c761 --- /dev/null +++ b/src/DevFlow/Microsoft.Maui.Client/Commands/AndroidCommands.Emulator.cs @@ -0,0 +1,496 @@ +// 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.Client.Models; +using Microsoft.Maui.Client.Output; +using Microsoft.Maui.Client.Providers.Android; +using Microsoft.Maui.Client.Utils; +using Spectre.Console; + +namespace Microsoft.Maui.Client.Commands; + +public static partial class AndroidCommands +{ + static Command CreateEmulatorCommand() + { + var command = new Command("emulator", "Manage Android emulators"); + + // emulator list + var listCommand = new Command("list", "List available emulators"); + listCommand.SetAction(async (ParseResult parseResult, CancellationToken cancellationToken) => + { + var androidProvider = Program.AndroidProvider; + + var useJson = parseResult.GetValue(GlobalOptions.JsonOption); + var formatter = Program.GetFormatter(parseResult); + + try + { + var avds = await androidProvider.GetAvdsAsync(cancellationToken); + + if (useJson) + { + formatter.Write(avds); + } + else + { + if (!avds.Any()) + { + formatter.WriteWarning("No emulators found."); + return 0; + } + + formatter.WriteTable( + avds, + ("Name", a => a.Name), + ("Target", a => a.Target ?? "—"), + ("Device", a => a.DeviceProfile ?? "—")); + } + return 0; + } + catch (Exception ex) + { + formatter.WriteError(ex); + return 1; + } + }); + + // emulator create + var nameArg = new Argument("name") { Description = "AVD name (prompted interactively if omitted)", Arity = ArgumentArity.ZeroOrOne, DefaultValueFactory = _ => string.Empty }; + var createCommand = new Command("create", "Create a new emulator") + { + nameArg, + new Option("--package") { Description = "System image package (auto-detects most recent if not specified)" }, + new Option("--device") { Description = "Device definition" }, + new Option("--force") { Description = "Overwrite existing emulator with the same name" } + }; + createCommand.SetAction(async (ParseResult parseResult, CancellationToken cancellationToken) => + { + var androidProvider = Program.AndroidProvider; + + var useJson = parseResult.GetValue(GlobalOptions.JsonOption); + var dryRun = parseResult.GetValue(GlobalOptions.DryRunOption); + var name = parseResult.GetValue(nameArg); + var package = parseResult.GetOption("package"); + var device = parseResult.GetOption("device"); + var force = parseResult.GetOption("force"); + var formatter = Program.GetFormatter(parseResult); + + try + { + var isCi = Program.IsCiMode(parseResult); + + // --- Step 1: System image selection --- + if (string.IsNullOrEmpty(package) && !useJson && !isCi && formatter is SpectreOutputFormatter spectre) + { + var sysImages = await spectre.StatusAsync("Finding installed system images...", async () => + { + var installed = await androidProvider.GetInstalledPackagesAsync(cancellationToken); + return installed.Where(p => p.Path.StartsWith("system-images;", StringComparison.Ordinal)).ToList(); + }); + + if (sysImages.Count == 0) + { + throw new InvalidOperationException( + "No system images installed. Install one first with: maui android install"); + } + + var selected = spectre.Prompt( + new SelectionPrompt() + .Title("[bold]Select a system image[/]") + .PageSize(12) + .HighlightStyle(new Style(Color.DodgerBlue1)) + .UseConverter(p => + { + var parts = p.Path.Split(';'); + var api = parts.Length > 1 ? parts[1] : ""; + var variant = parts.Length > 2 ? parts[2] : ""; + var arch = parts.Length > 3 ? parts[3] : ""; + return $"[bold]{Markup.Escape(api)}[/] {Markup.Escape(variant)} [dim]{Markup.Escape(arch)}[/]"; + }) + .AddChoices(sysImages.OrderByDescending(p => p.Path))); + package = selected.Path; + } + else if (string.IsNullOrEmpty(package)) + { + package = await androidProvider.GetMostRecentSystemImageAsync(cancellationToken); + if (string.IsNullOrEmpty(package)) + { + throw new InvalidOperationException( + "No system images installed. Install one first with: maui android install"); + } + if (!useJson) + formatter.WriteInfo($"Auto-detected system image: {package}"); + } + + // --- Step 2: Device profile selection --- + if (string.IsNullOrEmpty(device) && !useJson && !isCi && formatter is SpectreOutputFormatter spectre2) + { + var deviceProfiles = new List<(string Id, string Name)> + { + ("pixel_6", "Pixel 6"), + ("pixel_8", "Pixel 8"), + ("pixel_9", "Pixel 9"), + ("pixel_fold", "Pixel Fold"), + ("pixel_tablet", "Pixel Tablet"), + ("medium_phone", "Medium Phone"), + ("small_phone", "Small Phone"), + }; + + var selectedDevice = spectre2.Prompt( + new SelectionPrompt<(string Id, string Name)>() + .Title("[bold]Select a device profile[/]") + .HighlightStyle(new Style(Color.DodgerBlue1)) + .UseConverter(d => $"[bold]{Markup.Escape(d.Name)}[/] [dim]{Markup.Escape(d.Id)}[/]") + .AddChoices(deviceProfiles)); + device = selectedDevice.Id; + } + + device ??= "pixel_6"; + + // --- Step 3: Name prompt (after selections so user sees what they picked) --- + if (string.IsNullOrEmpty(name) && !useJson && !isCi && formatter is SpectreOutputFormatter spectre3) + { + // Build a sensible default name from the selected image + var apiPart = package?.Split(';').ElementAtOrDefault(1) ?? "android"; + var defaultName = $"MAUI_{apiPart.Replace("-", "_", StringComparison.Ordinal)}"; + + name = spectre3.Prompt( + new TextPrompt("[bold]Enter a name for the emulator[/]") + .DefaultValue(defaultName) + .Validate(n => + { + if (string.IsNullOrWhiteSpace(n)) + return ValidationResult.Error("[red]Name cannot be empty[/]"); + if (n.Contains(' ', StringComparison.Ordinal)) + return ValidationResult.Error("[red]Name cannot contain spaces[/]"); + return ValidationResult.Success(); + })); + } + + if (string.IsNullOrEmpty(name)) + { + throw new InvalidOperationException( + "AVD name is required. Provide it as an argument or run interactively."); + } + + if (dryRun) + { + formatter.WriteInfo($"[dry-run] Would create AVD: {name}"); + formatter.WriteProgress($"Package: {package}"); + formatter.WriteProgress($"Device: {device}"); + return 0; + } + + if (string.IsNullOrEmpty(package)) + throw new InvalidOperationException("No system image package specified. Provide one via --package or run interactively."); + + await androidProvider.CreateAvdAsync(name, device, package, force, cancellationToken); + + if (useJson) + { + formatter.Write(new { success = true, name = name, package = package, device = device }); + } + else + { + formatter.WriteSuccess($"Created AVD: {name} (device: {device})"); + } + return 0; + } + catch (Exception ex) + { + formatter.WriteError(ex); + return 1; + } + }); + + // emulator start + var startNameArg = new Argument("name") { Description = "AVD name to start (prompted interactively if omitted)", Arity = ArgumentArity.ZeroOrOne, DefaultValueFactory = _ => string.Empty }; + var startCommand = new Command("start", "Start an emulator") + { + startNameArg, + new Option("--cold-boot") { Description = "Perform a cold boot" }, + new Option("--wait") { Description = "Wait for boot completion" } + }; + startCommand.SetAction(async (ParseResult parseResult, CancellationToken cancellationToken) => + { + var androidProvider = Program.AndroidProvider; + + var useJson = parseResult.GetValue(GlobalOptions.JsonOption); + var dryRun = parseResult.GetValue(GlobalOptions.DryRunOption); + var name = parseResult.GetValue(startNameArg); + var coldBoot = parseResult.GetOption("cold-boot"); + var wait = parseResult.GetOption("wait"); + var formatter = Program.GetFormatter(parseResult); + + try + { + var isCi = Program.IsCiMode(parseResult); + + // Interactive AVD selection if name not provided + if (string.IsNullOrEmpty(name) && !useJson && !isCi && formatter is SpectreOutputFormatter spectre0) + { + var avds = await spectre0.StatusAsync("Finding emulators...", async () => + await androidProvider.GetAvdsAsync(cancellationToken)); + + if (!avds.Any()) + { + throw new InvalidOperationException( + "No emulators found. Create one first with: maui android emulator create"); + } + + var selectedAvd = spectre0.Prompt( + new SelectionPrompt() + .Title("[bold]Select an emulator to start[/]") + .PageSize(10) + .HighlightStyle(new Style(Color.DodgerBlue1)) + .UseConverter(a => + { + var target = a.Target ?? a.SystemImage ?? "unknown"; + return $"[bold]{Markup.Escape(a.Name)}[/] [dim]{Markup.Escape(target)}[/]"; + }) + .AddChoices(avds)); + name = selectedAvd.Name; + } + + if (string.IsNullOrEmpty(name)) + { + throw new InvalidOperationException( + "AVD name is required. Provide it as an argument or run interactively."); + } + + if (dryRun) + { + formatter.WriteInfo($"[dry-run] Would start AVD: {name}"); + return 0; + } + + if (!useJson && formatter is SpectreOutputFormatter spectre) + { + await spectre.StatusAsync($"Starting {name}...", async () => + { + await androidProvider.StartAvdAsync(name, coldBoot, wait, cancellationToken); + }); + } + else + { + formatter.WriteProgress($"Starting {name}..."); + await androidProvider.StartAvdAsync(name, coldBoot, wait, cancellationToken); + } + + if (useJson) + { + formatter.Write(new { success = true, name = name, status = "started" }); + } + else + { + formatter.WriteSuccess($"Started AVD: {name}"); + } + return 0; + } + catch (Exception ex) + { + formatter.WriteError(ex); + return 1; + } + }); + + command.Add(listCommand); + command.Add(createCommand); + command.Add(startCommand); + + // emulator stop + var stopNameArg = new Argument("name") { Description = "Emulator name (prompted interactively if omitted)", Arity = ArgumentArity.ZeroOrOne, DefaultValueFactory = _ => string.Empty }; + var stopCommand = new Command("stop", "Stop a running emulator") + { + stopNameArg + }; + stopCommand.SetAction(async (ParseResult parseResult, CancellationToken cancellationToken) => + { + var androidProvider = Program.AndroidProvider; + + var useJson = parseResult.GetValue(GlobalOptions.JsonOption); + var dryRun = parseResult.GetValue(GlobalOptions.DryRunOption); + var name = parseResult.GetValue(stopNameArg); + var formatter = Program.GetFormatter(parseResult); + + try + { + var isCi = Program.IsCiMode(parseResult); + + // Find running emulators + var devices = await androidProvider.GetDevicesAsync(cancellationToken); + var runningEmulators = devices.Where(d => d.IsEmulator).ToList(); + + // Interactive selection if name not provided + if (string.IsNullOrEmpty(name) && !useJson && !isCi && formatter is SpectreOutputFormatter spectreStop) + { + if (!runningEmulators.Any()) + { + throw new Errors.MauiToolException( + Errors.ErrorCodes.AndroidEmulatorNotFound, + "No running emulators found."); + } + + var selectedDevice = spectreStop.Prompt( + new SelectionPrompt() + .Title("[bold]Select a running emulator to stop[/]") + .PageSize(10) + .HighlightStyle(new Style(Color.DodgerBlue1)) + .UseConverter(d => + { + var avdName = d.Details?.TryGetValue("avd", out var avd) == true ? avd?.ToString() : null; + var label = avdName ?? d.Name ?? d.Id; + return $"[bold]{Markup.Escape(label)}[/] [dim]{Markup.Escape(d.Id)}[/]"; + }) + .AddChoices(runningEmulators)); + name = selectedDevice.Details?.TryGetValue("avd", out var avdVal) == true + ? avdVal?.ToString() ?? selectedDevice.Id + : selectedDevice.Id; + } + + if (string.IsNullOrEmpty(name)) + { + throw new InvalidOperationException( + "Emulator name is required. Provide it as an argument or run interactively."); + } + + var emulator = runningEmulators.FirstOrDefault(d => + d.Details != null && + d.Details.TryGetValue("avd", out var avd) && + string.Equals(avd?.ToString(), name, StringComparison.OrdinalIgnoreCase)); + + if (emulator == null) + { + // Fall back to matching by serial/id directly + emulator = runningEmulators.FirstOrDefault(d => + string.Equals(d.Id, name, StringComparison.OrdinalIgnoreCase)); + } + + if (emulator == null) + { + throw new Errors.MauiToolException( + Errors.ErrorCodes.AndroidEmulatorNotFound, + $"No running emulator found with name '{name}'"); + } + + var serial = emulator.Id; + + if (dryRun) + { + formatter.WriteInfo($"[dry-run] Would stop emulator: {name} ({serial})"); + return 0; + } + + if (!useJson && formatter is SpectreOutputFormatter spectreStop2) + { + await spectreStop2.StatusAsync($"Stopping {name}...", async () => + { + await androidProvider.StopEmulatorAsync(serial, cancellationToken); + }); + } + else + { + await androidProvider.StopEmulatorAsync(serial, cancellationToken); + } + + if (useJson) + { + formatter.Write(new { success = true, name = name, serial = serial, status = "stopped" }); + } + else + { + formatter.WriteSuccess($"Stopped emulator: {name} ({serial})"); + } + return 0; + } + catch (Exception ex) + { + formatter.WriteError(ex); + return 1; + } + }); + command.Add(stopCommand); + + // emulator delete + var deleteNameArg = new Argument("name") { Description = "AVD name to delete (prompted interactively if omitted)", Arity = ArgumentArity.ZeroOrOne, DefaultValueFactory = _ => string.Empty }; + var deleteCommand = new Command("delete", "Delete an emulator") + { + deleteNameArg + }; + deleteCommand.SetAction(async (ParseResult parseResult, CancellationToken cancellationToken) => + { + var androidProvider = Program.AndroidProvider; + + var useJson = parseResult.GetValue(GlobalOptions.JsonOption); + var dryRun = parseResult.GetValue(GlobalOptions.DryRunOption); + var name = parseResult.GetValue(deleteNameArg); + var formatter = Program.GetFormatter(parseResult); + + try + { + var isCi = Program.IsCiMode(parseResult); + + // Interactive selection if name not provided + if (string.IsNullOrEmpty(name) && !useJson && !isCi && formatter is SpectreOutputFormatter spectreDel) + { + var avds = await spectreDel.StatusAsync("Finding emulators...", async () => + await androidProvider.GetAvdsAsync(cancellationToken)); + + if (!avds.Any()) + { + throw new InvalidOperationException( + "No emulators found. Nothing to delete."); + } + + var selectedAvd = spectreDel.Prompt( + new SelectionPrompt() + .Title("[bold red]Select an emulator to delete[/]") + .PageSize(10) + .HighlightStyle(new Style(Color.Red1)) + .UseConverter(a => + { + var target = a.Target ?? a.SystemImage ?? "unknown"; + return $"[bold]{Markup.Escape(a.Name)}[/] [dim]{Markup.Escape(target)}[/]"; + }) + .AddChoices(avds)); + name = selectedAvd.Name; + } + + if (string.IsNullOrEmpty(name)) + { + throw new InvalidOperationException( + "AVD name is required. Provide it as an argument or run interactively."); + } + + if (dryRun) + { + formatter.WriteInfo($"[dry-run] Would delete AVD: {name}"); + return 0; + } + + await androidProvider.DeleteAvdAsync(name, cancellationToken); + + if (useJson) + { + formatter.Write(new { success = true, name = name, status = "deleted" }); + } + else + { + formatter.WriteSuccess($"Deleted AVD: {name}"); + } + return 0; + } + catch (Exception ex) + { + formatter.WriteError(ex); + return 1; + } + }); + command.Add(deleteCommand); + + return command; + } +} diff --git a/src/DevFlow/Microsoft.Maui.Client/Commands/AndroidCommands.Install.cs b/src/DevFlow/Microsoft.Maui.Client/Commands/AndroidCommands.Install.cs new file mode 100644 index 00000000..1d4dd0ce --- /dev/null +++ b/src/DevFlow/Microsoft.Maui.Client/Commands/AndroidCommands.Install.cs @@ -0,0 +1,189 @@ +// 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.Client.Models; +using Microsoft.Maui.Client.Output; +using Microsoft.Maui.Client.Providers.Android; +using Microsoft.Maui.Client.Utils; +using Spectre.Console; + +namespace Microsoft.Maui.Client.Commands; + +public static partial class AndroidCommands +{ + static Command CreateInstallCommand() + { + var packagesOption = new Option("--packages") + { + Description = "SDK packages to install (replaces defaults; comma-separated or multiple --packages flags)", + AllowMultipleArgumentsPerToken = true + }; + + var command = new Command("install", "Set up Android development environment") + { + new Option("--sdk-path") { Description = "Custom SDK installation path" }, + new Option("--jdk-path") { Description = "Custom JDK installation path" }, + new Option("--jdk-version") { Description = "JDK version to install (17 or 21)", DefaultValueFactory = _ => 17 }, + new Option("--accept-licenses") { Description = "Non-interactively accept all SDK licenses" }, + packagesOption + }; + + command.SetAction(async (ParseResult parseResult, CancellationToken cancellationToken) => + { + var androidProvider = Program.AndroidProvider; + + var useJson = parseResult.GetValue(GlobalOptions.JsonOption); + var dryRun = parseResult.GetValue(GlobalOptions.DryRunOption); + var sdkPath = parseResult.GetOption("sdk-path"); + var jdkPath = parseResult.GetOption("jdk-path"); + var jdkVersion = parseResult.GetOption("jdk-version"); + var rawPackages = parseResult.GetOption("packages"); + var acceptLicenses = parseResult.GetOption("accept-licenses"); + + // Support comma-separated packages: "pkg1,pkg2,pkg3" becomes ["pkg1", "pkg2", "pkg3"] + var packages = rawPackages? + .SelectMany(p => p.Split(',', StringSplitOptions.RemoveEmptyEntries | StringSplitOptions.TrimEntries)) + .ToArray(); + var formatter = Program.GetFormatter(parseResult); + + try + { + if (dryRun) + { + formatter.WriteInfo("[dry-run] Would install Android environment:"); + formatter.WriteProgress($"JDK version: {jdkVersion}"); + formatter.WriteProgress($"JDK path: {jdkPath ?? "(default)"}"); + formatter.WriteProgress($"SDK path: {sdkPath ?? "(default)"}"); + formatter.WriteProgress($"Accept licenses: {acceptLicenses}"); + if (packages?.Any() == true) + formatter.WriteProgress($"Extra packages: {string.Join(", ", packages)}"); + return 0; + } + + if (!useJson && formatter is SpectreOutputFormatter spectre) + { + // Proactively detect if SDK is in a protected location and request elevation + if (TryRequestElevation(androidProvider, formatter, useJson)) + { + formatter.WriteSuccess("Android environment installed successfully (elevated)"); + return 0; + } + + // Resolve package list: explicit --packages or interactive selection + var isCi = Program.IsCiMode(parseResult); + var pkgList = await ResolveInstallPackagesAsync(packages, spectre, androidProvider, isCi, cancellationToken); + + await spectre.LiveProgressAsync(async (ctx) => + { + // Step 1: JDK + var jdkTask = ctx.AddTask("Installing JDK"); + if (!androidProvider.IsJdkInstalled) + { + var jdkManager = Program.JdkManager; + await jdkManager.InstallAsync(jdkVersion, jdkPath, + onProgress: (pct, msg) => jdkTask.Update(pct, $"JDK: {msg}"), + cancellationToken); + jdkTask.Complete($"OpenJDK {jdkVersion} installed"); + } + else + { + jdkTask.Complete("JDK already installed"); + } + + // Step 2: SDK command-line tools + var sdkTask = ctx.AddTask("Installing SDK Tools"); + if (!androidProvider.IsSdkInstalled) + { + var targetSdkPath = sdkPath ?? PlatformDetector.Paths.DefaultAndroidSdkPath; + await androidProvider.InstallSdkToolsAsync(targetSdkPath, + onProgress: (phase, pct, msg) => + { + var label = phase switch + { + "ReadingManifest" => "Reading manifest...", + "Downloading" => $"Downloading: {msg}", + "Verifying" => "Verifying checksum...", + "Extracting" => "Extracting...", + "Complete" => "SDK Tools installed", + _ => msg + }; + sdkTask.Update(Math.Max(0, pct), label); + }, + cancellationToken); + sdkTask.Complete("SDK Tools installed"); + } + else + { + sdkTask.Complete("SDK Tools already installed"); + } + + // Step 3: Accept licenses (only if --accept-licenses) + if (acceptLicenses) + { + var licenseTask = ctx.AddTask("Accepting licenses"); + licenseTask.SetIndeterminate("Checking licenses..."); + await androidProvider.AcceptLicensesAsync( + onProgress: msg => licenseTask.SetIndeterminate(msg), + cancellationToken); + licenseTask.Complete("Licenses accepted"); + } + + // Step 4: Install packages + var pkgTask = ctx.AddTask($"Installing packages (0/{pkgList.Count})"); + pkgTask.Update(0, $"Installing packages (0/{pkgList.Count})..."); + await androidProvider.InstallPackagesAsync(pkgList, true, + onProgress: (pkg, idx, total) => + { + var pct = (double)idx / total * 100; + pkgTask.Update(pct, $"Installing {pkg} ({idx + 1}/{total})"); + }, + cancellationToken); + pkgTask.Complete($"{pkgList.Count} packages installed"); + }); + } + else + { + var progress = new Progress(message => + { + formatter.WriteProgress(message); + }); + + await androidProvider.InstallAsync( + sdkPath: sdkPath, + jdkPath: jdkPath, + jdkVersion: jdkVersion, + additionalPackages: packages is { Length: > 0 } ? packages : null, + acceptLicenses: acceptLicenses, + progress: progress, + cancellationToken: cancellationToken); + } + + formatter.WriteSuccess("Android environment installed successfully"); + return 0; + } + catch (UnauthorizedAccessException uaEx) when (PlatformDetector.IsWindows) + { + if (!useJson) + formatter.WriteWarning("Administrator access required. Requesting elevation..."); + + if (ProcessRunner.RelaunchElevated()) + { + formatter.WriteSuccess("Android environment installed successfully (elevated)"); + return 0; + } + + formatter.WriteError(uaEx); + return 1; + } + catch (Exception ex) + { + formatter.WriteError(ex); + return 1; + } + }); + + return command; + } +} diff --git a/src/DevFlow/Microsoft.Maui.Client/Commands/AndroidCommands.Jdk.cs b/src/DevFlow/Microsoft.Maui.Client/Commands/AndroidCommands.Jdk.cs new file mode 100644 index 00000000..8e08ca6c --- /dev/null +++ b/src/DevFlow/Microsoft.Maui.Client/Commands/AndroidCommands.Jdk.cs @@ -0,0 +1,120 @@ +// 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.Client.Models; +using Microsoft.Maui.Client.Output; +using Microsoft.Maui.Client.Providers.Android; +using Microsoft.Maui.Client.Utils; +using Spectre.Console; + +namespace Microsoft.Maui.Client.Commands; + +public static partial class AndroidCommands +{ + static Command CreateJdkCommand() + { + var command = new Command("jdk", "Manage JDK installation"); + + // jdk check + var checkCommand = new Command("check", "Check JDK installation status"); + checkCommand.SetAction(async (ParseResult parseResult, CancellationToken cancellationToken) => + { + var jdkManager = Program.JdkManager; + + var useJson = parseResult.GetValue(GlobalOptions.JsonOption); + var formatter = Program.GetFormatter(parseResult); + + var healthCheck = await jdkManager.CheckHealthAsync(cancellationToken); + + if (useJson) + { + formatter.Write(healthCheck); + } + else + { + var statusIcon = healthCheck.Status == Models.CheckStatus.Ok ? "✓" : + healthCheck.Status == Models.CheckStatus.Warning ? "⚠" : "✗"; + formatter.WriteInfo($"{statusIcon} {healthCheck.Message}"); + + if (healthCheck.Details?.TryGetValue("path", out var path) == true) + formatter.WriteProgress($"Path: {path}"); + } + }); + + // jdk install + var installCommand = new Command("install", "Install OpenJDK") + { + new Option("--version") { Description = "JDK version (17 or 21)", DefaultValueFactory = _ => 17 }, + new Option("--path") { Description = "Installation path" } + }; + installCommand.SetAction(async (ParseResult parseResult, CancellationToken cancellationToken) => + { + var jdkManager = Program.JdkManager; + + var useJson = parseResult.GetValue(GlobalOptions.JsonOption); + var dryRun = parseResult.GetValue(GlobalOptions.DryRunOption); + var version = parseResult.GetOption("version"); + var path = parseResult.GetOption("path"); + var formatter = Program.GetFormatter(parseResult); + + try + { + if (dryRun) + { + formatter.WriteInfo($"[dry-run] Would install OpenJDK {version}"); + return 0; + } + + formatter.WriteProgress($"Installing OpenJDK {version}..."); + await jdkManager.InstallAsync(version, path, cancellationToken); + + if (useJson) + { + formatter.Write(new { success = true, version = version, path = jdkManager.DetectedJdkPath }); + } + else + { + formatter.WriteSuccess($"OpenJDK {version} installed to {jdkManager.DetectedJdkPath}"); + } + return 0; + } + catch (Exception ex) + { + formatter.WriteError(ex); + return 1; + } + }); + + // jdk list + var listCommand = new Command("list", "List available JDK versions"); + listCommand.SetAction((ParseResult parseResult) => + { + var jdkManager = Program.JdkManager; + var useJson = parseResult.GetValue(GlobalOptions.JsonOption); + + var versions = jdkManager.GetAvailableVersions().ToList(); + + if (useJson) + { + var formatter = Program.GetFormatter(parseResult); + formatter.Write(new { versions = versions }); + } + else + { + var formatter = Program.GetFormatter(parseResult); + formatter.WriteTable( + versions, + ("Version", v => v.ToString()), + ("Status", v => jdkManager.DetectedJdkVersion == v ? "✓ current" : "")); + } + }); + + command.Add(checkCommand); + command.Add(installCommand); + command.Add(listCommand); + + return command; + } +} diff --git a/src/DevFlow/Microsoft.Maui.Client/Commands/AndroidCommands.Sdk.cs b/src/DevFlow/Microsoft.Maui.Client/Commands/AndroidCommands.Sdk.cs new file mode 100644 index 00000000..e0f0656a --- /dev/null +++ b/src/DevFlow/Microsoft.Maui.Client/Commands/AndroidCommands.Sdk.cs @@ -0,0 +1,538 @@ +// 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.Client.Models; +using Microsoft.Maui.Client.Output; +using Microsoft.Maui.Client.Providers.Android; +using Microsoft.Maui.Client.Utils; +using Spectre.Console; + +namespace Microsoft.Maui.Client.Commands; + +public static partial class AndroidCommands +{ + static Command CreateSdkCommand() + { + var command = new Command("sdk", "Manage Android SDK"); + + // sdk check + var checkCommand = new Command("check", "Check Android SDK installation status"); + checkCommand.SetAction(async (ParseResult parseResult, CancellationToken cancellationToken) => + { + var androidProvider = Program.AndroidProvider; + + var useJson = parseResult.GetValue(GlobalOptions.JsonOption); + var formatter = Program.GetFormatter(parseResult); + + var checks = await androidProvider.CheckHealthAsync(cancellationToken); + var sdkCheck = checks.FirstOrDefault(c => c.Name == "Android SDK"); + + if (sdkCheck != null) + { + if (useJson) + { + formatter.Write(sdkCheck); + } + else + { + var statusIcon = sdkCheck.Status == Models.CheckStatus.Ok ? "✓" : + sdkCheck.Status == Models.CheckStatus.Warning ? "⚠" : "✗"; + formatter.WriteInfo($"{statusIcon} {sdkCheck.Message ?? "Android SDK"}"); + + if (sdkCheck.Details?.TryGetValue("path", out var path) == true) + formatter.WriteProgress($"Path: {path}"); + } + } + }); + + // sdk install + var sdkInstallPkgsArg = new Argument("packages") + { + Description = "SDK packages to install (prompted interactively if omitted)", + Arity = ArgumentArity.ZeroOrMore, + DefaultValueFactory = _ => Array.Empty() + }; + var installCommand = new Command("install", "Install SDK packages") + { + sdkInstallPkgsArg + }; + installCommand.SetAction(async (ParseResult parseResult, CancellationToken cancellationToken) => + { + var androidProvider = Program.AndroidProvider; + + var useJson = parseResult.GetValue(GlobalOptions.JsonOption); + var dryRun = parseResult.GetValue(GlobalOptions.DryRunOption); + var packages = parseResult.GetValue(sdkInstallPkgsArg); + var formatter = Program.GetFormatter(parseResult); + + try + { + var isCi = Program.IsCiMode(parseResult); + + // Interactive selection if no packages specified + if ((packages == null || packages.Length == 0) && !useJson && !isCi && formatter is SpectreOutputFormatter spectre) + { + var (installed, available) = await spectre.StatusAsync("Fetching available packages...", async () => + { + var inst = await androidProvider.GetInstalledPackagesAsync(cancellationToken); + var avail = await androidProvider.GetAvailablePackagesAsync(cancellationToken); + return (inst, avail); + }); + + var installedPaths = new HashSet(installed.Select(p => p.Path), StringComparer.OrdinalIgnoreCase); + + // Merge and dedup + var allPackages = installed.Concat(available) + .GroupBy(p => p.Path) + .Select(g => g.FirstOrDefault(p => p.IsInstalled) ?? g.First()) + .ToList(); + + // Step 1: Pick API level + var platforms = allPackages + .Where(p => p.Path.StartsWith("platforms;android-", StringComparison.Ordinal)) + .OrderByDescending(p => + { + var apiStr = p.Path.Substring("platforms;android-".Length); + if (Version.TryParse(apiStr, out var v)) + return v; + if (int.TryParse(apiStr, out var n)) + return new Version(n, 0); + return new Version(0, 0); + }) + .ToList(); + + if (platforms.Count == 0) + { + throw new InvalidOperationException("No Android platforms found. Run 'maui android install' for full setup."); + } + + var selectedPlatform = spectre.Prompt( + new SelectionPrompt() + .Title("[bold]Select an Android API level[/]") + .PageSize(15) + .HighlightStyle(new Style(Color.DodgerBlue1)) + .UseConverter(p => + { + var apiStr = p.Path.Substring("platforms;android-".Length); + var status = installedPaths.Contains(p.Path) ? "[green]✓ Installed[/]" : "[dim]Not installed[/]"; + return $"[bold]Android {Markup.Escape(apiStr)}[/] {Markup.Escape(p.Description ?? "")} {status}"; + }) + .AddChoices(platforms)); + + var apiLevel = selectedPlatform.Path.Substring("platforms;android-".Length); + + // Step 2: Pick components for that API level + var componentChoices = new List<(string Label, string Path, bool Installed)> + { + ($"Platform (platforms;android-{apiLevel})", $"platforms;android-{apiLevel}", installedPaths.Contains($"platforms;android-{apiLevel}")), + ($"Platform Tools", "platform-tools", installedPaths.Contains("platform-tools")), + }; + + // Find matching build-tools + var matchingBuildTools = allPackages + .Where(p => p.Path.StartsWith($"build-tools;{apiLevel}.", StringComparison.Ordinal) + || p.Path == $"build-tools;{apiLevel}.0.0") + .OrderByDescending(p => p.Version) + .FirstOrDefault(); + var buildToolsPath = matchingBuildTools?.Path ?? $"build-tools;{apiLevel}.0.0"; + componentChoices.Add(($"Build Tools ({buildToolsPath})", buildToolsPath, installedPaths.Contains(buildToolsPath))); + + componentChoices.Add(("Emulator", "emulator", installedPaths.Contains("emulator"))); + + // Find system images for this API level + var sysImages = allPackages + .Where(p => p.Path.StartsWith($"system-images;android-{apiLevel};", StringComparison.Ordinal)) + .ToList(); + foreach (var img in sysImages) + { + var parts = img.Path.Split(';'); + var variant = parts.Length > 2 ? parts[2] : ""; + var arch = parts.Length > 3 ? parts[3] : ""; + componentChoices.Add(($"System Image ({variant}/{arch})", img.Path, installedPaths.Contains(img.Path))); + } + + // If no system images found in available list, add a default + if (!sysImages.Any()) + { + var defaultArch = PlatformDetector.IsArm64 ? "arm64-v8a" : "x86_64"; + var defaultImg = $"system-images;android-{apiLevel};google_apis;{defaultArch}"; + componentChoices.Add(($"System Image (google_apis/{defaultArch})", defaultImg, false)); + } + + var selectedComponents = spectre.Prompt( + new MultiSelectionPrompt<(string Label, string Path, bool Installed)>() + .Title("[bold]Select components to install[/]") + .PageSize(15) + .HighlightStyle(new Style(Color.DodgerBlue1)) + .InstructionsText("[grey](Press [blue][/] to toggle, [green][/] to accept)[/]") + .UseConverter(c => + { + var status = c.Installed ? " [green]✓[/]" : ""; + return $"{Markup.Escape(c.Label)}{status}"; + }) + .AddChoices(componentChoices)); + + packages = selectedComponents.Select(c => c.Path).ToArray(); + + if (packages.Length == 0) + { + spectre.WriteWarning("No components selected. Nothing to install."); + return 0; + } + + spectre.WriteInfo($"Will install: {string.Join(", ", packages)}"); + } + + if (packages == null || packages.Length == 0) + { + throw new InvalidOperationException( + "No packages specified. Provide package names or run interactively."); + } + + // Proactively detect if SDK is in a protected location and request elevation + if (TryRequestElevation(androidProvider, formatter, useJson)) + { + if (useJson) + formatter.Write(new { success = true, installed = packages, elevated = true }); + else + formatter.WriteSuccess("Packages installed successfully (elevated)"); + return 0; + } + + if (dryRun) + { + formatter.WriteInfo($"[dry-run] Would install: {string.Join(", ", packages)}"); + return 0; + } + + if (!useJson && formatter is SpectreOutputFormatter spectreInstall) + { + var total = packages.Length; + for (var i = 0; i < total; i++) + { + var pkg = packages[i]; + var counter = $"[dim]({i + 1}/{total})[/]"; + await spectreInstall.Console.Status() + .AutoRefresh(true) + .Spinner(Spinner.Known.Dots) + .SpinnerStyle(new Style(Color.Blue)) + .StartAsync($"[blue]Installing:[/] {Markup.Escape(pkg)} {counter}", async _ => + { + await androidProvider.InstallPackagesAsync(new[] { pkg }, true, cancellationToken); + }); + spectreInstall.Console.MarkupLine($" [green]✓[/] {Markup.Escape(pkg)}"); + } + } + else + { + await androidProvider.InstallPackagesAsync(packages, true, cancellationToken); + } + + if (useJson) + { + formatter.Write(new { success = true, installed = packages }); + } + else + { + formatter.WriteSuccess($"Installed: {string.Join(", ", packages)}"); + } + return 0; + } + catch (Exception ex) + { + formatter.WriteError(ex); + return 1; + } + }); + + // sdk list + var listCommand = new Command("list", "List SDK packages") + { + new Option("--available") { Description = "List packages available for installation" }, + new Option("--all") { Description = "List both installed and available packages" } + }; + listCommand.SetAction(async (ParseResult parseResult, CancellationToken cancellationToken) => + { + var androidProvider = Program.AndroidProvider; + + var useJson = parseResult.GetValue(GlobalOptions.JsonOption); + var showAvailable = parseResult.GetOption("available"); + var showAll = parseResult.GetOption("all"); + var formatter = Program.GetFormatter(parseResult); + + try + { + var packages = new List(); + + if (showAll) + { + // Get both installed and available + var installed = await androidProvider.GetInstalledPackagesAsync(cancellationToken); + var available = await androidProvider.GetAvailablePackagesAsync(cancellationToken); + packages.AddRange(installed); + packages.AddRange(available); + } + else if (showAvailable) + { + var available = await androidProvider.GetAvailablePackagesAsync(cancellationToken); + var installed = await androidProvider.GetInstalledPackagesAsync(cancellationToken); + var installedPaths = new HashSet(installed.Select(p => p.Path), StringComparer.OrdinalIgnoreCase); + + // Mark available packages that are already installed + packages = available.Select(p => installedPaths.Contains(p.Path) ? p with { IsInstalled = true } : p).ToList(); + } + else + { + packages = await androidProvider.GetInstalledPackagesAsync(cancellationToken); + } + + if (useJson) + { + formatter.Write(packages); + } + else + { + if (!packages.Any()) + { + formatter.WriteWarning(showAvailable ? "No packages available." : "No packages installed."); + return 0; + } + + var spectre = formatter as SpectreOutputFormatter; + + void WriteGroupedPackages(IEnumerable pkgs, string title) + { + if (!pkgs.Any()) + { + formatter.WriteWarning($"No {title.ToLowerInvariant()}."); + return; + } + + formatter.WriteInfo($"{title}:"); + + // Group by category (part before first ';') + var groups = pkgs + .GroupBy(p => p.Path.Contains(';', StringComparison.Ordinal) ? p.Path.Substring(0, p.Path.IndexOf(';', StringComparison.Ordinal)) : p.Path) + .OrderBy(g => g.Key); + + foreach (var group in groups) + { + var items = group.OrderByDescending(p => p.Version).ToList(); + if (items.Count == 1) + { + var p = items[0]; + spectre?.WriteMarkupLine($" {Markup.Escape(p.Path)} [dim]{Markup.Escape(p.Version ?? "")}[/] [dim italic]{Markup.Escape(p.Description ?? "")}[/]"); + } + else + { + // Sub-group by major version and show only latest per major + var byMajor = items + .GroupBy(p => + { + var ver = p.Path.Contains(';', StringComparison.Ordinal) ? p.Path.Substring(p.Path.LastIndexOf(';') + 1) : p.Version ?? ""; + var dot = ver.IndexOf('.', StringComparison.Ordinal); + return dot > 0 ? ver.Substring(0, dot) : ver; + }) + .OrderByDescending(g => int.TryParse(g.Key, out var n) ? n : 0) + .ToList(); + + spectre?.WriteMarkupLine($" [bold]{Markup.Escape(group.Key)}[/] [dim]({byMajor.Count} major, {items.Count} total)[/]"); + foreach (var major in byMajor) + { + var latest = major.First(); + var latestVer = latest.Path.Contains(';', StringComparison.Ordinal) ? latest.Path.Substring(latest.Path.LastIndexOf(';') + 1) : latest.Version ?? ""; + var others = major.Count() - 1; + var suffix = others > 0 ? $" [dim](+{others} older)[/]" : ""; + spectre?.WriteMarkupLine($" {Markup.Escape(latestVer),-20} [dim]{Markup.Escape(latest.Description ?? "")}[/]{suffix}"); + } + } + } + } + + if (showAll) + { + WriteGroupedPackages(packages.Where(p => p.IsInstalled), "Installed packages"); + WriteGroupedPackages(packages.Where(p => !p.IsInstalled), "Available packages"); + } + else + { + WriteGroupedPackages(packages, showAvailable ? "Available packages" : "Installed packages"); + } + } + return 0; + } + catch (Exception ex) + { + formatter.WriteError(ex); + return 1; + } + }); + + // sdk accept-licenses + var acceptLicensesCommand = new Command("accept-licenses", "Accept Android SDK licenses interactively"); + acceptLicensesCommand.SetAction(async (ParseResult parseResult, CancellationToken cancellationToken) => + { + var androidProvider = Program.AndroidProvider; + + var useJson = parseResult.GetValue(GlobalOptions.JsonOption); + var formatter = Program.GetFormatter(parseResult); + + try + { + // Check if licenses already accepted + var accepted = await androidProvider.AreLicensesAcceptedAsync(cancellationToken); + if (accepted) + { + if (useJson) + { + formatter.Write(new + { + success = true, + status = "already_accepted", + message = "SDK licenses are already accepted" + }); + } + else + { + formatter.WriteSuccess("SDK licenses are already accepted"); + } + return 0; + } + + // Get the command for interactive license acceptance + var licenseCommand = androidProvider.GetLicenseAcceptanceCommand(); + if (licenseCommand == null) + { + if (useJson) + { + formatter.Write(new + { + success = false, + status = "sdk_not_found", + message = "Android SDK not found. Run 'maui android install' first." + }); + } + else + { + formatter.WriteError(new Exception("Android SDK not found. Run 'maui android install' first.")); + } + return 1; + } + + if (useJson) + { + // For IDE integration: return the command to run in a terminal + formatter.Write(new + { + success = true, + status = "requires_interaction", + message = "Run the following command in a terminal to accept licenses interactively", + command = licenseCommand.Value.Command, + arguments = licenseCommand.Value.Arguments, + full_command = $"{licenseCommand.Value.Command} {licenseCommand.Value.Arguments}" + }); + } + else + { + // For CLI: run interactively + formatter.WriteInfo("Starting interactive license acceptance..."); + formatter.WriteInfo("Review each license and type 'y' to accept.\n"); + + // Run sdkmanager --licenses interactively (inherits stdin/stdout) + var processInfo = new System.Diagnostics.ProcessStartInfo + { + FileName = licenseCommand.Value.Command, + Arguments = licenseCommand.Value.Arguments, + UseShellExecute = false, + RedirectStandardInput = false, + RedirectStandardOutput = false, + RedirectStandardError = false + }; + + // Set environment variables for JDK/SDK + foreach (var kvp in AndroidEnvironment.BuildEnvironmentVariables(androidProvider.SdkPath, androidProvider.JdkPath)) + processInfo.Environment[kvp.Key] = kvp.Value; + + using var process = System.Diagnostics.Process.Start(processInfo); + if (process != null) + { + await process.WaitForExitAsync(cancellationToken); + + if (process.ExitCode == 0) + { + formatter.WriteSuccess("License acceptance completed"); + } + else + { + formatter.WriteWarning($"License acceptance exited with code {process.ExitCode}"); + } + } + } + return 0; + } + catch (Exception ex) + { + formatter.WriteError(ex); + return 1; + } + }); + + command.Add(checkCommand); + command.Add(installCommand); + command.Add(listCommand); + command.Add(acceptLicensesCommand); + command.Add(CreateSdkUninstallCommand()); + + return command; + } + + static Command CreateSdkUninstallCommand() + { + var command = new Command("uninstall", "Uninstall SDK packages") + { + new Argument("packages") { Description = "Package names to uninstall", Arity = ArgumentArity.OneOrMore } + }; + + command.SetAction(async (ParseResult parseResult, CancellationToken cancellationToken) => + { + var androidProvider = Program.AndroidProvider; + + var useJson = parseResult.GetValue(GlobalOptions.JsonOption); + var dryRun = parseResult.GetValue(GlobalOptions.DryRunOption); + var packages = parseResult.GetValue( + (Argument)parseResult.CommandResult.Command.Arguments.First())!; + var formatter = Program.GetFormatter(parseResult); + + try + { + if (dryRun) + { + formatter.WriteInfo($"[dry-run] Would uninstall: {string.Join(", ", packages)}"); + return 0; + } + + await androidProvider.UninstallPackagesAsync(packages, cancellationToken); + + if (useJson) + { + formatter.Write(new { success = true, uninstalled = packages }); + } + else + { + formatter.WriteSuccess($"Uninstalled: {string.Join(", ", packages)}"); + } + return 0; + } + catch (Exception ex) + { + formatter.WriteError(ex); + return 1; + } + }); + + return command; + } + +} diff --git a/src/DevFlow/Microsoft.Maui.Client/Commands/AndroidCommands.cs b/src/DevFlow/Microsoft.Maui.Client/Commands/AndroidCommands.cs new file mode 100644 index 00000000..fd73a422 --- /dev/null +++ b/src/DevFlow/Microsoft.Maui.Client/Commands/AndroidCommands.cs @@ -0,0 +1,193 @@ +// 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 Microsoft.Maui.Client.Output; +using Microsoft.Maui.Client.Providers.Android; +using Microsoft.Maui.Client.Utils; +using Spectre.Console; + +namespace Microsoft.Maui.Client.Commands; + +/// +/// Implementation of 'maui android' command group. +/// Sub-commands are in partial class files: Install, Jdk, Sdk, Emulator. +/// +public static partial class AndroidCommands +{ + public static Command Create() + { + var command = new Command("android", "Android SDK and device management"); + + command.Add(CreateInstallCommand()); + command.Add(CreateJdkCommand()); + command.Add(CreateSdkCommand()); + command.Add(CreateEmulatorCommand()); + + return command; + } + + + /// + /// Resolves packages to install: uses explicit --packages if provided, otherwise shows interactive prompts. + /// In CI mode, returns defaults without interactive prompts. + /// + static async Task> ResolveInstallPackagesAsync( + string[]? explicitPackages, + SpectreOutputFormatter spectre, + IAndroidProvider androidProvider, + bool isCi, + CancellationToken cancellationToken) + { + // If user specified packages explicitly, use those + if (explicitPackages is { Length: > 0 }) + return explicitPackages.ToList(); + + // In CI mode, use defaults without interactive prompts + if (isCi) + return GetDefaultPackages(); + + // Try to fetch available packages for interactive selection + List installed; + List available; + try + { + (installed, available) = await spectre.StatusAsync("Fetching available packages...", async () => + { + var inst = await androidProvider.GetInstalledPackagesAsync(cancellationToken); + var avail = await androidProvider.GetAvailablePackagesAsync(cancellationToken); + return (inst, avail); + }); + } + catch (Exception ex) + { + System.Diagnostics.Trace.WriteLine($"Package list fetch failed, using defaults: {ex.Message}"); + return GetDefaultPackages(); + } + + // Merge: all known platforms (installed + available), dedup by path + var allPackages = installed.Concat(available) + .GroupBy(p => p.Path) + .Select(g => g.FirstOrDefault(p => p.IsInstalled) ?? g.First()) // prefer installed entry + .ToList(); + + // Build platform choices: platforms;android-XX + var platforms = allPackages + .Where(p => p.Path.StartsWith("platforms;android-", StringComparison.Ordinal)) + .OrderByDescending(p => + { + var apiStr = p.Path.Substring("platforms;android-".Length); + // Handle both integer (36) and dotted (36.1) API levels + if (Version.TryParse(apiStr, out var v)) + return v; + if (int.TryParse(apiStr, out var n)) + return new Version(n, 0); + return new Version(0, 0); + }) + .ToList(); + + if (platforms.Count == 0) + return GetDefaultPackages(); + + // Prompt 1: Select Android platform + var platformChoices = platforms.Select(p => + { + var apiStr = p.Path.Substring("platforms;android-".Length); + var status = p.IsInstalled ? "[green]Installed[/]" : "[dim]Not installed[/]"; + // Display: "Android 35 Android SDK Platform 35 Installed" + return new PlatformChoice( + p.Path, + $"Android {apiStr}", + p.Description ?? $"Android SDK Platform {apiStr}", + p.IsInstalled); + }).ToList(); + + var selectedPlatform = spectre.Prompt( + new SelectionPrompt() + .Title("[bold]Select an Android platform to install[/]") + .PageSize(15) + .HighlightStyle(new Style(Color.DodgerBlue1)) + .UseConverter(c => + { + var status = c.IsInstalled ? "[green]Installed[/]" : "[dim]Not installed[/]"; + return $"[bold]{Markup.Escape(c.DisplayName)}[/] {Markup.Escape(c.Description)} {status}"; + }) + .AddChoices(platformChoices)); + + var apiLevel = selectedPlatform.PackagePath.Substring("platforms;android-".Length); + + // Find matching build-tools for this API level + var matchingBuildTools = allPackages + .Where(p => p.Path.StartsWith($"build-tools;{apiLevel}.", StringComparison.Ordinal) + || p.Path == $"build-tools;{apiLevel}.0.0") + .OrderByDescending(p => p.Version) + .FirstOrDefault(); + + var buildToolsPath = matchingBuildTools?.Path ?? $"build-tools;{apiLevel}.0.0"; + var arch = PlatformDetector.IsArm64 ? "arm64-v8a" : "x86_64"; + var sysImagePath = $"system-images;android-{apiLevel};google_apis;{arch}"; + + // Prompt 2: Select install scope + var scopeChoices = new List + { + new("Platform only", + $"Install {selectedPlatform.PackagePath}", + new List { selectedPlatform.PackagePath }), + new("Platform + Build Tools", + $"Install platform and {buildToolsPath}", + new List { selectedPlatform.PackagePath, buildToolsPath, "platform-tools" }), + new("Full Development Setup", + "Platform, Build Tools, System Image, and Emulator", + new List + { + selectedPlatform.PackagePath, + buildToolsPath, + "platform-tools", + "emulator", + sysImagePath + }) + }; + + var selectedScope = spectre.Prompt( + new SelectionPrompt() + .Title("[bold]Select what to install[/]") + .HighlightStyle(new Style(Color.DodgerBlue1)) + .UseConverter(s => $"[bold]{Markup.Escape(s.Name)}[/] [dim]{Markup.Escape(s.Description)}[/]") + .AddChoices(scopeChoices)); + + spectre.WriteInfo($"Will install: {string.Join(", ", selectedScope.Packages)}"); + return selectedScope.Packages; + } + + static List GetDefaultPackages() => new() + { + "platform-tools", + "emulator", + "platforms;android-35", + "build-tools;35.0.0", + $"system-images;android-35;google_apis;{(PlatformDetector.IsArm64 ? "arm64-v8a" : "x86_64")}" + }; + + record PlatformChoice(string PackagePath, string DisplayName, string Description, bool IsInstalled); + record InstallScope(string Name, string Description, List Packages); + + /// + /// Checks if the SDK is in a protected location and attempts elevation if needed. + /// Returns true if elevation was launched (caller should return), false if no elevation needed. + /// Throws UnauthorizedAccessException if elevation was cancelled. + /// + static bool TryRequestElevation(IAndroidProvider androidProvider, IOutputFormatter formatter, bool useJson) + { + if (!PlatformDetector.IsWindows || !androidProvider.SdkPathRequiresElevation || ProcessRunner.IsRunningElevated()) + return false; + + if (!useJson) + formatter.WriteWarning($"Android SDK is in a protected location ({androidProvider.SdkPath}). Administrator access required."); + + if (ProcessRunner.RelaunchElevated()) + return true; + + throw new UnauthorizedAccessException( + $"Administrator access is required for {androidProvider.SdkPath}. Run this command from an administrator terminal, or set ANDROID_HOME to a user-writable location."); + } +} diff --git a/src/DevFlow/Microsoft.Maui.Client/Commands/CommandExtensions.cs b/src/DevFlow/Microsoft.Maui.Client/Commands/CommandExtensions.cs new file mode 100644 index 00000000..bbb938f6 --- /dev/null +++ b/src/DevFlow/Microsoft.Maui.Client/Commands/CommandExtensions.cs @@ -0,0 +1,27 @@ +// 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; + +namespace Microsoft.Maui.Client.Commands; + +/// +/// Extension methods for System.CommandLine option parsing. +/// Eliminates the repetitive cast-and-LINQ pattern for looking up command-local options by name. +/// +internal static class CommandExtensions +{ + /// + /// Gets the value for a command-local option by name, avoiding the verbose + /// (Option<T>)parseResult.CommandResult.Command.Options.First(o => o.Name == "...") pattern. + /// + public static T? GetOption(this ParseResult parseResult, string name) + { + var option = parseResult.CommandResult.Command.Options.FirstOrDefault(o => + o.Name == name || o.Name == $"--{name}"); + if (option is Option typed) + return parseResult.GetValue(typed); + return default; + } +} diff --git a/src/DevFlow/Microsoft.Maui.Client/Commands/DeviceCommand.cs b/src/DevFlow/Microsoft.Maui.Client/Commands/DeviceCommand.cs new file mode 100644 index 00000000..13da3b9b --- /dev/null +++ b/src/DevFlow/Microsoft.Maui.Client/Commands/DeviceCommand.cs @@ -0,0 +1,72 @@ +// 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.Client.Models; +using Microsoft.Maui.Client.Output; +using Microsoft.Maui.Client.Services; + +namespace Microsoft.Maui.Client.Commands; + +/// +/// Implementation of 'maui device' commands. +/// +public static class DeviceCommand +{ + public static Command Create() + { + var command = new Command("device", "Manage devices and emulators"); + + command.Add(CreateListCommand()); + + return command; + } + + static Command CreateListCommand() + { + var platformOption = new Option("--platform") { Description = "Filter by platform (android, apple, windows, all)", DefaultValueFactory = _ => "all" }; + var command = new Command("list", "List available devices") + { + platformOption + }; + + command.SetAction(async (ParseResult parseResult, CancellationToken cancellationToken) => + { + var deviceManager = Program.DeviceManager; + var formatter = Program.GetFormatter(parseResult); + var useJson = parseResult.GetValue(GlobalOptions.JsonOption); + var platform = parseResult.GetValue(platformOption); + + try + { + var devices = platform == "all" + ? await deviceManager.GetAllDevicesAsync(cancellationToken) + : await deviceManager.GetDevicesByPlatformAsync(platform!, cancellationToken); + + if (useJson) + { + formatter.Write(devices); + } + else + { + if (!devices.Any()) + { + formatter.WriteWarning("No devices found."); + return 0; + } + + formatter.WriteResult(new DeviceListResult { Devices = devices.ToList() }); + } + return 0; + } + catch (Exception ex) + { + formatter.WriteError(ex); + return 1; + } + }); + + return command; + } +} diff --git a/src/DevFlow/Microsoft.Maui.Client/Commands/DoctorCommand.cs b/src/DevFlow/Microsoft.Maui.Client/Commands/DoctorCommand.cs new file mode 100644 index 00000000..25a44f34 --- /dev/null +++ b/src/DevFlow/Microsoft.Maui.Client/Commands/DoctorCommand.cs @@ -0,0 +1,108 @@ +// 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.Client.Models; +using Microsoft.Maui.Client.Output; +using Microsoft.Maui.Client.Services; + +namespace Microsoft.Maui.Client.Commands; + +/// +/// Implementation of 'maui doctor' command. +/// +public static class DoctorCommand +{ + public static Command Create() + { + var command = new Command("doctor", "Check system for MAUI development readiness") + { + // Options + new Option("--fix") { Description = "Attempt to automatically fix issues" }, + new Option("--platform") { Description = "Check only specific platform (dotnet, android, windows)" } + }; + + command.SetAction(async (ParseResult parseResult, CancellationToken cancellationToken) => + { + var doctorService = Program.DoctorService; + + var useJson = parseResult.GetValue(GlobalOptions.JsonOption); + var fix = parseResult.GetOption("fix"); + var platform = parseResult.GetOption("platform"); + var formatter = Program.GetFormatter(parseResult); + + try + { + DoctorReport report; + + if (!useJson && formatter is SpectreOutputFormatter spectre) + { + report = await spectre.StatusAsync("Running health checks...", async () => + string.IsNullOrEmpty(platform) + ? await doctorService.RunAllChecksAsync(cancellationToken) + : await doctorService.RunCategoryChecksAsync(platform, cancellationToken)); + } + else + { + report = string.IsNullOrEmpty(platform) + ? await doctorService.RunAllChecksAsync(cancellationToken) + : await doctorService.RunCategoryChecksAsync(platform, cancellationToken); + } + + // Output the report + formatter.Write(report); + + // Attempt fixes if requested + if (fix) + { + var fixableIssues = report.Checks + .Where(c => c.Fix?.AutoFixable == true) + .ToList(); + + if (fixableIssues.Any()) + { + if (!useJson && formatter is SpectreOutputFormatter spectreFix) + { + await spectreFix.LiveProgressAsync(async (ctx) => + { + for (int i = 0; i < fixableIssues.Count; i++) + { + var issue = fixableIssues[i]; + var task = ctx.AddTask($"Fixing: {issue.Name}"); + var success = await doctorService.TryFixAsync(issue.Fix!, cancellationToken); + task.Complete(success ? $"Fixed: {issue.Name}" : $"Failed: {issue.Name}"); + } + }); + } + else + { + formatter.WriteInfo("Attempting automatic fixes..."); + foreach (var issue in fixableIssues) + { + formatter.WriteProgress($"Fixing: {issue.Name}"); + await doctorService.TryFixAsync(issue.Fix!, cancellationToken); + } + } + + // Re-run checks after fixes to get accurate exit code + report = string.IsNullOrEmpty(platform) + ? await doctorService.RunAllChecksAsync(cancellationToken) + : await doctorService.RunCategoryChecksAsync(platform, cancellationToken); + } + } + + // Set exit code based on (post-fix) results + var hasErrors = report.Checks.Any(c => c.Status == Models.CheckStatus.Error); + return hasErrors ? 1 : 0; + } + catch (Exception ex) + { + formatter.WriteError(ex); + return 1; + } + }); + + return command; + } +} diff --git a/src/DevFlow/Microsoft.Maui.Client/Commands/VersionCommand.cs b/src/DevFlow/Microsoft.Maui.Client/Commands/VersionCommand.cs new file mode 100644 index 00000000..70207daf --- /dev/null +++ b/src/DevFlow/Microsoft.Maui.Client/Commands/VersionCommand.cs @@ -0,0 +1,48 @@ +// 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 System.Reflection; +using Microsoft.Maui.Client.Output; + +namespace Microsoft.Maui.Client.Commands; + +/// +/// Implementation of 'maui version' command. +/// +public static class VersionCommand +{ + public static Command Create() + { + var command = new Command("version", "Display version information"); + + command.SetAction((ParseResult parseResult) => + { + var useJson = parseResult.GetValue(GlobalOptions.JsonOption); + + var assembly = Assembly.GetExecutingAssembly(); + var version = assembly.GetCustomAttribute()?.InformationalVersion + ?? assembly.GetName().Version?.ToString() + ?? "0.0.0"; + + if (useJson) + { + var formatter = Program.GetFormatter(parseResult); + formatter.Write(new + { + version = version, + runtime = Environment.Version.ToString(), + os = Environment.OSVersion.ToString() + }); + } + else + { + var formatter = Program.GetFormatter(parseResult); + formatter.WriteVersion(version, $".NET {Environment.Version}", Environment.OSVersion.ToString()); + } + }); + + return command; + } +} diff --git a/src/DevFlow/Microsoft.Maui.Client/Errors/ErrorCodes.cs b/src/DevFlow/Microsoft.Maui.Client/Errors/ErrorCodes.cs new file mode 100644 index 00000000..4bb82d42 --- /dev/null +++ b/src/DevFlow/Microsoft.Maui.Client/Errors/ErrorCodes.cs @@ -0,0 +1,46 @@ +// Licensed to the .NET Foundation under one or more agreements. +// The .NET Foundation licenses this file to you under the MIT license. + +namespace Microsoft.Maui.Client.Errors; + +/// +/// Error code taxonomy for MAUI Dev Tools. +/// Format: E{category}{number} +/// Categories: +/// 1xxx - Tool errors (internal bugs) +/// 2xxx - Platform/SDK errors +/// 3xxx - User action required +/// 4xxx - Network errors +/// 5xxx - Permission errors +/// +public static class ErrorCodes +{ + // Tool errors (E1xxx) + public const string InternalError = "E1001"; + public const string InvalidArgument = "E1004"; + public const string DeviceNotFound = "E1006"; + public const string PlatformNotSupported = "E1007"; + + // Platform/SDK errors - JDK (E20xx) + public const string JdkNotFound = "E2001"; + public const string JdkVersionUnsupported = "E2002"; + public const string JdkInstallFailed = "E2003"; + + // Platform/SDK errors - Android SDK (E21xx) + public const string AndroidSdkNotFound = "E2101"; + public const string AndroidSdkManagerNotFound = "E2102"; + public const string AndroidLicensesNotAccepted = "E2103"; + public const string AndroidPackageInstallFailed = "E2105"; + public const string AndroidEmulatorNotFound = "E2106"; + public const string AndroidAvdCreateFailed = "E2108"; + public const string AndroidAdbNotFound = "E2110"; + public const string AndroidDeviceNotFound = "E2111"; + public const string AndroidAvdDeleteFailed = "E2112"; + + // Platform/SDK errors - Windows (E23xx) + public const string WindowsSdkNotFound = "E2301"; + + // Platform/SDK errors - .NET (E24xx) + public const string DotNetNotFound = "E2401"; + public const string MauiWorkloadMissing = "E2402"; +} diff --git a/src/DevFlow/Microsoft.Maui.Client/Errors/MauiToolException.cs b/src/DevFlow/Microsoft.Maui.Client/Errors/MauiToolException.cs new file mode 100644 index 00000000..c03e0366 --- /dev/null +++ b/src/DevFlow/Microsoft.Maui.Client/Errors/MauiToolException.cs @@ -0,0 +1,95 @@ +// Licensed to the .NET Foundation under one or more agreements. +// The .NET Foundation licenses this file to you under the MIT license. + +namespace Microsoft.Maui.Client.Errors; + +/// +/// Exception thrown by MAUI Dev Tools with structured error information. +/// +public class MauiToolException : Exception +{ + public string Code { get; } + public RemediationInfo? Remediation { get; } + public Dictionary? Context { get; } + public string? NativeError { get; } + + public MauiToolException(string code, string message) + : base(message) + { + Code = code; + } + + public MauiToolException(string code, string message, Exception innerException) + : base(message, innerException) + { + Code = code; + } + + public MauiToolException(string code, string message, RemediationInfo? remediation = null, + Dictionary? context = null, string? nativeError = null) + : base(message) + { + Code = code; + Remediation = remediation; + Context = context; + NativeError = nativeError; + } + + /// + /// Creates an auto-fixable exception with a remediation command. + /// + public static MauiToolException AutoFixable(string code, string message, string fixCommand, + Dictionary? context = null, string? nativeError = null) + { + return new MauiToolException(code, message, + new RemediationInfo(RemediationType.AutoFixable, fixCommand), + context, nativeError); + } + + /// + /// Creates an exception requiring manual user action. + /// + public static MauiToolException UserActionRequired(string code, string message, string[] manualSteps, + Dictionary? context = null, string? nativeError = null) + { + return new MauiToolException(code, message, + new RemediationInfo(RemediationType.UserAction, null, manualSteps), + context, nativeError); + } + + /// + /// Creates a terminal exception that cannot be fixed. + /// + public static MauiToolException Terminal(string code, string message, + Dictionary? context = null, string? nativeError = null) + { + return new MauiToolException(code, message, + new RemediationInfo(RemediationType.Terminal), + context, nativeError); + } +} + +/// +/// Remediation information for an error. +/// +public record RemediationInfo( + RemediationType Type, + string? Command = null, + string[]? ManualSteps = null, + string? DocsUrl = null +); + +/// +/// Type of remediation available for an error. +/// +public enum RemediationType +{ + /// The tool can fix this automatically. + AutoFixable, + /// User must take manual steps. + UserAction, + /// Cannot be fixed (e.g., unsupported OS). + Terminal, + /// Tool doesn't recognize this error. + Unknown +} diff --git a/src/DevFlow/Microsoft.Maui.Client/Microsoft.Maui.Client.csproj b/src/DevFlow/Microsoft.Maui.Client/Microsoft.Maui.Client.csproj new file mode 100644 index 00000000..f4a15b8f --- /dev/null +++ b/src/DevFlow/Microsoft.Maui.Client/Microsoft.Maui.Client.csproj @@ -0,0 +1,28 @@ + + + + Exe + net10.0 + Microsoft.Maui.Client + maui + true + maui + Microsoft.Maui.Client + MAUI Dev Tools Client - CLI for .NET MAUI development environment setup and device management + true + true + true + + + + + + + + + + + + + + diff --git a/src/DevFlow/Microsoft.Maui.Client/Models/Device.cs b/src/DevFlow/Microsoft.Maui.Client/Models/Device.cs new file mode 100644 index 00000000..70dbd0da --- /dev/null +++ b/src/DevFlow/Microsoft.Maui.Client/Models/Device.cs @@ -0,0 +1,211 @@ +// Licensed to the .NET Foundation under one or more agreements. +// The .NET Foundation licenses this file to you under the MIT license. + +using System.Text.Json.Serialization; + +namespace Microsoft.Maui.Client.Models; + +/// +/// Represents a device (physical device, emulator, or simulator). +/// Follows the MauiDevice schema for cross-platform device representation. +/// +public record Device +{ + /// + /// Display name of the device (e.g., "iPhone 15 Pro", "Pixel 6"). + /// + [JsonPropertyName("name")] + public required string Name { get; init; } + + /// + /// Unique identifier for the device (e.g., UDID for iOS, serial for Android). + /// + [JsonPropertyName("identifier")] + public required string Id { get; init; } + + /// + /// Emulator/AVD identifier if applicable (e.g., AVD name for Android emulators). + /// + [JsonPropertyName("emulator_id")] + [JsonIgnore(Condition = JsonIgnoreCondition.WhenWritingNull)] + public string? EmulatorId { get; init; } + + /// + /// Supported platforms (e.g., ["android"], ["ios", "maccatalyst"]). + /// + [JsonPropertyName("platforms")] + public required string[] Platforms { get; init; } + + /// + /// OS version number (e.g., "35" for Android API 35, "18.5" for iOS 18.5). + /// + [JsonPropertyName("version")] + [JsonIgnore(Condition = JsonIgnoreCondition.WhenWritingNull)] + public string? Version { get; init; } + + /// + /// Human-readable OS version name (e.g., "Android 15", "iOS 18.5"). + /// + [JsonPropertyName("version_name")] + [JsonIgnore(Condition = JsonIgnoreCondition.WhenWritingNull)] + public string? VersionName { get; init; } + + /// + /// Device manufacturer (e.g., "Google", "Samsung", "Apple"). + /// + [JsonPropertyName("manufacturer")] + [JsonIgnore(Condition = JsonIgnoreCondition.WhenWritingNull)] + public string? Manufacturer { get; init; } + + /// + /// Device model (e.g., "Pixel 6", "iPhone 15 Pro"). + /// + [JsonPropertyName("model")] + [JsonIgnore(Condition = JsonIgnoreCondition.WhenWritingNull)] + public string? Model { get; init; } + + /// + /// Device sub-model or variant (e.g., "Pro Max", "Ultra"). + /// + [JsonPropertyName("sub_model")] + [JsonIgnore(Condition = JsonIgnoreCondition.WhenWritingNull)] + public string? SubModel { get; init; } + + /// + /// Device form factor/idiom (e.g., "phone", "tablet", "watch", "tv", "desktop"). + /// + [JsonPropertyName("idiom")] + [JsonIgnore(Condition = JsonIgnoreCondition.WhenWritingNull)] + public string? Idiom { get; init; } + + /// + /// Platform architecture (e.g., "arm64-v8a", "x86_64"). + /// + [JsonPropertyName("platform_architecture")] + [JsonIgnore(Condition = JsonIgnoreCondition.WhenWritingNull)] + public string? PlatformArchitecture { get; init; } + + /// + /// .NET runtime identifiers for this device (e.g., ["android-arm64", "android-x64"]). + /// + [JsonPropertyName("runtime_identifiers")] + [JsonIgnore(Condition = JsonIgnoreCondition.WhenWritingNull)] + public string[]? RuntimeIdentifiers { get; init; } + + /// + /// CPU architecture (e.g., "arm64", "x86_64"). + /// + [JsonPropertyName("architecture")] + [JsonIgnore(Condition = JsonIgnoreCondition.WhenWritingNull)] + public string? Architecture { get; init; } + + /// + /// Whether this is an emulator/simulator (true) or physical device (false). + /// + [JsonPropertyName("is_emulator")] + public bool IsEmulator { get; init; } + + /// + /// Whether the device is currently running/booted. + /// + [JsonPropertyName("is_running")] + public bool IsRunning { get; init; } + + /// + /// Connection type (e.g., "usb", "wifi", "local" for simulators). + /// + [JsonPropertyName("connection_type")] + [JsonIgnore(Condition = JsonIgnoreCondition.WhenWritingNull)] + public string? ConnectionType { get; init; } + + // Legacy properties for backward compatibility + + /// + /// Platform identifier (legacy, use Platforms array instead). + /// + [JsonPropertyName("platform")] + public string Platform => Platforms.FirstOrDefault() ?? "unknown"; + + /// + /// Device type enum (legacy, use IsEmulator instead). + /// + [JsonPropertyName("type")] + public DeviceType Type { get; init; } + + /// + /// Device state enum (legacy, use IsRunning instead). + /// + [JsonPropertyName("state")] + public DeviceState State { get; init; } + + /// + /// OS version (legacy alias for Version). + /// + [JsonPropertyName("os_version")] + [JsonIgnore(Condition = JsonIgnoreCondition.WhenWritingNull)] + public string? OsVersion => Version; + + /// + /// Additional device-specific details. + /// + [JsonPropertyName("details")] + [JsonIgnore(Condition = JsonIgnoreCondition.WhenWritingNull)] + public Dictionary? Details { get; init; } +} + +[JsonConverter(typeof(JsonStringEnumConverter))] +public enum DeviceType +{ + Physical, + Emulator, + Simulator +} + +[JsonConverter(typeof(JsonStringEnumConverter))] +public enum DeviceState +{ + Unknown, + Connected, + Disconnected, + Booted, + Shutdown, + Booting, + ShuttingDown, + Offline +} + +/// +/// Device idiom/form factor. +/// +public static class DeviceIdiom +{ + public const string Phone = "phone"; + public const string Tablet = "tablet"; + public const string Watch = "watch"; + public const string TV = "tv"; + public const string Desktop = "desktop"; + public const string Unknown = "unknown"; +} + +/// +/// Connection types for devices. +/// +public static class ConnectionType +{ + public const string Usb = "usb"; + public const string Wifi = "wifi"; + public const string Local = "local"; + public const string Unknown = "unknown"; +} + +/// +/// Result of device list command. +/// +public record DeviceListResult +{ + [JsonPropertyName("devices")] + public required List Devices { get; init; } + + [JsonPropertyName("count")] + public int Count => Devices.Count; +} diff --git a/src/DevFlow/Microsoft.Maui.Client/Models/DoctorReport.cs b/src/DevFlow/Microsoft.Maui.Client/Models/DoctorReport.cs new file mode 100644 index 00000000..9e657e00 --- /dev/null +++ b/src/DevFlow/Microsoft.Maui.Client/Models/DoctorReport.cs @@ -0,0 +1,116 @@ +// Licensed to the .NET Foundation under one or more agreements. +// The .NET Foundation licenses this file to you under the MIT license. + +using System.Text.Json.Serialization; + +namespace Microsoft.Maui.Client.Models; + +/// +/// Result of the doctor command. +/// +public record DoctorReport +{ + [JsonPropertyName("schema_version")] + public string SchemaVersion { get; init; } = "1.0"; + + [JsonPropertyName("correlation_id")] + public required string CorrelationId { get; init; } + + [JsonPropertyName("timestamp")] + public required DateTime Timestamp { get; init; } + + [JsonPropertyName("status")] + public required HealthStatus Status { get; init; } + + [JsonPropertyName("checks")] + public required List Checks { get; init; } + + [JsonPropertyName("summary")] + public required DoctorSummary Summary { get; init; } +} + +/// +/// Summary of doctor checks. +/// +public record DoctorSummary +{ + [JsonPropertyName("total")] + public int Total { get; init; } + + [JsonPropertyName("ok")] + public int Ok { get; init; } + + [JsonPropertyName("warning")] + public int Warning { get; init; } + + [JsonPropertyName("error")] + public int Error { get; init; } +} + +/// +/// Individual health check result. +/// +public record HealthCheck +{ + [JsonPropertyName("category")] + public required string Category { get; init; } + + [JsonPropertyName("name")] + public required string Name { get; init; } + + [JsonPropertyName("status")] + public required CheckStatus Status { get; init; } + + [JsonPropertyName("message")] + [JsonIgnore(Condition = JsonIgnoreCondition.WhenWritingNull)] + public string? Message { get; init; } + + [JsonPropertyName("details")] + [JsonIgnore(Condition = JsonIgnoreCondition.WhenWritingNull)] + public Dictionary? Details { get; init; } + + [JsonPropertyName("fix")] + [JsonIgnore(Condition = JsonIgnoreCondition.WhenWritingNull)] + public FixInfo? Fix { get; init; } +} + +/// +/// Information about how to fix an issue. +/// +public record FixInfo +{ + [JsonPropertyName("issue_id")] + public required string IssueId { get; init; } + + [JsonPropertyName("description")] + public required string Description { get; init; } + + [JsonPropertyName("auto_fixable")] + public bool AutoFixable { get; init; } + + [JsonPropertyName("command")] + [JsonIgnore(Condition = JsonIgnoreCondition.WhenWritingNull)] + public string? Command { get; init; } + + [JsonPropertyName("manual_steps")] + [JsonIgnore(Condition = JsonIgnoreCondition.WhenWritingNull)] + public string[]? ManualSteps { get; init; } +} + +[JsonConverter(typeof(JsonStringEnumConverter))] +public enum HealthStatus +{ + Healthy, + Unhealthy, + Degraded +} + +[JsonConverter(typeof(JsonStringEnumConverter))] +public enum CheckStatus +{ + Ok, + Warning, + Error, + Skipped, + NotApplicable +} diff --git a/src/DevFlow/Microsoft.Maui.Client/Models/ErrorResult.cs b/src/DevFlow/Microsoft.Maui.Client/Models/ErrorResult.cs new file mode 100644 index 00000000..1c765419 --- /dev/null +++ b/src/DevFlow/Microsoft.Maui.Client/Models/ErrorResult.cs @@ -0,0 +1,110 @@ +// Licensed to the .NET Foundation under one or more agreements. +// The .NET Foundation licenses this file to you under the MIT license. + +using System.Text.Json.Serialization; + +namespace Microsoft.Maui.Client.Models; + +/// +/// Structured error result for JSON output. +/// +public record ErrorResult +{ + [JsonPropertyName("code")] + public required string Code { get; init; } + + [JsonPropertyName("category")] + public required string Category { get; init; } + + [JsonPropertyName("severity")] + public string Severity { get; init; } = "error"; + + [JsonPropertyName("message")] + public required string Message { get; init; } + + [JsonPropertyName("native_error")] + [JsonIgnore(Condition = JsonIgnoreCondition.WhenWritingNull)] + public string? NativeError { get; init; } + + [JsonPropertyName("context")] + [JsonIgnore(Condition = JsonIgnoreCondition.WhenWritingNull)] + public Dictionary? Context { get; init; } + + [JsonPropertyName("remediation")] + [JsonIgnore(Condition = JsonIgnoreCondition.WhenWritingNull)] + public RemediationResult? Remediation { get; init; } + + [JsonPropertyName("docs_url")] + [JsonIgnore(Condition = JsonIgnoreCondition.WhenWritingNull)] + public string? DocsUrl { get; init; } + + [JsonPropertyName("correlation_id")] + [JsonIgnore(Condition = JsonIgnoreCondition.WhenWritingNull)] + public string? CorrelationId { get; init; } + + /// + /// Derives error category from error code. + /// + public static string GetCategory(string code) + { + if (code.Length < 3) + return "unknown"; + + return code[1] switch + { + '1' => "tool", + '2' => "platform", + '3' => "user", + '4' => "network", + '5' => "permission", + _ => "unknown" + }; + } + + /// + /// Converts an exception to a structured ErrorResult. + /// + public static ErrorResult FromException(Exception exception) + { + if (exception is Errors.MauiToolException mex) + { + return new ErrorResult + { + Code = mex.Code, + Category = GetCategory(mex.Code), + Message = mex.Message, + NativeError = mex.NativeError, + Remediation = mex.Remediation != null ? new RemediationResult + { + Type = mex.Remediation.Type.ToString().ToLowerInvariant(), + Command = mex.Remediation.Command, + ManualSteps = mex.Remediation.ManualSteps + } : null + }; + } + + return new ErrorResult + { + Code = Errors.ErrorCodes.InternalError, + Category = "tool", + Message = exception.Message + }; + } +} + +/// +/// Remediation information in JSON output. +/// +public record RemediationResult +{ + [JsonPropertyName("type")] + public required string Type { get; init; } + + [JsonPropertyName("command")] + [JsonIgnore(Condition = JsonIgnoreCondition.WhenWritingNull)] + public string? Command { get; init; } + + [JsonPropertyName("manual_steps")] + [JsonIgnore(Condition = JsonIgnoreCondition.WhenWritingNull)] + public string[]? ManualSteps { get; init; } +} diff --git a/src/DevFlow/Microsoft.Maui.Client/Models/Platforms.cs b/src/DevFlow/Microsoft.Maui.Client/Models/Platforms.cs new file mode 100644 index 00000000..f188a4f2 --- /dev/null +++ b/src/DevFlow/Microsoft.Maui.Client/Models/Platforms.cs @@ -0,0 +1,30 @@ +// Licensed to the .NET Foundation under one or more agreements. +// The .NET Foundation licenses this file to you under the MIT license. + +namespace Microsoft.Maui.Client.Models; + +/// +/// Platform constants to avoid magic strings. +/// +public static class Platforms +{ + public const string Android = "android"; + public const string iOS = "ios"; + public const string MacCatalyst = "maccatalyst"; + public const string Windows = "windows"; + public const string All = "all"; + + public static bool IsValid(string? platform) => platform?.ToLowerInvariant() switch + { + Android or iOS or MacCatalyst or Windows or All => true, + _ => false + }; + + public static string Normalize(string? platform) => platform?.ToLowerInvariant() switch + { + "apple" or "iphone" or "ipad" => iOS, + "mac" or "macos" or "catalyst" => MacCatalyst, + "win" or "win32" or "win64" => Windows, + _ => platform?.ToLowerInvariant() ?? All + }; +} diff --git a/src/DevFlow/Microsoft.Maui.Client/Output/IOutputFormatter.cs b/src/DevFlow/Microsoft.Maui.Client/Output/IOutputFormatter.cs new file mode 100644 index 00000000..ea8bf60d --- /dev/null +++ b/src/DevFlow/Microsoft.Maui.Client/Output/IOutputFormatter.cs @@ -0,0 +1,62 @@ +// 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.Client.Models; + +namespace Microsoft.Maui.Client.Output; + +/// +/// Interface for output formatting. +/// +public interface IOutputFormatter +{ + /// + /// Writes a generic object to output. + /// + void Write(T result); + + /// + /// Writes the result to output. + /// + void WriteResult(T result); + + /// + /// Writes an error from an exception. + /// + void WriteError(Exception exception); + + /// + /// Writes an error to output. + /// + void WriteError(ErrorResult error); + + /// + /// Writes a success message. + /// + void WriteSuccess(string message); + + /// + /// Writes a warning message. + /// + void WriteWarning(string message); + + /// + /// Writes an info message. + /// + void WriteInfo(string message); + + /// + /// Writes a progress update. + /// + void WriteProgress(string message, int? percentage = null); + + /// + /// Writes a table of data. + /// + void WriteTable(IEnumerable items, params (string Header, Func Selector)[] columns); + + /// + /// Writes version information. + /// + void WriteVersion(string version, string runtime, string os); +} diff --git a/src/DevFlow/Microsoft.Maui.Client/Output/JsonOutputFormatter.cs b/src/DevFlow/Microsoft.Maui.Client/Output/JsonOutputFormatter.cs new file mode 100644 index 00000000..8ab215a9 --- /dev/null +++ b/src/DevFlow/Microsoft.Maui.Client/Output/JsonOutputFormatter.cs @@ -0,0 +1,92 @@ +// Licensed to the .NET Foundation under one or more agreements. +// The .NET Foundation licenses this file to you under the MIT license. + +using System.Text.Json; +using System.Text.Json.Serialization; +using Microsoft.Maui.Client.Models; + +namespace Microsoft.Maui.Client.Output; + +/// +/// JSON output formatter for machine-readable output. +/// +public class JsonOutputFormatter : IOutputFormatter +{ + static readonly JsonSerializerOptions s_options = new() + { + WriteIndented = true, + PropertyNamingPolicy = JsonNamingPolicy.SnakeCaseLower, + DefaultIgnoreCondition = JsonIgnoreCondition.WhenWritingNull, + Converters = { new JsonStringEnumConverter(JsonNamingPolicy.SnakeCaseLower) } + }; + + readonly TextWriter _output; + + public JsonOutputFormatter(TextWriter? output = null) + { + _output = output ?? Console.Out; + } + + public void Write(T result) + { + WriteResult(result); + } + + public void WriteResult(T result) + { + var json = JsonSerializer.Serialize(result, s_options); + _output.WriteLine(json); + } + + public void WriteError(Exception exception) + { + WriteError(ErrorResult.FromException(exception)); + } + + public void WriteError(ErrorResult error) + { + WriteResult(error); + } + + public void WriteSuccess(string message) + { + WriteResult(new { status = "success", message }); + } + + public void WriteWarning(string message) + { + WriteResult(new { status = "warning", message }); + } + + public void WriteInfo(string message) + { + WriteResult(new { status = "info", message }); + } + + public void WriteProgress(string message, int? percentage = null) + { + WriteResult(new { status = "progress", message, percentage }); + } + + public void WriteTable(IEnumerable items, params (string Header, Func Selector)[] columns) + { + var rows = items.Select(item => + columns.ToDictionary(c => c.Header.ToLowerInvariant(), c => c.Selector(item))); + WriteResult(rows.ToList()); + } + + public void WriteVersion(string version, string runtime, string os) + { + WriteResult(new { version, runtime, os }); + } + + /// + /// Serializes an object to JSON string. + /// + public static string Serialize(T obj) => JsonSerializer.Serialize(obj, s_options); + + /// + /// Deserializes JSON string to object. + /// + public static T? Deserialize(string json) => JsonSerializer.Deserialize(json, s_options); +} diff --git a/src/DevFlow/Microsoft.Maui.Client/Output/SpectreHelpBuilder.cs b/src/DevFlow/Microsoft.Maui.Client/Output/SpectreHelpBuilder.cs new file mode 100644 index 00000000..681b260d --- /dev/null +++ b/src/DevFlow/Microsoft.Maui.Client/Output/SpectreHelpBuilder.cs @@ -0,0 +1,156 @@ +// 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 Spectre.Console; + +namespace Microsoft.Maui.Client.Output; + +/// +/// Custom help rendering using Spectre.Console for colorized output. +/// Produces styled help matching the tool's visual identity. +/// +static class SpectreHelpBuilder +{ + /// + /// Writes colorized help for a command to the console. + /// + internal static void WriteHelp(Command command) + { + var console = AnsiConsole.Console; + + // Description + if (!string.IsNullOrEmpty(command.Description)) + { + console.MarkupLine("[yellow]Description:[/]"); + console.MarkupLine($" {Markup.Escape(command.Description)}"); + console.WriteLine(); + } + + // Usage + console.MarkupLine("[yellow]Usage:[/]"); + console.MarkupLine($" {Markup.Escape(BuildUsageLine(command))}"); + console.WriteLine(); + + // Options + var options = GetVisibleOptions(command).ToList(); + if (options.Count > 0) + { + console.MarkupLine("[yellow]Options:[/]"); + + // Add standard options that are injected by the parser framework + var helpEntry = ("-?, -h, --help", "Show help and usage information"); + var versionEntry = ("--version", "Show version information"); + var allAliasLengths = options.Select(o => FormatAliases(o).Length) + .Append(helpEntry.Item1.Length) + .Append(versionEntry.Item1.Length); + var maxAliasLen = allAliasLengths.Max(); + + foreach (var option in options) + { + var aliases = FormatAliases(option); + var desc = option.Description ?? string.Empty; + var padding = new string(' ', Math.Max(1, maxAliasLen - aliases.Length + 2)); + console.MarkupLine($" [green]{Markup.Escape(aliases)}[/]{padding}{Markup.Escape(desc)}"); + } + + // Help option + var helpPad = new string(' ', Math.Max(1, maxAliasLen - helpEntry.Item1.Length + 2)); + console.MarkupLine($" [green]{Markup.Escape(helpEntry.Item1)}[/]{helpPad}{Markup.Escape(helpEntry.Item2)}"); + + // Version option (root command only) + if (command is RootCommand) + { + var verPad = new string(' ', Math.Max(1, maxAliasLen - versionEntry.Item1.Length + 2)); + console.MarkupLine($" [green]{Markup.Escape(versionEntry.Item1)}[/]{verPad}{Markup.Escape(versionEntry.Item2)}"); + } + + console.WriteLine(); + } + + // Subcommands + var subcommands = command.Subcommands.Where(c => !c.Hidden).OrderBy(c => c.Name).ToList(); + if (subcommands.Count > 0) + { + console.MarkupLine("[yellow]Commands:[/]"); + var maxNameLen = subcommands.Max(c => c.Name.Length); + foreach (var sub in subcommands) + { + var name = sub.Name; + var desc = sub.Description ?? string.Empty; + var padding = new string(' ', Math.Max(1, maxNameLen - name.Length + 2)); + console.MarkupLine($" [green]{Markup.Escape(name)}[/]{padding}{Markup.Escape(desc)}"); + } + console.WriteLine(); + } + + // Arguments + var arguments = command.Arguments.Where(a => !a.Hidden).ToList(); + if (arguments.Count > 0) + { + console.MarkupLine("[yellow]Arguments:[/]"); + var maxArgLen = arguments.Max(a => $"<{a.Name}>".Length); + foreach (var arg in arguments) + { + var name = $"<{arg.Name}>"; + var desc = arg.Description ?? string.Empty; + var padding = new string(' ', Math.Max(1, maxArgLen - name.Length + 2)); + console.MarkupLine($" [green]{Markup.Escape(name)}[/]{padding}{Markup.Escape(desc)}"); + } + console.WriteLine(); + } + } + + static string BuildUsageLine(Command command) + { + var parts = new List(); + + // Walk up to build the full command path + var current = command; + var path = new Stack(); + while (current != null) + { + if (!string.IsNullOrEmpty(current.Name)) + path.Push(current.Name); + current = current.Parents.OfType().FirstOrDefault(); + } + parts.Add(string.Join(" ", path)); + + if (command.Subcommands.Any(c => !c.Hidden)) + parts.Add("[command]"); + + if (GetVisibleOptions(command).Any()) + parts.Add("[options]"); + + foreach (var arg in command.Arguments.Where(a => !a.Hidden)) + parts.Add($"<{arg.Name}>"); + + return string.Join(" ", parts); + } + + static IEnumerable