diff --git a/.github/workflows/DevHome-CI.yml b/.github/workflows/DevHome-CI.yml index 0fbbb1baf5..8c6df009b4 100644 --- a/.github/workflows/DevHome-CI.yml +++ b/.github/workflows/DevHome-CI.yml @@ -64,6 +64,24 @@ jobs: - name: Build_SDK run: cmd /c "$env:VSDevCmd" "&" msbuild /p:Configuration=Release,Platform=${{ matrix.platform }} extensionsdk\\DevHomeSDK.sln + - name: Build_DevSetupAgent_x86 + if: ${{ matrix.platform != 'arm64' }} + run: cmd /c "$env:VSDevCmd" "&" msbuild /p:Configuration=${{ matrix.configuration }},Platform=x86 HyperVExtension\\DevSetupAgent.sln + + - name: Compress_DevSetupAgent_x86 + if: ${{ matrix.platform != 'arm64' }} + shell: pwsh + run: Compress-Archive -Force -Path HyperVExtension\src\DevSetupAgent\bin\x86\${{ matrix.configuration }}\net8.0-windows10.0.22000.0\win10-x86\* -DestinationPath "HyperVExtension\src\DevSetupAgent\bin\x86\${{ matrix.configuration }}\DevSetupAgent_x86.zip" + + - name: Build_DevSetupAgent_arm64 + if: ${{ matrix.platform == 'arm64' }} + run: cmd /c "$env:VSDevCmd" "&" msbuild /p:Configuration=${{ matrix.configuration }},Platform=arm64 HyperVExtension\\DevSetupAgent.sln + + - name: Compress_DevSetupAgent_arm64 + if: ${{ matrix.platform == 'arm64' }} + shell: pwsh + run: Compress-Archive -Force -Path HyperVExtension\src\DevSetupAgent\bin\arm64\${{ matrix.configuration }}\net8.0-windows10.0.22000.0\win10-arm64\* -DestinationPath "HyperVExtension\src\DevSetupAgent\bin\arm64\${{ matrix.configuration }}\DevSetupAgent_arm64.zip" + - name: Build_DevHome run: cmd /c "$env:VSDevCmd" "&" msbuild /p:Configuration=${{ matrix.configuration }},Platform=${{ matrix.platform }} DevHome.sln diff --git a/Build.ps1 b/Build.ps1 index 293d5d3b4a..1b790597ac 100644 --- a/Build.ps1 +++ b/Build.ps1 @@ -26,7 +26,7 @@ Description: Options: -Platform - Only buil the selected platform(s) + Only build the selected platform(s) Example: -Platform x64 Example: -Platform "x86,x64,arm64" @@ -61,6 +61,22 @@ if (($BuildStep -ieq "all") -Or ($BuildStep -ieq "sdk")) { } } +if (($BuildStep -ieq "all") -Or ($BuildStep -ieq "DevSetupAgent") -Or ($BuildStep -ieq "fullMsix")) { + foreach ($configuration in $env:Build_Configuration.Split(",")) { + # We use x86 DevSetupAgent for x64 and x86 Dev Home build. Only need to build it once if we are building multiple platforms. + $builtX86 = $false + foreach ($platform in $env:Build_Platform.Split(",")) { + if ($Platform -ieq "arm64") { + HyperVExtension\BuildDevSetupAgentHelper.ps1 -Platform $Platform -Configuration $configuration -VersionOfSDK $env:sdk_version -SDKNugetSource $SDKNugetSource -AzureBuildingBranch $AzureBuildingBranch -IsAzurePipelineBuild $IsAzurePipelineBuild -BypassWarning + } + elseif (-not $builtX86) { + HyperVExtension\BuildDevSetupAgentHelper.ps1 -Platform "x86" -Configuration $configuration -VersionOfSDK $env:sdk_version -SDKNugetSource $SDKNugetSource -AzureBuildingBranch $AzureBuildingBranch -IsAzurePipelineBuild $IsAzurePipelineBuild -BypassWarning + $builtX86 = $true + } + } + } +} + $msbuildPath = &"${env:ProgramFiles(x86)}\Microsoft Visual Studio\Installer\vswhere.exe" -latest -prerelease -products * -requires Microsoft.Component.MSBuild -find MSBuild\**\Bin\MSBuild.exe if ($IsAzurePipelineBuild) { $nugetPath = "nuget.exe"; @@ -79,7 +95,7 @@ if (-not([string]::IsNullOrWhiteSpace($SDKNugetSource))) { . build\Scripts\CertSignAndInstall.ps1 Try { - if (($BuildStep -ieq "all") -Or ($BuildStep -ieq "msix")) { + if (($BuildStep -ieq "all") -Or ($BuildStep -ieq "msix") -Or ($BuildStep -ieq "fullMsix")) { $buildRing = "Dev" $newPackageName = $null $newPackageDisplayName = $null @@ -140,6 +156,9 @@ Try { } $appxmanifest.Save($appxmanifestPath) + # This is needed for vcxproj + & $nugetPath restore + foreach ($platform in $env:Build_Platform.Split(",")) { foreach ($configuration in $env:Build_Configuration.Split(",")) { $appxPackageDir = (Join-Path $env:Build_RootDirectory "AppxPackages\$configuration") diff --git a/CoreWidgetProvider/Widgets/Assets/screenshots/MemoryScreenshotDark.png b/CoreWidgetProvider/Widgets/Assets/screenshots/MemoryScreenshotDark.png deleted file mode 100644 index 97d19266bd..0000000000 Binary files a/CoreWidgetProvider/Widgets/Assets/screenshots/MemoryScreenshotDark.png and /dev/null differ diff --git a/DevHome.sln b/DevHome.sln index dff8216347..068148209b 100644 --- a/DevHome.sln +++ b/DevHome.sln @@ -13,6 +13,7 @@ Project("{2150E333-8FDC-42A3-9474-1A3956D46DE8}") = "Solution Items", "Solution EndProject Project("{9A19103F-16F7-4668-BE54-9A1E7A4F7556}") = "DevHome", "src\DevHome.csproj", "{60E0FD98-5396-436D-BAB7-187A853A5DC6}" ProjectSection(ProjectDependencies) = postProject + {0689521A-1686-46DB-81E1-3B84AA0EF1AC} = {0689521A-1686-46DB-81E1-3B84AA0EF1AC} {8BE0016E-5BBD-459E-A382-B1CE56E7CA5D} = {8BE0016E-5BBD-459E-A382-B1CE56E7CA5D} {D2303635-3DD9-4DCA-A38A-F5306D0BB8FE} = {D2303635-3DD9-4DCA-A38A-F5306D0BB8FE} EndProjectSection @@ -35,8 +36,6 @@ Project("{9A19103F-16F7-4668-BE54-9A1E7A4F7556}") = "SampleTool.UnitTest", "tool EndProject Project("{2150E333-8FDC-42A3-9474-1A3956D46DE8}") = "SetupFlow", "SetupFlow", "{4179A05E-37F1-46CD-9218-0889EA2BB75B}" EndProject -Project("{2150E333-8FDC-42A3-9474-1A3956D46DE8}") = "Samples", "Samples", "{BE6229BE-72EE-4A32-BE20-F8C6FC629047}" -EndProject Project("{2150E333-8FDC-42A3-9474-1A3956D46DE8}") = "Tools", "Tools", "{A972EC5B-FC61-4964-A6FF-F9633EB75DFD}" EndProject Project("{9A19103F-16F7-4668-BE54-9A1E7A4F7556}") = "DevHome.SetupFlow.Common", "tools\SetupFlow\DevHome.SetupFlow.Common\DevHome.SetupFlow.Common.csproj", "{54082587-A435-423F-AE1B-01B906FFA7C5}" @@ -65,7 +64,7 @@ Project("{9A19103F-16F7-4668-BE54-9A1E7A4F7556}") = "DevHome.SetupFlow.ElevatedC EndProject Project("{2150E333-8FDC-42A3-9474-1A3956D46DE8}") = "CoreWidgetProvider", "CoreWidgetProvider", "{32C0052F-10AF-48C0-A7A3-B0A1793397FD}" EndProject -Project("{9A19103F-16F7-4668-BE54-9A1E7A4F7556}") = "CoreWidgetProvider", "CoreWidgetProvider\CoreWidgetProvider.csproj", "{0D879E08-99AA-4019-9D04-DEA9F7C7BFC1}" +Project("{9A19103F-16F7-4668-BE54-9A1E7A4F7556}") = "CoreWidgetProvider", "extensions\CoreWidgetProvider\CoreWidgetProvider.csproj", "{0D879E08-99AA-4019-9D04-DEA9F7C7BFC1}" EndProject Project("{9A19103F-16F7-4668-BE54-9A1E7A4F7556}") = "DevHome.ExtensionLibrary", "tools\ExtensionLibrary\DevHome.ExtensionLibrary\DevHome.ExtensionLibrary.csproj", "{69F8B7DF-F52B-4B74-9A16-AB3241BB8912}" EndProject @@ -73,6 +72,48 @@ Project("{2150E333-8FDC-42A3-9474-1A3956D46DE8}") = "ExtensionLibrary", "Extensi EndProject Project("{9A19103F-16F7-4668-BE54-9A1E7A4F7556}") = "DevHome.Experiments", "tools\Experiments\src\DevHome.Experiments.csproj", "{2F9AD5AF-EF3B-496A-8566-9E9539E3DF43}" EndProject +Project("{2150E333-8FDC-42A3-9474-1A3956D46DE8}") = "Extensions", "Extensions", "{DCAF188B-60C3-4EDB-8049-BAA927FBCD7D}" +EndProject +Project("{2150E333-8FDC-42A3-9474-1A3956D46DE8}") = "SampleTool", "SampleTool", "{E7C94F61-D6CF-464D-8D50-210488AF7A50}" +EndProject +Project("{2150E333-8FDC-42A3-9474-1A3956D46DE8}") = "Environments", "Environments", "{8FC9A04E-1FFD-42BA-B304-D1FA964D99CE}" +EndProject +Project("{9A19103F-16F7-4668-BE54-9A1E7A4F7556}") = "DevHome.Environments", "tools\Environments\DevHome.Environments\DevHome.Environments.csproj", "{CFD8A90D-8B6D-4ED6-BA35-FF894BEB46C0}" +EndProject +Project("{2150E333-8FDC-42A3-9474-1A3956D46DE8}") = "DevEnvironmentProviders", "DevEnvironmentProviders", "{8296B318-2782-4A0E-97F1-C770411C779A}" +EndProject +Project("{9A19103F-16F7-4668-BE54-9A1E7A4F7556}") = "HyperVExtensionServer", "HyperVExtension\src\HyperVExtensionServer\HyperVExtensionServer.csproj", "{0689521A-1686-46DB-81E1-3B84AA0EF1AC}" +EndProject +Project("{9A19103F-16F7-4668-BE54-9A1E7A4F7556}") = "HyperVExtension.Common", "HyperVExtension\src\HyperVExtension.Common\HyperVExtension.Common.csproj", "{A716481F-C1AF-4243-84F9-7B9399055E51}" +EndProject +Project("{2150E333-8FDC-42A3-9474-1A3956D46DE8}") = "HyperVExtension", "HyperVExtension", "{0ADDE603-FBC0-415C-A88B-8A3F5A086FB8}" +EndProject +Project("{9A19103F-16F7-4668-BE54-9A1E7A4F7556}") = "HyperVExtension", "HyperVExtension\src\HyperVExtension\HyperVExtension.csproj", "{75976510-22B7-4910-96F2-3E1519C3FF35}" +EndProject +Project("{9A19103F-16F7-4668-BE54-9A1E7A4F7556}") = "HyperVExtension.Logging", "HyperVExtension\src\Logging\HyperVExtension.Logging.csproj", "{2A96D2DD-48F8-475A-9DA2-30CB4EF71419}" +EndProject +Project("{9A19103F-16F7-4668-BE54-9A1E7A4F7556}") = "HyperVExtension.Telemetry", "HyperVExtension\src\Telemetry\HyperVExtension.Telemetry.csproj", "{D92BC45D-6D1B-4DE3-9303-4B3ED1971192}" +EndProject +Project("{2150E333-8FDC-42A3-9474-1A3956D46DE8}") = "src", "src", "{81AACED5-CFB5-47A6-AFD6-4625AADCFFA3}" +EndProject +Project("{2150E333-8FDC-42A3-9474-1A3956D46DE8}") = "test", "test", "{3E3791DF-070D-4ADE-96E8-93D6FBD53953}" +EndProject +Project("{9A19103F-16F7-4668-BE54-9A1E7A4F7556}") = "DevSetupAgent", "HyperVExtension\src\DevSetupAgent\DevSetupAgent.csproj", "{D8256951-EB23-45AA-8A0B-4573DF8E26F2}" +EndProject +Project("{9A19103F-16F7-4668-BE54-9A1E7A4F7556}") = "DevSetupEngine", "HyperVExtension\src\DevSetupEngine\DevSetupEngine.csproj", "{2C7522A3-DCE2-4ED0-889A-AD10F241EDF4}" +EndProject +Project("{8BC9CEB8-8B4A-11D0-8D11-00A0C91BC942}") = "DevSetupEngineIdl", "HyperVExtension\src\DevSetupEngineIdl\DevSetupEngineIdl.vcxproj", "{D6B0C16D-858A-4C1B-99CF-D6F4CF5BCD5F}" +EndProject +Project("{9A19103F-16F7-4668-BE54-9A1E7A4F7556}") = "DevSetupEngineProjection", "HyperVExtension\src\DevSetupEngineProjection\DevSetupEngineProjection.csproj", "{AC872D0F-2F11-48C4-949C-2464EA1AC66F}" +EndProject +Project("{9A19103F-16F7-4668-BE54-9A1E7A4F7556}") = "HyperVExtension.UnitTest", "HyperVExtension\test\HyperVExtension\HyperVExtension.UnitTest.csproj", "{F9121D0A-BB3A-4010-A982-CD8B77F47AA2}" +EndProject +Project("{9A19103F-16F7-4668-BE54-9A1E7A4F7556}") = "DevSetupEngine.Test", "HyperVExtension\test\DevSetupEngine.Test\DevSetupEngine.Test.csproj", "{F4095FD3-6A3F-490B-966D-E63059612EE6}" +EndProject +Project("{9A19103F-16F7-4668-BE54-9A1E7A4F7556}") = "DevSetupAgent.Test", "HyperVExtension\test\DevSetupAgent.Test\DevSetupAgent.Test.csproj", "{0E05A442-BDC7-43D4-A000-F8C986826716}" +EndProject +Project("{9A19103F-16F7-4668-BE54-9A1E7A4F7556}") = "HyperVExtension.HostGuestCommunication", "HyperVExtension\src\HyperVExtension.HostGuestCommunication\HyperVExtension.HostGuestCommunication.csproj", "{D759CD66-494C-4A00-8075-8B65A9891349}" +EndProject Global GlobalSection(SolutionConfigurationPlatforms) = preSolution Debug|Any CPU = Debug|Any CPU @@ -413,15 +454,239 @@ Global {2F9AD5AF-EF3B-496A-8566-9E9539E3DF43}.Release|x64.Build.0 = Release|x64 {2F9AD5AF-EF3B-496A-8566-9E9539E3DF43}.Release|x86.ActiveCfg = Release|x86 {2F9AD5AF-EF3B-496A-8566-9E9539E3DF43}.Release|x86.Build.0 = Release|x86 + {CFD8A90D-8B6D-4ED6-BA35-FF894BEB46C0}.Debug|Any CPU.ActiveCfg = Debug|x64 + {CFD8A90D-8B6D-4ED6-BA35-FF894BEB46C0}.Debug|Any CPU.Build.0 = Debug|x64 + {CFD8A90D-8B6D-4ED6-BA35-FF894BEB46C0}.Debug|arm64.ActiveCfg = Debug|ARM64 + {CFD8A90D-8B6D-4ED6-BA35-FF894BEB46C0}.Debug|arm64.Build.0 = Debug|ARM64 + {CFD8A90D-8B6D-4ED6-BA35-FF894BEB46C0}.Debug|x64.ActiveCfg = Debug|x64 + {CFD8A90D-8B6D-4ED6-BA35-FF894BEB46C0}.Debug|x64.Build.0 = Debug|x64 + {CFD8A90D-8B6D-4ED6-BA35-FF894BEB46C0}.Debug|x86.ActiveCfg = Debug|x86 + {CFD8A90D-8B6D-4ED6-BA35-FF894BEB46C0}.Debug|x86.Build.0 = Debug|x86 + {CFD8A90D-8B6D-4ED6-BA35-FF894BEB46C0}.Release|Any CPU.ActiveCfg = Release|x64 + {CFD8A90D-8B6D-4ED6-BA35-FF894BEB46C0}.Release|Any CPU.Build.0 = Release|x64 + {CFD8A90D-8B6D-4ED6-BA35-FF894BEB46C0}.Release|arm64.ActiveCfg = Release|ARM64 + {CFD8A90D-8B6D-4ED6-BA35-FF894BEB46C0}.Release|arm64.Build.0 = Release|ARM64 + {CFD8A90D-8B6D-4ED6-BA35-FF894BEB46C0}.Release|x64.ActiveCfg = Release|x64 + {CFD8A90D-8B6D-4ED6-BA35-FF894BEB46C0}.Release|x64.Build.0 = Release|x64 + {CFD8A90D-8B6D-4ED6-BA35-FF894BEB46C0}.Release|x86.ActiveCfg = Release|x86 + {CFD8A90D-8B6D-4ED6-BA35-FF894BEB46C0}.Release|x86.Build.0 = Release|x86 + {0689521A-1686-46DB-81E1-3B84AA0EF1AC}.Debug|Any CPU.ActiveCfg = Debug|x64 + {0689521A-1686-46DB-81E1-3B84AA0EF1AC}.Debug|Any CPU.Build.0 = Debug|x64 + {0689521A-1686-46DB-81E1-3B84AA0EF1AC}.Debug|arm64.ActiveCfg = Debug|ARM64 + {0689521A-1686-46DB-81E1-3B84AA0EF1AC}.Debug|arm64.Build.0 = Debug|ARM64 + {0689521A-1686-46DB-81E1-3B84AA0EF1AC}.Debug|x64.ActiveCfg = Debug|x64 + {0689521A-1686-46DB-81E1-3B84AA0EF1AC}.Debug|x64.Build.0 = Debug|x64 + {0689521A-1686-46DB-81E1-3B84AA0EF1AC}.Debug|x86.ActiveCfg = Debug|x86 + {0689521A-1686-46DB-81E1-3B84AA0EF1AC}.Debug|x86.Build.0 = Debug|x86 + {0689521A-1686-46DB-81E1-3B84AA0EF1AC}.Release|Any CPU.ActiveCfg = Release|x64 + {0689521A-1686-46DB-81E1-3B84AA0EF1AC}.Release|Any CPU.Build.0 = Release|x64 + {0689521A-1686-46DB-81E1-3B84AA0EF1AC}.Release|arm64.ActiveCfg = Release|ARM64 + {0689521A-1686-46DB-81E1-3B84AA0EF1AC}.Release|arm64.Build.0 = Release|ARM64 + {0689521A-1686-46DB-81E1-3B84AA0EF1AC}.Release|x64.ActiveCfg = Release|x64 + {0689521A-1686-46DB-81E1-3B84AA0EF1AC}.Release|x64.Build.0 = Release|x64 + {0689521A-1686-46DB-81E1-3B84AA0EF1AC}.Release|x86.ActiveCfg = Release|x86 + {0689521A-1686-46DB-81E1-3B84AA0EF1AC}.Release|x86.Build.0 = Release|x86 + {A716481F-C1AF-4243-84F9-7B9399055E51}.Debug|Any CPU.ActiveCfg = Debug|x64 + {A716481F-C1AF-4243-84F9-7B9399055E51}.Debug|Any CPU.Build.0 = Debug|x64 + {A716481F-C1AF-4243-84F9-7B9399055E51}.Debug|arm64.ActiveCfg = Debug|arm64 + {A716481F-C1AF-4243-84F9-7B9399055E51}.Debug|arm64.Build.0 = Debug|arm64 + {A716481F-C1AF-4243-84F9-7B9399055E51}.Debug|x64.ActiveCfg = Debug|x64 + {A716481F-C1AF-4243-84F9-7B9399055E51}.Debug|x64.Build.0 = Debug|x64 + {A716481F-C1AF-4243-84F9-7B9399055E51}.Debug|x86.ActiveCfg = Debug|x86 + {A716481F-C1AF-4243-84F9-7B9399055E51}.Debug|x86.Build.0 = Debug|x86 + {A716481F-C1AF-4243-84F9-7B9399055E51}.Release|Any CPU.ActiveCfg = Release|x64 + {A716481F-C1AF-4243-84F9-7B9399055E51}.Release|Any CPU.Build.0 = Release|x64 + {A716481F-C1AF-4243-84F9-7B9399055E51}.Release|arm64.ActiveCfg = Release|arm64 + {A716481F-C1AF-4243-84F9-7B9399055E51}.Release|arm64.Build.0 = Release|arm64 + {A716481F-C1AF-4243-84F9-7B9399055E51}.Release|x64.ActiveCfg = Release|x64 + {A716481F-C1AF-4243-84F9-7B9399055E51}.Release|x64.Build.0 = Release|x64 + {A716481F-C1AF-4243-84F9-7B9399055E51}.Release|x86.ActiveCfg = Release|x86 + {A716481F-C1AF-4243-84F9-7B9399055E51}.Release|x86.Build.0 = Release|x86 + {75976510-22B7-4910-96F2-3E1519C3FF35}.Debug|Any CPU.ActiveCfg = Debug|x64 + {75976510-22B7-4910-96F2-3E1519C3FF35}.Debug|Any CPU.Build.0 = Debug|x64 + {75976510-22B7-4910-96F2-3E1519C3FF35}.Debug|arm64.ActiveCfg = Debug|ARM64 + {75976510-22B7-4910-96F2-3E1519C3FF35}.Debug|arm64.Build.0 = Debug|ARM64 + {75976510-22B7-4910-96F2-3E1519C3FF35}.Debug|x64.ActiveCfg = Debug|x64 + {75976510-22B7-4910-96F2-3E1519C3FF35}.Debug|x64.Build.0 = Debug|x64 + {75976510-22B7-4910-96F2-3E1519C3FF35}.Debug|x86.ActiveCfg = Debug|x86 + {75976510-22B7-4910-96F2-3E1519C3FF35}.Debug|x86.Build.0 = Debug|x86 + {75976510-22B7-4910-96F2-3E1519C3FF35}.Release|Any CPU.ActiveCfg = Release|x64 + {75976510-22B7-4910-96F2-3E1519C3FF35}.Release|Any CPU.Build.0 = Release|x64 + {75976510-22B7-4910-96F2-3E1519C3FF35}.Release|arm64.ActiveCfg = Release|ARM64 + {75976510-22B7-4910-96F2-3E1519C3FF35}.Release|arm64.Build.0 = Release|ARM64 + {75976510-22B7-4910-96F2-3E1519C3FF35}.Release|x64.ActiveCfg = Release|x64 + {75976510-22B7-4910-96F2-3E1519C3FF35}.Release|x64.Build.0 = Release|x64 + {75976510-22B7-4910-96F2-3E1519C3FF35}.Release|x86.ActiveCfg = Release|x86 + {75976510-22B7-4910-96F2-3E1519C3FF35}.Release|x86.Build.0 = Release|x86 + {2A96D2DD-48F8-475A-9DA2-30CB4EF71419}.Debug|Any CPU.ActiveCfg = Debug|x64 + {2A96D2DD-48F8-475A-9DA2-30CB4EF71419}.Debug|Any CPU.Build.0 = Debug|x64 + {2A96D2DD-48F8-475A-9DA2-30CB4EF71419}.Debug|arm64.ActiveCfg = Debug|arm64 + {2A96D2DD-48F8-475A-9DA2-30CB4EF71419}.Debug|arm64.Build.0 = Debug|arm64 + {2A96D2DD-48F8-475A-9DA2-30CB4EF71419}.Debug|x64.ActiveCfg = Debug|x64 + {2A96D2DD-48F8-475A-9DA2-30CB4EF71419}.Debug|x64.Build.0 = Debug|x64 + {2A96D2DD-48F8-475A-9DA2-30CB4EF71419}.Debug|x86.ActiveCfg = Debug|x86 + {2A96D2DD-48F8-475A-9DA2-30CB4EF71419}.Debug|x86.Build.0 = Debug|x86 + {2A96D2DD-48F8-475A-9DA2-30CB4EF71419}.Release|Any CPU.ActiveCfg = Release|x64 + {2A96D2DD-48F8-475A-9DA2-30CB4EF71419}.Release|Any CPU.Build.0 = Release|x64 + {2A96D2DD-48F8-475A-9DA2-30CB4EF71419}.Release|arm64.ActiveCfg = Release|arm64 + {2A96D2DD-48F8-475A-9DA2-30CB4EF71419}.Release|arm64.Build.0 = Release|arm64 + {2A96D2DD-48F8-475A-9DA2-30CB4EF71419}.Release|x64.ActiveCfg = Release|x64 + {2A96D2DD-48F8-475A-9DA2-30CB4EF71419}.Release|x64.Build.0 = Release|x64 + {2A96D2DD-48F8-475A-9DA2-30CB4EF71419}.Release|x86.ActiveCfg = Release|x86 + {2A96D2DD-48F8-475A-9DA2-30CB4EF71419}.Release|x86.Build.0 = Release|x86 + {D92BC45D-6D1B-4DE3-9303-4B3ED1971192}.Debug|Any CPU.ActiveCfg = Debug|x64 + {D92BC45D-6D1B-4DE3-9303-4B3ED1971192}.Debug|Any CPU.Build.0 = Debug|x64 + {D92BC45D-6D1B-4DE3-9303-4B3ED1971192}.Debug|arm64.ActiveCfg = Debug|arm64 + {D92BC45D-6D1B-4DE3-9303-4B3ED1971192}.Debug|arm64.Build.0 = Debug|arm64 + {D92BC45D-6D1B-4DE3-9303-4B3ED1971192}.Debug|x64.ActiveCfg = Debug|x64 + {D92BC45D-6D1B-4DE3-9303-4B3ED1971192}.Debug|x64.Build.0 = Debug|x64 + {D92BC45D-6D1B-4DE3-9303-4B3ED1971192}.Debug|x86.ActiveCfg = Debug|x86 + {D92BC45D-6D1B-4DE3-9303-4B3ED1971192}.Debug|x86.Build.0 = Debug|x86 + {D92BC45D-6D1B-4DE3-9303-4B3ED1971192}.Release|Any CPU.ActiveCfg = Release|x64 + {D92BC45D-6D1B-4DE3-9303-4B3ED1971192}.Release|Any CPU.Build.0 = Release|x64 + {D92BC45D-6D1B-4DE3-9303-4B3ED1971192}.Release|arm64.ActiveCfg = Release|arm64 + {D92BC45D-6D1B-4DE3-9303-4B3ED1971192}.Release|arm64.Build.0 = Release|arm64 + {D92BC45D-6D1B-4DE3-9303-4B3ED1971192}.Release|x64.ActiveCfg = Release|x64 + {D92BC45D-6D1B-4DE3-9303-4B3ED1971192}.Release|x64.Build.0 = Release|x64 + {D92BC45D-6D1B-4DE3-9303-4B3ED1971192}.Release|x86.ActiveCfg = Release|x86 + {D92BC45D-6D1B-4DE3-9303-4B3ED1971192}.Release|x86.Build.0 = Release|x86 + {D8256951-EB23-45AA-8A0B-4573DF8E26F2}.Debug|Any CPU.ActiveCfg = Debug|x64 + {D8256951-EB23-45AA-8A0B-4573DF8E26F2}.Debug|Any CPU.Build.0 = Debug|x64 + {D8256951-EB23-45AA-8A0B-4573DF8E26F2}.Debug|arm64.ActiveCfg = Debug|arm64 + {D8256951-EB23-45AA-8A0B-4573DF8E26F2}.Debug|arm64.Build.0 = Debug|arm64 + {D8256951-EB23-45AA-8A0B-4573DF8E26F2}.Debug|x64.ActiveCfg = Debug|x64 + {D8256951-EB23-45AA-8A0B-4573DF8E26F2}.Debug|x64.Build.0 = Debug|x64 + {D8256951-EB23-45AA-8A0B-4573DF8E26F2}.Debug|x86.ActiveCfg = Debug|x86 + {D8256951-EB23-45AA-8A0B-4573DF8E26F2}.Debug|x86.Build.0 = Debug|x86 + {D8256951-EB23-45AA-8A0B-4573DF8E26F2}.Release|Any CPU.ActiveCfg = Release|x64 + {D8256951-EB23-45AA-8A0B-4573DF8E26F2}.Release|Any CPU.Build.0 = Release|x64 + {D8256951-EB23-45AA-8A0B-4573DF8E26F2}.Release|arm64.ActiveCfg = Release|arm64 + {D8256951-EB23-45AA-8A0B-4573DF8E26F2}.Release|arm64.Build.0 = Release|arm64 + {D8256951-EB23-45AA-8A0B-4573DF8E26F2}.Release|x64.ActiveCfg = Release|x64 + {D8256951-EB23-45AA-8A0B-4573DF8E26F2}.Release|x64.Build.0 = Release|x64 + {D8256951-EB23-45AA-8A0B-4573DF8E26F2}.Release|x86.ActiveCfg = Release|x86 + {D8256951-EB23-45AA-8A0B-4573DF8E26F2}.Release|x86.Build.0 = Release|x86 + {2C7522A3-DCE2-4ED0-889A-AD10F241EDF4}.Debug|Any CPU.ActiveCfg = Debug|x64 + {2C7522A3-DCE2-4ED0-889A-AD10F241EDF4}.Debug|Any CPU.Build.0 = Debug|x64 + {2C7522A3-DCE2-4ED0-889A-AD10F241EDF4}.Debug|arm64.ActiveCfg = Debug|arm64 + {2C7522A3-DCE2-4ED0-889A-AD10F241EDF4}.Debug|arm64.Build.0 = Debug|arm64 + {2C7522A3-DCE2-4ED0-889A-AD10F241EDF4}.Debug|x64.ActiveCfg = Debug|x64 + {2C7522A3-DCE2-4ED0-889A-AD10F241EDF4}.Debug|x64.Build.0 = Debug|x64 + {2C7522A3-DCE2-4ED0-889A-AD10F241EDF4}.Debug|x86.ActiveCfg = Debug|x86 + {2C7522A3-DCE2-4ED0-889A-AD10F241EDF4}.Debug|x86.Build.0 = Debug|x86 + {2C7522A3-DCE2-4ED0-889A-AD10F241EDF4}.Release|Any CPU.ActiveCfg = Release|x64 + {2C7522A3-DCE2-4ED0-889A-AD10F241EDF4}.Release|Any CPU.Build.0 = Release|x64 + {2C7522A3-DCE2-4ED0-889A-AD10F241EDF4}.Release|arm64.ActiveCfg = Release|arm64 + {2C7522A3-DCE2-4ED0-889A-AD10F241EDF4}.Release|arm64.Build.0 = Release|arm64 + {2C7522A3-DCE2-4ED0-889A-AD10F241EDF4}.Release|x64.ActiveCfg = Release|x64 + {2C7522A3-DCE2-4ED0-889A-AD10F241EDF4}.Release|x64.Build.0 = Release|x64 + {2C7522A3-DCE2-4ED0-889A-AD10F241EDF4}.Release|x86.ActiveCfg = Release|x86 + {2C7522A3-DCE2-4ED0-889A-AD10F241EDF4}.Release|x86.Build.0 = Release|x86 + {D6B0C16D-858A-4C1B-99CF-D6F4CF5BCD5F}.Debug|Any CPU.ActiveCfg = Debug|x64 + {D6B0C16D-858A-4C1B-99CF-D6F4CF5BCD5F}.Debug|Any CPU.Build.0 = Debug|x64 + {D6B0C16D-858A-4C1B-99CF-D6F4CF5BCD5F}.Debug|arm64.ActiveCfg = Debug|ARM64 + {D6B0C16D-858A-4C1B-99CF-D6F4CF5BCD5F}.Debug|arm64.Build.0 = Debug|ARM64 + {D6B0C16D-858A-4C1B-99CF-D6F4CF5BCD5F}.Debug|x64.ActiveCfg = Debug|x64 + {D6B0C16D-858A-4C1B-99CF-D6F4CF5BCD5F}.Debug|x64.Build.0 = Debug|x64 + {D6B0C16D-858A-4C1B-99CF-D6F4CF5BCD5F}.Debug|x86.ActiveCfg = Debug|Win32 + {D6B0C16D-858A-4C1B-99CF-D6F4CF5BCD5F}.Debug|x86.Build.0 = Debug|Win32 + {D6B0C16D-858A-4C1B-99CF-D6F4CF5BCD5F}.Release|Any CPU.ActiveCfg = Release|x64 + {D6B0C16D-858A-4C1B-99CF-D6F4CF5BCD5F}.Release|Any CPU.Build.0 = Release|x64 + {D6B0C16D-858A-4C1B-99CF-D6F4CF5BCD5F}.Release|arm64.ActiveCfg = Release|ARM64 + {D6B0C16D-858A-4C1B-99CF-D6F4CF5BCD5F}.Release|arm64.Build.0 = Release|ARM64 + {D6B0C16D-858A-4C1B-99CF-D6F4CF5BCD5F}.Release|x64.ActiveCfg = Release|x64 + {D6B0C16D-858A-4C1B-99CF-D6F4CF5BCD5F}.Release|x64.Build.0 = Release|x64 + {D6B0C16D-858A-4C1B-99CF-D6F4CF5BCD5F}.Release|x86.ActiveCfg = Release|Win32 + {D6B0C16D-858A-4C1B-99CF-D6F4CF5BCD5F}.Release|x86.Build.0 = Release|Win32 + {AC872D0F-2F11-48C4-949C-2464EA1AC66F}.Debug|Any CPU.ActiveCfg = Debug|x64 + {AC872D0F-2F11-48C4-949C-2464EA1AC66F}.Debug|Any CPU.Build.0 = Debug|x64 + {AC872D0F-2F11-48C4-949C-2464EA1AC66F}.Debug|arm64.ActiveCfg = Debug|arm64 + {AC872D0F-2F11-48C4-949C-2464EA1AC66F}.Debug|arm64.Build.0 = Debug|arm64 + {AC872D0F-2F11-48C4-949C-2464EA1AC66F}.Debug|x64.ActiveCfg = Debug|x64 + {AC872D0F-2F11-48C4-949C-2464EA1AC66F}.Debug|x64.Build.0 = Debug|x64 + {AC872D0F-2F11-48C4-949C-2464EA1AC66F}.Debug|x86.ActiveCfg = Debug|x86 + {AC872D0F-2F11-48C4-949C-2464EA1AC66F}.Debug|x86.Build.0 = Debug|x86 + {AC872D0F-2F11-48C4-949C-2464EA1AC66F}.Release|Any CPU.ActiveCfg = Release|x64 + {AC872D0F-2F11-48C4-949C-2464EA1AC66F}.Release|Any CPU.Build.0 = Release|x64 + {AC872D0F-2F11-48C4-949C-2464EA1AC66F}.Release|arm64.ActiveCfg = Release|arm64 + {AC872D0F-2F11-48C4-949C-2464EA1AC66F}.Release|arm64.Build.0 = Release|arm64 + {AC872D0F-2F11-48C4-949C-2464EA1AC66F}.Release|x64.ActiveCfg = Release|x64 + {AC872D0F-2F11-48C4-949C-2464EA1AC66F}.Release|x64.Build.0 = Release|x64 + {AC872D0F-2F11-48C4-949C-2464EA1AC66F}.Release|x86.ActiveCfg = Release|x86 + {AC872D0F-2F11-48C4-949C-2464EA1AC66F}.Release|x86.Build.0 = Release|x86 + {F9121D0A-BB3A-4010-A982-CD8B77F47AA2}.Debug|Any CPU.ActiveCfg = Debug|x64 + {F9121D0A-BB3A-4010-A982-CD8B77F47AA2}.Debug|Any CPU.Build.0 = Debug|x64 + {F9121D0A-BB3A-4010-A982-CD8B77F47AA2}.Debug|arm64.ActiveCfg = Debug|arm64 + {F9121D0A-BB3A-4010-A982-CD8B77F47AA2}.Debug|arm64.Build.0 = Debug|arm64 + {F9121D0A-BB3A-4010-A982-CD8B77F47AA2}.Debug|x64.ActiveCfg = Debug|x64 + {F9121D0A-BB3A-4010-A982-CD8B77F47AA2}.Debug|x64.Build.0 = Debug|x64 + {F9121D0A-BB3A-4010-A982-CD8B77F47AA2}.Debug|x86.ActiveCfg = Debug|x86 + {F9121D0A-BB3A-4010-A982-CD8B77F47AA2}.Debug|x86.Build.0 = Debug|x86 + {F9121D0A-BB3A-4010-A982-CD8B77F47AA2}.Release|Any CPU.ActiveCfg = Release|x64 + {F9121D0A-BB3A-4010-A982-CD8B77F47AA2}.Release|Any CPU.Build.0 = Release|x64 + {F9121D0A-BB3A-4010-A982-CD8B77F47AA2}.Release|arm64.ActiveCfg = Release|arm64 + {F9121D0A-BB3A-4010-A982-CD8B77F47AA2}.Release|arm64.Build.0 = Release|arm64 + {F9121D0A-BB3A-4010-A982-CD8B77F47AA2}.Release|x64.ActiveCfg = Release|x64 + {F9121D0A-BB3A-4010-A982-CD8B77F47AA2}.Release|x64.Build.0 = Release|x64 + {F9121D0A-BB3A-4010-A982-CD8B77F47AA2}.Release|x86.ActiveCfg = Release|x86 + {F9121D0A-BB3A-4010-A982-CD8B77F47AA2}.Release|x86.Build.0 = Release|x86 + {F4095FD3-6A3F-490B-966D-E63059612EE6}.Debug|Any CPU.ActiveCfg = Debug|x64 + {F4095FD3-6A3F-490B-966D-E63059612EE6}.Debug|Any CPU.Build.0 = Debug|x64 + {F4095FD3-6A3F-490B-966D-E63059612EE6}.Debug|arm64.ActiveCfg = Debug|ARM64 + {F4095FD3-6A3F-490B-966D-E63059612EE6}.Debug|arm64.Build.0 = Debug|ARM64 + {F4095FD3-6A3F-490B-966D-E63059612EE6}.Debug|x64.ActiveCfg = Debug|x64 + {F4095FD3-6A3F-490B-966D-E63059612EE6}.Debug|x64.Build.0 = Debug|x64 + {F4095FD3-6A3F-490B-966D-E63059612EE6}.Debug|x86.ActiveCfg = Debug|x86 + {F4095FD3-6A3F-490B-966D-E63059612EE6}.Debug|x86.Build.0 = Debug|x86 + {F4095FD3-6A3F-490B-966D-E63059612EE6}.Release|Any CPU.ActiveCfg = Release|x64 + {F4095FD3-6A3F-490B-966D-E63059612EE6}.Release|Any CPU.Build.0 = Release|x64 + {F4095FD3-6A3F-490B-966D-E63059612EE6}.Release|arm64.ActiveCfg = Release|ARM64 + {F4095FD3-6A3F-490B-966D-E63059612EE6}.Release|arm64.Build.0 = Release|ARM64 + {F4095FD3-6A3F-490B-966D-E63059612EE6}.Release|x64.ActiveCfg = Release|x64 + {F4095FD3-6A3F-490B-966D-E63059612EE6}.Release|x64.Build.0 = Release|x64 + {F4095FD3-6A3F-490B-966D-E63059612EE6}.Release|x86.ActiveCfg = Release|x86 + {F4095FD3-6A3F-490B-966D-E63059612EE6}.Release|x86.Build.0 = Release|x86 + {0E05A442-BDC7-43D4-A000-F8C986826716}.Debug|Any CPU.ActiveCfg = Debug|x64 + {0E05A442-BDC7-43D4-A000-F8C986826716}.Debug|Any CPU.Build.0 = Debug|x64 + {0E05A442-BDC7-43D4-A000-F8C986826716}.Debug|arm64.ActiveCfg = Debug|ARM64 + {0E05A442-BDC7-43D4-A000-F8C986826716}.Debug|arm64.Build.0 = Debug|ARM64 + {0E05A442-BDC7-43D4-A000-F8C986826716}.Debug|x64.ActiveCfg = Debug|x64 + {0E05A442-BDC7-43D4-A000-F8C986826716}.Debug|x64.Build.0 = Debug|x64 + {0E05A442-BDC7-43D4-A000-F8C986826716}.Debug|x86.ActiveCfg = Debug|x86 + {0E05A442-BDC7-43D4-A000-F8C986826716}.Debug|x86.Build.0 = Debug|x86 + {0E05A442-BDC7-43D4-A000-F8C986826716}.Release|Any CPU.ActiveCfg = Release|x64 + {0E05A442-BDC7-43D4-A000-F8C986826716}.Release|Any CPU.Build.0 = Release|x64 + {0E05A442-BDC7-43D4-A000-F8C986826716}.Release|arm64.ActiveCfg = Release|ARM64 + {0E05A442-BDC7-43D4-A000-F8C986826716}.Release|arm64.Build.0 = Release|ARM64 + {0E05A442-BDC7-43D4-A000-F8C986826716}.Release|x64.ActiveCfg = Release|x64 + {0E05A442-BDC7-43D4-A000-F8C986826716}.Release|x64.Build.0 = Release|x64 + {0E05A442-BDC7-43D4-A000-F8C986826716}.Release|x86.ActiveCfg = Release|x86 + {0E05A442-BDC7-43D4-A000-F8C986826716}.Release|x86.Build.0 = Release|x86 + {D759CD66-494C-4A00-8075-8B65A9891349}.Debug|Any CPU.ActiveCfg = Debug|x64 + {D759CD66-494C-4A00-8075-8B65A9891349}.Debug|Any CPU.Build.0 = Debug|x64 + {D759CD66-494C-4A00-8075-8B65A9891349}.Debug|arm64.ActiveCfg = Debug|arm64 + {D759CD66-494C-4A00-8075-8B65A9891349}.Debug|arm64.Build.0 = Debug|arm64 + {D759CD66-494C-4A00-8075-8B65A9891349}.Debug|x64.ActiveCfg = Debug|x64 + {D759CD66-494C-4A00-8075-8B65A9891349}.Debug|x64.Build.0 = Debug|x64 + {D759CD66-494C-4A00-8075-8B65A9891349}.Debug|x86.ActiveCfg = Debug|x86 + {D759CD66-494C-4A00-8075-8B65A9891349}.Debug|x86.Build.0 = Debug|x86 + {D759CD66-494C-4A00-8075-8B65A9891349}.Release|Any CPU.ActiveCfg = Release|x64 + {D759CD66-494C-4A00-8075-8B65A9891349}.Release|Any CPU.Build.0 = Release|x64 + {D759CD66-494C-4A00-8075-8B65A9891349}.Release|arm64.ActiveCfg = Release|arm64 + {D759CD66-494C-4A00-8075-8B65A9891349}.Release|arm64.Build.0 = Release|arm64 + {D759CD66-494C-4A00-8075-8B65A9891349}.Release|x64.ActiveCfg = Release|x64 + {D759CD66-494C-4A00-8075-8B65A9891349}.Release|x64.Build.0 = Release|x64 + {D759CD66-494C-4A00-8075-8B65A9891349}.Release|x86.ActiveCfg = Release|x86 + {D759CD66-494C-4A00-8075-8B65A9891349}.Release|x86.Build.0 = Release|x86 EndGlobalSection GlobalSection(SolutionProperties) = preSolution HideSolutionNode = FALSE EndGlobalSection GlobalSection(NestedProjects) = preSolution - {CD512D91-FDA6-4908-89D5-4106F090A7BE} = {BE6229BE-72EE-4A32-BE20-F8C6FC629047} + {CD512D91-FDA6-4908-89D5-4106F090A7BE} = {E7C94F61-D6CF-464D-8D50-210488AF7A50} {0901B260-1B88-4B99-A9F8-477ED0A74FBD} = {4179A05E-37F1-46CD-9218-0889EA2BB75B} {9A62BDDC-F33E-4EBE-B407-533263A92511} = {4179A05E-37F1-46CD-9218-0889EA2BB75B} - {F65759A2-AF44-4211-9817-76E6D02F37D0} = {BE6229BE-72EE-4A32-BE20-F8C6FC629047} + {F65759A2-AF44-4211-9817-76E6D02F37D0} = {E7C94F61-D6CF-464D-8D50-210488AF7A50} {4179A05E-37F1-46CD-9218-0889EA2BB75B} = {A972EC5B-FC61-4964-A6FF-F9633EB75DFD} {54082587-A435-423F-AE1B-01B906FFA7C5} = {4179A05E-37F1-46CD-9218-0889EA2BB75B} {222B92B1-AC7A-409D-957B-A3851D3F41B0} = {A972EC5B-FC61-4964-A6FF-F9633EB75DFD} @@ -430,10 +695,30 @@ Global {940FD524-1AC0-4BBA-BBBE-1E4F2E797508} = {4179A05E-37F1-46CD-9218-0889EA2BB75B} {A7E5FD7B-B41A-4CAE-A45A-E686DFA8ACF1} = {4179A05E-37F1-46CD-9218-0889EA2BB75B} {053AF75C-5CD8-497F-BA25-47435BD86047} = {4179A05E-37F1-46CD-9218-0889EA2BB75B} + {32C0052F-10AF-48C0-A7A3-B0A1793397FD} = {DCAF188B-60C3-4EDB-8049-BAA927FBCD7D} {0D879E08-99AA-4019-9D04-DEA9F7C7BFC1} = {32C0052F-10AF-48C0-A7A3-B0A1793397FD} {69F8B7DF-F52B-4B74-9A16-AB3241BB8912} = {F6EAB7D3-8F0A-4455-8969-2EF4A67314A0} {F6EAB7D3-8F0A-4455-8969-2EF4A67314A0} = {A972EC5B-FC61-4964-A6FF-F9633EB75DFD} {2F9AD5AF-EF3B-496A-8566-9E9539E3DF43} = {A972EC5B-FC61-4964-A6FF-F9633EB75DFD} + {E7C94F61-D6CF-464D-8D50-210488AF7A50} = {A972EC5B-FC61-4964-A6FF-F9633EB75DFD} + {8FC9A04E-1FFD-42BA-B304-D1FA964D99CE} = {A972EC5B-FC61-4964-A6FF-F9633EB75DFD} + {CFD8A90D-8B6D-4ED6-BA35-FF894BEB46C0} = {8FC9A04E-1FFD-42BA-B304-D1FA964D99CE} + {0689521A-1686-46DB-81E1-3B84AA0EF1AC} = {81AACED5-CFB5-47A6-AFD6-4625AADCFFA3} + {A716481F-C1AF-4243-84F9-7B9399055E51} = {81AACED5-CFB5-47A6-AFD6-4625AADCFFA3} + {0ADDE603-FBC0-415C-A88B-8A3F5A086FB8} = {8296B318-2782-4A0E-97F1-C770411C779A} + {75976510-22B7-4910-96F2-3E1519C3FF35} = {81AACED5-CFB5-47A6-AFD6-4625AADCFFA3} + {2A96D2DD-48F8-475A-9DA2-30CB4EF71419} = {81AACED5-CFB5-47A6-AFD6-4625AADCFFA3} + {D92BC45D-6D1B-4DE3-9303-4B3ED1971192} = {81AACED5-CFB5-47A6-AFD6-4625AADCFFA3} + {81AACED5-CFB5-47A6-AFD6-4625AADCFFA3} = {0ADDE603-FBC0-415C-A88B-8A3F5A086FB8} + {3E3791DF-070D-4ADE-96E8-93D6FBD53953} = {0ADDE603-FBC0-415C-A88B-8A3F5A086FB8} + {D8256951-EB23-45AA-8A0B-4573DF8E26F2} = {81AACED5-CFB5-47A6-AFD6-4625AADCFFA3} + {2C7522A3-DCE2-4ED0-889A-AD10F241EDF4} = {81AACED5-CFB5-47A6-AFD6-4625AADCFFA3} + {D6B0C16D-858A-4C1B-99CF-D6F4CF5BCD5F} = {81AACED5-CFB5-47A6-AFD6-4625AADCFFA3} + {AC872D0F-2F11-48C4-949C-2464EA1AC66F} = {81AACED5-CFB5-47A6-AFD6-4625AADCFFA3} + {F9121D0A-BB3A-4010-A982-CD8B77F47AA2} = {3E3791DF-070D-4ADE-96E8-93D6FBD53953} + {F4095FD3-6A3F-490B-966D-E63059612EE6} = {3E3791DF-070D-4ADE-96E8-93D6FBD53953} + {0E05A442-BDC7-43D4-A000-F8C986826716} = {3E3791DF-070D-4ADE-96E8-93D6FBD53953} + {D759CD66-494C-4A00-8075-8B65A9891349} = {81AACED5-CFB5-47A6-AFD6-4625AADCFFA3} EndGlobalSection GlobalSection(ExtensibilityGlobals) = postSolution SolutionGuid = {030B5641-B206-46BB-BF71-36FF009088FA} diff --git a/Directory.Build.props b/Directory.Build.props index 2012ca32e8..2d989711ee 100644 --- a/Directory.Build.props +++ b/Directory.Build.props @@ -33,8 +33,9 @@ all runtime; build; native; contentfiles; analyzers; buildtransitive - - + + + @@ -43,6 +44,6 @@ true - + \ No newline at end of file diff --git a/HyperVExtension/BuildDevSetupAgentHelper.cmd b/HyperVExtension/BuildDevSetupAgentHelper.cmd new file mode 100644 index 0000000000..0573c1cdfb --- /dev/null +++ b/HyperVExtension/BuildDevSetupAgentHelper.cmd @@ -0,0 +1,5 @@ +@echo off + +powershell -ExecutionPolicy Unrestricted -NoLogo -NoProfile -File %~dp0\BuildDevSetupAgentHelper.ps1 %* + +exit /b %ERRORLEVEL% \ No newline at end of file diff --git a/HyperVExtension/BuildDevSetupAgentHelper.ps1 b/HyperVExtension/BuildDevSetupAgentHelper.ps1 new file mode 100644 index 0000000000..3af8871493 --- /dev/null +++ b/HyperVExtension/BuildDevSetupAgentHelper.ps1 @@ -0,0 +1,113 @@ +Param( + [string]$Platform = "x64", + [string]$Configuration = "debug", + [string]$VersionOfSDK, + [string]$SDKNugetSource, + [string]$Version, + [string]$BuildStep = "all", + [string]$AzureBuildingBranch = "main", + [bool]$IsAzurePipelineBuild = $false, + [switch]$BypassWarning = $false, + [switch]$Help = $false +) + +$StartTime = Get-Date + +if ($Help) { + Write-Host @" +Copyright (c) Microsoft Corporation. +Licensed under the MIT License. + +Syntax: + BuildDevSetupAgentHelper.cmd [options] + +Description: + Builds DevSetupAgent. + +Options: + + -Platform + Only build the selected platform(s) + Example: -Platform x64 + Example: -Platform "x86,x64,arm64" + + -Configuration + Only build the selected configuration(s) + Example: -Configuration Release + Example: -Configuration "Debug,Release" + + -Help + Display this usage message. +"@ + Exit +} + +if (-not $BypassWarning) { + Write-Host @" +This script is not meant to be run directly. To build DevSetupAgent, please run the following from the root directory: +build -BuildStep "DevSetupAgent" +"@ -ForegroundColor RED + Exit +} + +$isAdmin = ([Security.Principal.WindowsPrincipal] [Security.Principal.WindowsIdentity]::GetCurrent()).IsInRole([Security.Principal.WindowsBuiltInRole] 'Administrator') + +$ErrorActionPreference = "Stop" + +$msbuildPath = &"${env:ProgramFiles(x86)}\Microsoft Visual Studio\Installer\vswhere.exe" -latest -prerelease -products * -requires Microsoft.Component.MSBuild -find MSBuild\**\Bin\MSBuild.exe +if ($IsAzurePipelineBuild) { + $nugetPath = "nuget.exe"; +} else { + $nugetPath = (Join-Path $env:Build_RootDirectory "build\NugetWrapper.cmd") +} + +if (-not([string]::IsNullOrWhiteSpace($SDKNugetSource))) { + & $nugetPath sources add -Source $SDKNugetSource +} + +Try { + $buildRing = "Dev" + + if ($AzureBuildingBranch -ieq "release") { + $buildRing = "Stable" + } elseif ($AzureBuildingBranch -ieq "staging") { + $buildRing = "Canary" + } + + Write-Host $nugetPath + + & $nugetPath restore + + $msbuildArgs = @( + ("HyperVExtension\DevSetupAgent.sln"), + ("/p:Platform="+$platform), + ("/p:Configuration="+$configuration), + ("/restore"), + ("/binaryLogger:DevSetupAgent.$platform.$configuration.binlog"), + ("/p:BuildRing=$buildRing") + ) + if (-not([string]::IsNullOrWhiteSpace($VersionOfSDK))) { + $msbuildArgs += ("/p:DevHomeSDKVersion="+$env:sdk_version) + } + + & $msbuildPath $msbuildArgs + + $binariesOutputPath = (Join-Path $env:Build_RootDirectory "HyperVExtension\src\DevSetupAgent\bin\$Platform\$Configuration\net8.0-windows10.0.22000.0\win10-$Platform\*") + $zipOutputPath = (Join-Path $env:Build_RootDirectory "HyperVExtension\src\DevSetupAgent\bin\$Platform\$Configuration\DevSetupAgent_$Platform.zip") + + Compress-Archive -Force -Path $binariesOutputPath $zipOutputPath +} Catch { + $formatString = "`n{0}`n`n{1}`n`n" + $fields = $_, $_.ScriptStackTrace + Write-Host ($formatString -f $fields) -ForegroundColor RED + Exit 1 +} + +$TotalTime = (Get-Date)-$StartTime +$TotalMinutes = [math]::Floor($TotalTime.TotalMinutes) +$TotalSeconds = [math]::Ceiling($TotalTime.TotalSeconds) + +Write-Host @" +Total Running Time: +$TotalMinutes minutes and $TotalSeconds seconds +"@ -ForegroundColor CYAN diff --git a/HyperVExtension/DevSetupAgent.sln b/HyperVExtension/DevSetupAgent.sln new file mode 100644 index 0000000000..f6cc14da6c --- /dev/null +++ b/HyperVExtension/DevSetupAgent.sln @@ -0,0 +1,213 @@ + +Microsoft Visual Studio Solution File, Format Version 12.00 +# Visual Studio Version 17 +VisualStudioVersion = 17.9.34616.47 +MinimumVisualStudioVersion = 10.0.40219.1 +Project("{2150E333-8FDC-42A3-9474-1A3956D46DE8}") = "Solution Items", "Solution Items", "{635E7B21-397B-4D31-9D5A-C82D406DFB74}" + ProjectSection(SolutionItems) = preProject + ..\.editorconfig = ..\.editorconfig + ..\.vsconfig = ..\.vsconfig + ..\Directory.Build.props = ..\Directory.Build.props + ..\Solution.props = ..\Solution.props + EndProjectSection +EndProject +Project("{8BC9CEB8-8B4A-11D0-8D11-00A0C91BC942}") = "DevSetupEngineIdl", "src\DevSetupEngineIdl\DevSetupEngineIdl.vcxproj", "{D6B0C16D-858A-4C1B-99CF-D6F4CF5BCD5F}" +EndProject +Project("{9A19103F-16F7-4668-BE54-9A1E7A4F7556}") = "DevSetupEngineProjection", "src\DevSetupEngineProjection\DevSetupEngineProjection.csproj", "{81637044-773F-4A0D-A166-AD110738435E}" +EndProject +Project("{9A19103F-16F7-4668-BE54-9A1E7A4F7556}") = "HyperVExtension.Logging", "src\Logging\HyperVExtension.Logging.csproj", "{A12EFFFD-9C84-47A8-AE57-426AB56FC19B}" +EndProject +Project("{9A19103F-16F7-4668-BE54-9A1E7A4F7556}") = "DevSetupEngine", "src\DevSetupEngine\DevSetupEngine.csproj", "{61D565EF-FA52-4426-B3D3-CBF6841C3014}" +EndProject +Project("{9A19103F-16F7-4668-BE54-9A1E7A4F7556}") = "HyperVExtension.HostGuestCommunication", "src\HyperVExtension.HostGuestCommunication\HyperVExtension.HostGuestCommunication.csproj", "{CB5CE4D1-89E9-4E24-9C0C-40439B6317BA}" +EndProject +Project("{9A19103F-16F7-4668-BE54-9A1E7A4F7556}") = "HyperVExtension.Common", "src\HyperVExtension.Common\HyperVExtension.Common.csproj", "{7F1EB9CE-372C-434E-ACA1-3AC009D7AC81}" +EndProject +Project("{9A19103F-16F7-4668-BE54-9A1E7A4F7556}") = "HyperVExtension.Telemetry", "src\Telemetry\HyperVExtension.Telemetry.csproj", "{A06B631E-D563-44A3-A678-2F7E7806902F}" +EndProject +Project("{9A19103F-16F7-4668-BE54-9A1E7A4F7556}") = "DevSetupAgent", "src\DevSetupAgent\DevSetupAgent.csproj", "{FD0D43E0-3724-4C77-B06E-33B3B83E9945}" +EndProject +Project("{9A19103F-16F7-4668-BE54-9A1E7A4F7556}") = "DevSetupAgent.Test", "test\DevSetupAgent.Test\DevSetupAgent.Test.csproj", "{F38A10BC-C93B-4D99-B079-CE486E529176}" +EndProject +Project("{9A19103F-16F7-4668-BE54-9A1E7A4F7556}") = "DevSetupEngine.Test", "test\DevSetupEngine.Test\DevSetupEngine.Test.csproj", "{1ABAEFB2-B954-431A-9814-9EEDBD676C21}" +EndProject +Global + GlobalSection(SolutionConfigurationPlatforms) = preSolution + Debug|Any CPU = Debug|Any CPU + Debug|arm64 = Debug|arm64 + Debug|x64 = Debug|x64 + Debug|x86 = Debug|x86 + Release|Any CPU = Release|Any CPU + Release|arm64 = Release|arm64 + Release|x64 = Release|x64 + Release|x86 = Release|x86 + EndGlobalSection + GlobalSection(ProjectConfigurationPlatforms) = postSolution + {D6B0C16D-858A-4C1B-99CF-D6F4CF5BCD5F}.Debug|Any CPU.ActiveCfg = Debug|x64 + {D6B0C16D-858A-4C1B-99CF-D6F4CF5BCD5F}.Debug|Any CPU.Build.0 = Debug|x64 + {D6B0C16D-858A-4C1B-99CF-D6F4CF5BCD5F}.Debug|arm64.ActiveCfg = Debug|ARM64 + {D6B0C16D-858A-4C1B-99CF-D6F4CF5BCD5F}.Debug|arm64.Build.0 = Debug|ARM64 + {D6B0C16D-858A-4C1B-99CF-D6F4CF5BCD5F}.Debug|x64.ActiveCfg = Debug|x64 + {D6B0C16D-858A-4C1B-99CF-D6F4CF5BCD5F}.Debug|x64.Build.0 = Debug|x64 + {D6B0C16D-858A-4C1B-99CF-D6F4CF5BCD5F}.Debug|x86.ActiveCfg = Debug|Win32 + {D6B0C16D-858A-4C1B-99CF-D6F4CF5BCD5F}.Debug|x86.Build.0 = Debug|Win32 + {D6B0C16D-858A-4C1B-99CF-D6F4CF5BCD5F}.Release|Any CPU.ActiveCfg = Release|x64 + {D6B0C16D-858A-4C1B-99CF-D6F4CF5BCD5F}.Release|Any CPU.Build.0 = Release|x64 + {D6B0C16D-858A-4C1B-99CF-D6F4CF5BCD5F}.Release|arm64.ActiveCfg = Release|ARM64 + {D6B0C16D-858A-4C1B-99CF-D6F4CF5BCD5F}.Release|arm64.Build.0 = Release|ARM64 + {D6B0C16D-858A-4C1B-99CF-D6F4CF5BCD5F}.Release|x64.ActiveCfg = Release|x64 + {D6B0C16D-858A-4C1B-99CF-D6F4CF5BCD5F}.Release|x64.Build.0 = Release|x64 + {D6B0C16D-858A-4C1B-99CF-D6F4CF5BCD5F}.Release|x86.ActiveCfg = Release|Win32 + {D6B0C16D-858A-4C1B-99CF-D6F4CF5BCD5F}.Release|x86.Build.0 = Release|Win32 + {81637044-773F-4A0D-A166-AD110738435E}.Debug|Any CPU.ActiveCfg = Debug|x64 + {81637044-773F-4A0D-A166-AD110738435E}.Debug|Any CPU.Build.0 = Debug|x64 + {81637044-773F-4A0D-A166-AD110738435E}.Debug|arm64.ActiveCfg = Debug|arm64 + {81637044-773F-4A0D-A166-AD110738435E}.Debug|arm64.Build.0 = Debug|arm64 + {81637044-773F-4A0D-A166-AD110738435E}.Debug|x64.ActiveCfg = Debug|x64 + {81637044-773F-4A0D-A166-AD110738435E}.Debug|x64.Build.0 = Debug|x64 + {81637044-773F-4A0D-A166-AD110738435E}.Debug|x86.ActiveCfg = Debug|x86 + {81637044-773F-4A0D-A166-AD110738435E}.Debug|x86.Build.0 = Debug|x86 + {81637044-773F-4A0D-A166-AD110738435E}.Release|Any CPU.ActiveCfg = Release|x64 + {81637044-773F-4A0D-A166-AD110738435E}.Release|Any CPU.Build.0 = Release|x64 + {81637044-773F-4A0D-A166-AD110738435E}.Release|arm64.ActiveCfg = Release|arm64 + {81637044-773F-4A0D-A166-AD110738435E}.Release|arm64.Build.0 = Release|arm64 + {81637044-773F-4A0D-A166-AD110738435E}.Release|x64.ActiveCfg = Release|x64 + {81637044-773F-4A0D-A166-AD110738435E}.Release|x64.Build.0 = Release|x64 + {81637044-773F-4A0D-A166-AD110738435E}.Release|x86.ActiveCfg = Release|x86 + {81637044-773F-4A0D-A166-AD110738435E}.Release|x86.Build.0 = Release|x86 + {A12EFFFD-9C84-47A8-AE57-426AB56FC19B}.Debug|Any CPU.ActiveCfg = Debug|x64 + {A12EFFFD-9C84-47A8-AE57-426AB56FC19B}.Debug|Any CPU.Build.0 = Debug|x64 + {A12EFFFD-9C84-47A8-AE57-426AB56FC19B}.Debug|arm64.ActiveCfg = Debug|arm64 + {A12EFFFD-9C84-47A8-AE57-426AB56FC19B}.Debug|arm64.Build.0 = Debug|arm64 + {A12EFFFD-9C84-47A8-AE57-426AB56FC19B}.Debug|x64.ActiveCfg = Debug|x64 + {A12EFFFD-9C84-47A8-AE57-426AB56FC19B}.Debug|x64.Build.0 = Debug|x64 + {A12EFFFD-9C84-47A8-AE57-426AB56FC19B}.Debug|x86.ActiveCfg = Debug|x86 + {A12EFFFD-9C84-47A8-AE57-426AB56FC19B}.Debug|x86.Build.0 = Debug|x86 + {A12EFFFD-9C84-47A8-AE57-426AB56FC19B}.Release|Any CPU.ActiveCfg = Release|x64 + {A12EFFFD-9C84-47A8-AE57-426AB56FC19B}.Release|Any CPU.Build.0 = Release|x64 + {A12EFFFD-9C84-47A8-AE57-426AB56FC19B}.Release|arm64.ActiveCfg = Release|arm64 + {A12EFFFD-9C84-47A8-AE57-426AB56FC19B}.Release|arm64.Build.0 = Release|arm64 + {A12EFFFD-9C84-47A8-AE57-426AB56FC19B}.Release|x64.ActiveCfg = Release|x64 + {A12EFFFD-9C84-47A8-AE57-426AB56FC19B}.Release|x64.Build.0 = Release|x64 + {A12EFFFD-9C84-47A8-AE57-426AB56FC19B}.Release|x86.ActiveCfg = Release|x86 + {A12EFFFD-9C84-47A8-AE57-426AB56FC19B}.Release|x86.Build.0 = Release|x86 + {61D565EF-FA52-4426-B3D3-CBF6841C3014}.Debug|Any CPU.ActiveCfg = Debug|x64 + {61D565EF-FA52-4426-B3D3-CBF6841C3014}.Debug|Any CPU.Build.0 = Debug|x64 + {61D565EF-FA52-4426-B3D3-CBF6841C3014}.Debug|arm64.ActiveCfg = Debug|arm64 + {61D565EF-FA52-4426-B3D3-CBF6841C3014}.Debug|arm64.Build.0 = Debug|arm64 + {61D565EF-FA52-4426-B3D3-CBF6841C3014}.Debug|x64.ActiveCfg = Debug|x64 + {61D565EF-FA52-4426-B3D3-CBF6841C3014}.Debug|x64.Build.0 = Debug|x64 + {61D565EF-FA52-4426-B3D3-CBF6841C3014}.Debug|x86.ActiveCfg = Debug|x86 + {61D565EF-FA52-4426-B3D3-CBF6841C3014}.Debug|x86.Build.0 = Debug|x86 + {61D565EF-FA52-4426-B3D3-CBF6841C3014}.Release|Any CPU.ActiveCfg = Release|x64 + {61D565EF-FA52-4426-B3D3-CBF6841C3014}.Release|Any CPU.Build.0 = Release|x64 + {61D565EF-FA52-4426-B3D3-CBF6841C3014}.Release|arm64.ActiveCfg = Release|arm64 + {61D565EF-FA52-4426-B3D3-CBF6841C3014}.Release|arm64.Build.0 = Release|arm64 + {61D565EF-FA52-4426-B3D3-CBF6841C3014}.Release|x64.ActiveCfg = Release|x64 + {61D565EF-FA52-4426-B3D3-CBF6841C3014}.Release|x64.Build.0 = Release|x64 + {61D565EF-FA52-4426-B3D3-CBF6841C3014}.Release|x86.ActiveCfg = Release|x86 + {61D565EF-FA52-4426-B3D3-CBF6841C3014}.Release|x86.Build.0 = Release|x86 + {CB5CE4D1-89E9-4E24-9C0C-40439B6317BA}.Debug|Any CPU.ActiveCfg = Debug|x64 + {CB5CE4D1-89E9-4E24-9C0C-40439B6317BA}.Debug|Any CPU.Build.0 = Debug|x64 + {CB5CE4D1-89E9-4E24-9C0C-40439B6317BA}.Debug|arm64.ActiveCfg = Debug|arm64 + {CB5CE4D1-89E9-4E24-9C0C-40439B6317BA}.Debug|arm64.Build.0 = Debug|arm64 + {CB5CE4D1-89E9-4E24-9C0C-40439B6317BA}.Debug|x64.ActiveCfg = Debug|x64 + {CB5CE4D1-89E9-4E24-9C0C-40439B6317BA}.Debug|x64.Build.0 = Debug|x64 + {CB5CE4D1-89E9-4E24-9C0C-40439B6317BA}.Debug|x86.ActiveCfg = Debug|x86 + {CB5CE4D1-89E9-4E24-9C0C-40439B6317BA}.Debug|x86.Build.0 = Debug|x86 + {CB5CE4D1-89E9-4E24-9C0C-40439B6317BA}.Release|Any CPU.ActiveCfg = Release|x64 + {CB5CE4D1-89E9-4E24-9C0C-40439B6317BA}.Release|Any CPU.Build.0 = Release|x64 + {CB5CE4D1-89E9-4E24-9C0C-40439B6317BA}.Release|arm64.ActiveCfg = Release|arm64 + {CB5CE4D1-89E9-4E24-9C0C-40439B6317BA}.Release|arm64.Build.0 = Release|arm64 + {CB5CE4D1-89E9-4E24-9C0C-40439B6317BA}.Release|x64.ActiveCfg = Release|x64 + {CB5CE4D1-89E9-4E24-9C0C-40439B6317BA}.Release|x64.Build.0 = Release|x64 + {CB5CE4D1-89E9-4E24-9C0C-40439B6317BA}.Release|x86.ActiveCfg = Release|x86 + {CB5CE4D1-89E9-4E24-9C0C-40439B6317BA}.Release|x86.Build.0 = Release|x86 + {7F1EB9CE-372C-434E-ACA1-3AC009D7AC81}.Debug|Any CPU.ActiveCfg = Debug|x64 + {7F1EB9CE-372C-434E-ACA1-3AC009D7AC81}.Debug|Any CPU.Build.0 = Debug|x64 + {7F1EB9CE-372C-434E-ACA1-3AC009D7AC81}.Debug|arm64.ActiveCfg = Debug|arm64 + {7F1EB9CE-372C-434E-ACA1-3AC009D7AC81}.Debug|arm64.Build.0 = Debug|arm64 + {7F1EB9CE-372C-434E-ACA1-3AC009D7AC81}.Debug|x64.ActiveCfg = Debug|x64 + {7F1EB9CE-372C-434E-ACA1-3AC009D7AC81}.Debug|x64.Build.0 = Debug|x64 + {7F1EB9CE-372C-434E-ACA1-3AC009D7AC81}.Debug|x86.ActiveCfg = Debug|x86 + {7F1EB9CE-372C-434E-ACA1-3AC009D7AC81}.Debug|x86.Build.0 = Debug|x86 + {7F1EB9CE-372C-434E-ACA1-3AC009D7AC81}.Release|Any CPU.ActiveCfg = Release|x64 + {7F1EB9CE-372C-434E-ACA1-3AC009D7AC81}.Release|Any CPU.Build.0 = Release|x64 + {7F1EB9CE-372C-434E-ACA1-3AC009D7AC81}.Release|arm64.ActiveCfg = Release|arm64 + {7F1EB9CE-372C-434E-ACA1-3AC009D7AC81}.Release|arm64.Build.0 = Release|arm64 + {7F1EB9CE-372C-434E-ACA1-3AC009D7AC81}.Release|x64.ActiveCfg = Release|x64 + {7F1EB9CE-372C-434E-ACA1-3AC009D7AC81}.Release|x64.Build.0 = Release|x64 + {7F1EB9CE-372C-434E-ACA1-3AC009D7AC81}.Release|x86.ActiveCfg = Release|x86 + {7F1EB9CE-372C-434E-ACA1-3AC009D7AC81}.Release|x86.Build.0 = Release|x86 + {A06B631E-D563-44A3-A678-2F7E7806902F}.Debug|Any CPU.ActiveCfg = Debug|x64 + {A06B631E-D563-44A3-A678-2F7E7806902F}.Debug|Any CPU.Build.0 = Debug|x64 + {A06B631E-D563-44A3-A678-2F7E7806902F}.Debug|arm64.ActiveCfg = Debug|arm64 + {A06B631E-D563-44A3-A678-2F7E7806902F}.Debug|arm64.Build.0 = Debug|arm64 + {A06B631E-D563-44A3-A678-2F7E7806902F}.Debug|x64.ActiveCfg = Debug|x64 + {A06B631E-D563-44A3-A678-2F7E7806902F}.Debug|x64.Build.0 = Debug|x64 + {A06B631E-D563-44A3-A678-2F7E7806902F}.Debug|x86.ActiveCfg = Debug|x86 + {A06B631E-D563-44A3-A678-2F7E7806902F}.Debug|x86.Build.0 = Debug|x86 + {A06B631E-D563-44A3-A678-2F7E7806902F}.Release|Any CPU.ActiveCfg = Release|x64 + {A06B631E-D563-44A3-A678-2F7E7806902F}.Release|Any CPU.Build.0 = Release|x64 + {A06B631E-D563-44A3-A678-2F7E7806902F}.Release|arm64.ActiveCfg = Release|arm64 + {A06B631E-D563-44A3-A678-2F7E7806902F}.Release|arm64.Build.0 = Release|arm64 + {A06B631E-D563-44A3-A678-2F7E7806902F}.Release|x64.ActiveCfg = Release|x64 + {A06B631E-D563-44A3-A678-2F7E7806902F}.Release|x64.Build.0 = Release|x64 + {A06B631E-D563-44A3-A678-2F7E7806902F}.Release|x86.ActiveCfg = Release|x86 + {A06B631E-D563-44A3-A678-2F7E7806902F}.Release|x86.Build.0 = Release|x86 + {FD0D43E0-3724-4C77-B06E-33B3B83E9945}.Debug|Any CPU.ActiveCfg = Debug|x64 + {FD0D43E0-3724-4C77-B06E-33B3B83E9945}.Debug|Any CPU.Build.0 = Debug|x64 + {FD0D43E0-3724-4C77-B06E-33B3B83E9945}.Debug|arm64.ActiveCfg = Debug|arm64 + {FD0D43E0-3724-4C77-B06E-33B3B83E9945}.Debug|arm64.Build.0 = Debug|arm64 + {FD0D43E0-3724-4C77-B06E-33B3B83E9945}.Debug|x64.ActiveCfg = Debug|x64 + {FD0D43E0-3724-4C77-B06E-33B3B83E9945}.Debug|x64.Build.0 = Debug|x64 + {FD0D43E0-3724-4C77-B06E-33B3B83E9945}.Debug|x86.ActiveCfg = Debug|x86 + {FD0D43E0-3724-4C77-B06E-33B3B83E9945}.Debug|x86.Build.0 = Debug|x86 + {FD0D43E0-3724-4C77-B06E-33B3B83E9945}.Release|Any CPU.ActiveCfg = Release|x64 + {FD0D43E0-3724-4C77-B06E-33B3B83E9945}.Release|Any CPU.Build.0 = Release|x64 + {FD0D43E0-3724-4C77-B06E-33B3B83E9945}.Release|arm64.ActiveCfg = Release|arm64 + {FD0D43E0-3724-4C77-B06E-33B3B83E9945}.Release|arm64.Build.0 = Release|arm64 + {FD0D43E0-3724-4C77-B06E-33B3B83E9945}.Release|x64.ActiveCfg = Release|x64 + {FD0D43E0-3724-4C77-B06E-33B3B83E9945}.Release|x64.Build.0 = Release|x64 + {FD0D43E0-3724-4C77-B06E-33B3B83E9945}.Release|x86.ActiveCfg = Release|x86 + {FD0D43E0-3724-4C77-B06E-33B3B83E9945}.Release|x86.Build.0 = Release|x86 + {F38A10BC-C93B-4D99-B079-CE486E529176}.Debug|Any CPU.ActiveCfg = Debug|x64 + {F38A10BC-C93B-4D99-B079-CE486E529176}.Debug|Any CPU.Build.0 = Debug|x64 + {F38A10BC-C93B-4D99-B079-CE486E529176}.Debug|arm64.ActiveCfg = Debug|arm64 + {F38A10BC-C93B-4D99-B079-CE486E529176}.Debug|arm64.Build.0 = Debug|arm64 + {F38A10BC-C93B-4D99-B079-CE486E529176}.Debug|x64.ActiveCfg = Debug|x64 + {F38A10BC-C93B-4D99-B079-CE486E529176}.Debug|x64.Build.0 = Debug|x64 + {F38A10BC-C93B-4D99-B079-CE486E529176}.Debug|x86.ActiveCfg = Debug|x86 + {F38A10BC-C93B-4D99-B079-CE486E529176}.Debug|x86.Build.0 = Debug|x86 + {F38A10BC-C93B-4D99-B079-CE486E529176}.Release|Any CPU.ActiveCfg = Release|x64 + {F38A10BC-C93B-4D99-B079-CE486E529176}.Release|Any CPU.Build.0 = Release|x64 + {F38A10BC-C93B-4D99-B079-CE486E529176}.Release|arm64.ActiveCfg = Release|arm64 + {F38A10BC-C93B-4D99-B079-CE486E529176}.Release|arm64.Build.0 = Release|arm64 + {F38A10BC-C93B-4D99-B079-CE486E529176}.Release|x64.ActiveCfg = Release|x64 + {F38A10BC-C93B-4D99-B079-CE486E529176}.Release|x64.Build.0 = Release|x64 + {F38A10BC-C93B-4D99-B079-CE486E529176}.Release|x86.ActiveCfg = Release|x86 + {F38A10BC-C93B-4D99-B079-CE486E529176}.Release|x86.Build.0 = Release|x86 + {1ABAEFB2-B954-431A-9814-9EEDBD676C21}.Debug|Any CPU.ActiveCfg = Debug|x64 + {1ABAEFB2-B954-431A-9814-9EEDBD676C21}.Debug|Any CPU.Build.0 = Debug|x64 + {1ABAEFB2-B954-431A-9814-9EEDBD676C21}.Debug|arm64.ActiveCfg = Debug|arm64 + {1ABAEFB2-B954-431A-9814-9EEDBD676C21}.Debug|arm64.Build.0 = Debug|arm64 + {1ABAEFB2-B954-431A-9814-9EEDBD676C21}.Debug|x64.ActiveCfg = Debug|x64 + {1ABAEFB2-B954-431A-9814-9EEDBD676C21}.Debug|x64.Build.0 = Debug|x64 + {1ABAEFB2-B954-431A-9814-9EEDBD676C21}.Debug|x86.ActiveCfg = Debug|x86 + {1ABAEFB2-B954-431A-9814-9EEDBD676C21}.Debug|x86.Build.0 = Debug|x86 + {1ABAEFB2-B954-431A-9814-9EEDBD676C21}.Release|Any CPU.ActiveCfg = Release|x64 + {1ABAEFB2-B954-431A-9814-9EEDBD676C21}.Release|Any CPU.Build.0 = Release|x64 + {1ABAEFB2-B954-431A-9814-9EEDBD676C21}.Release|arm64.ActiveCfg = Release|arm64 + {1ABAEFB2-B954-431A-9814-9EEDBD676C21}.Release|arm64.Build.0 = Release|arm64 + {1ABAEFB2-B954-431A-9814-9EEDBD676C21}.Release|x64.ActiveCfg = Release|x64 + {1ABAEFB2-B954-431A-9814-9EEDBD676C21}.Release|x64.Build.0 = Release|x64 + {1ABAEFB2-B954-431A-9814-9EEDBD676C21}.Release|x86.ActiveCfg = Release|x86 + {1ABAEFB2-B954-431A-9814-9EEDBD676C21}.Release|x86.Build.0 = Release|x86 + EndGlobalSection + GlobalSection(SolutionProperties) = preSolution + HideSolutionNode = FALSE + EndGlobalSection + GlobalSection(ExtensibilityGlobals) = postSolution + SolutionGuid = {8C470F44-AFCF-441B-9B5C-42A024A74BB9} + EndGlobalSection +EndGlobal diff --git a/HyperVExtension/ToolingVersions.props b/HyperVExtension/ToolingVersions.props new file mode 100644 index 0000000000..955145dbd2 --- /dev/null +++ b/HyperVExtension/ToolingVersions.props @@ -0,0 +1,9 @@ + + + + + net8.0-windows10.0.22000.0 + 10.0.19041.0 + 10.0.19041.0 + + diff --git a/HyperVExtension/src/DevSetupAgent/DevAgentService.cs b/HyperVExtension/src/DevSetupAgent/DevAgentService.cs new file mode 100644 index 0000000000..20ac557ef7 --- /dev/null +++ b/HyperVExtension/src/DevSetupAgent/DevAgentService.cs @@ -0,0 +1,58 @@ +// Copyright (c) Microsoft Corporation. +// Licensed under the MIT License. + +using HyperVExtension.Telemetry; +using Microsoft.Windows.AppLifecycle; + +namespace HyperVExtension.DevSetupAgent; + +/// +/// Service to communicate between host and guest machines. +/// The main loop waits for messages from the host, processes them, and send response back to host. +/// +public class DevAgentService : BackgroundService +{ + private readonly IHost _host; + + public DevAgentService(IHost host) + { + _host = host; + } + + protected async override Task ExecuteAsync(CancellationToken stoppingToken) + { + Logging.Logger()?.ReportInfo($"DevAgentService started at: {DateTimeOffset.Now}"); + + try + { + var channel = _host.GetService(); + var requestManager = _host.GetService(); + + while (!stoppingToken.IsCancellationRequested) + { + try + { + var message = await channel.WaitForMessageAsync(stoppingToken); + + if (!stoppingToken.IsCancellationRequested && (message != null)) + { + requestManager.ProcessRequestMessage(message, stoppingToken); + } + } + catch (Exception ex) + { + Logging.Logger()?.ReportError($"Exception in DevAgentService.", ex); + } + } + } + catch (Exception ex) + { + Logging.Logger()?.ReportError($"Failed to run DevSetupAgent.", ex); + throw; + } + finally + { + Logging.Logger()?.ReportInfo($"DevAgentService stopped at: {DateTimeOffset.Now}"); + } + } +} diff --git a/HyperVExtension/src/DevSetupAgent/DevSetupAgent.csproj b/HyperVExtension/src/DevSetupAgent/DevSetupAgent.csproj new file mode 100644 index 0000000000..cd97b19282 --- /dev/null +++ b/HyperVExtension/src/DevSetupAgent/DevSetupAgent.csproj @@ -0,0 +1,46 @@ + + + + enable + enable + dotnet-DevSetupAgent-674f51cd-70a6-4b78-8376-66efbf84c412 + Dev + x86;x64;arm64 + win10-x86;win10-x64;win10-arm64 + Properties\PublishProfiles\win10-$(Platform).pubxml + true + + + + $(DefineConstants);CANARY_BUILD + $(DefineConstants);STABLE_BUILD + + + + + + + all + runtime; build; native; contentfiles; analyzers; buildtransitive + + + + + + + + + + + + + + all + runtime; build; native; contentfiles; analyzers; buildtransitive + + + + + + + diff --git a/HyperVExtension/src/DevSetupAgent/Extensions/ApplyConfigurationResultExtensions.cs b/HyperVExtension/src/DevSetupAgent/Extensions/ApplyConfigurationResultExtensions.cs new file mode 100644 index 0000000000..c31bb3c11c --- /dev/null +++ b/HyperVExtension/src/DevSetupAgent/Extensions/ApplyConfigurationResultExtensions.cs @@ -0,0 +1,147 @@ +// Copyright (c) Microsoft Corporation. +// Licensed under the MIT License. + +using HyperVExtension.HostGuestCommunication; +using Microsoft.Windows.DevHome.DevSetupEngine; + +namespace HyperVExtension.DevSetupAgent; + +public static class ApplyConfigurationResultExtensions +{ + public static ApplyConfigurationResult Populate( + this ApplyConfigurationResult result, IApplyConfigurationResult applyConfigurationResult) + { + if (applyConfigurationResult.ResultCode != null) + { + result.ResultCode = applyConfigurationResult.ResultCode.HResult; + } + + result.ResultDescription = applyConfigurationResult.ResultDescription; + result.OpenConfigurationSetResult = new OpenConfigurationSetResult().Populate(applyConfigurationResult.OpenConfigurationSetResult); + result.ApplyConfigurationSetResult = new ApplyConfigurationSetResult().Populate(applyConfigurationResult.ApplyConfigurationSetResult); + return result; + } + + public static ConfigurationUnitResultInformation Populate( + this ConfigurationUnitResultInformation result, IConfigurationUnitResultInformation? configurationUnitResultInformation) + { + if (configurationUnitResultInformation != null) + { + result.ResultCode = configurationUnitResultInformation.ResultCode != null ? configurationUnitResultInformation.ResultCode.HResult : 0; + result.Description = configurationUnitResultInformation.Description; + result.Details = configurationUnitResultInformation.Details; + result.ResultSource = (HostGuestCommunication.ConfigurationUnitResultSource)configurationUnitResultInformation.ResultSource; + } + + return result; + } + + public static OpenConfigurationSetResult Populate(this OpenConfigurationSetResult result, IOpenConfigurationSetResult? openConfigurationSetResult) + { + if (openConfigurationSetResult != null) + { + result.ResultCode = openConfigurationSetResult.ResultCode != null ? openConfigurationSetResult.ResultCode.HResult : 0; + result.Field = openConfigurationSetResult.Field; + result.Value = openConfigurationSetResult.Value; + result.Line = openConfigurationSetResult.Line; + result.Column = openConfigurationSetResult.Column; + } + + return result; + } + + public static ConfigurationUnit Populate(this ConfigurationUnit result, IConfigurationUnit? configurationUnit) + { + if (configurationUnit != null) + { + result.Type = configurationUnit.Type; + result.Identifier = configurationUnit.Identifier; + result.State = (HostGuestCommunication.ConfigurationUnitState)configurationUnit.State; + result.Intent = (HostGuestCommunication.ConfigurationUnitIntent)configurationUnit.Intent; + result.IsGroup = configurationUnit.IsGroup; + if (configurationUnit.Settings != null) + { + result.Settings = new Dictionary(); + foreach (var setting in configurationUnit.Settings) + { + result.Settings.Add(setting.Key, setting.Value.ToString() ?? string.Empty); + } + } + + if (configurationUnit.Units != null) + { + var count = configurationUnit.Units.Count; + if (count > 0) + { + result.Units = new List(count); + for (var i = 0; i < count; i++) + { + result.Units.Add(new ConfigurationUnit().Populate(configurationUnit.Units[i])); + } + } + } + } + + return result; + } + + public static ConfigurationSetChangeData Populate(this ConfigurationSetChangeData result, IConfigurationSetChangeData? configurationSetChangeData) + { + if (configurationSetChangeData != null) + { + result.Change = (HostGuestCommunication.ConfigurationSetChangeEventType)configurationSetChangeData.Change; + result.SetState = (HostGuestCommunication.ConfigurationSetState)configurationSetChangeData.SetState; + result.UnitState = (HostGuestCommunication.ConfigurationUnitState)configurationSetChangeData.UnitState; + if (configurationSetChangeData.ResultInformation != null) + { + result.ResultInformation = new ConfigurationUnitResultInformation().Populate(configurationSetChangeData.ResultInformation); + } + + if (configurationSetChangeData.Unit != null) + { + result.Unit = new ConfigurationUnit().Populate(configurationSetChangeData.Unit); + } + } + + return result; + } + + public static ApplyConfigurationUnitResult Populate(this ApplyConfigurationUnitResult result, IApplyConfigurationUnitResult? applyConfigurationUnitResult) + { + if (applyConfigurationUnitResult != null) + { + if (applyConfigurationUnitResult.Unit != null) + { + result.Unit = new ConfigurationUnit().Populate(applyConfigurationUnitResult.Unit); + } + + result.PreviouslyInDesiredState = applyConfigurationUnitResult.PreviouslyInDesiredState; + result.RebootRequired = applyConfigurationUnitResult.RebootRequired; + if (applyConfigurationUnitResult.ResultInformation != null) + { + result.ResultInformation = new ConfigurationUnitResultInformation().Populate(applyConfigurationUnitResult.ResultInformation); + } + } + + return result; + } + + public static ApplyConfigurationSetResult Populate(this ApplyConfigurationSetResult result, IApplyConfigurationSetResult applyConfigurationSetResult) + { + result.ResultCode = applyConfigurationSetResult?.ResultCode?.HResult ?? 0; + if (applyConfigurationSetResult?.UnitResults != null) + { + var count = applyConfigurationSetResult.UnitResults.Count; + if (count > 0) + { + result.UnitResults = new List(count); + for (var i = 0; i < count; i++) + { + result.UnitResults.Add(new ApplyConfigurationUnitResult().Populate(applyConfigurationSetResult.UnitResults[i])); + } + } + } + + return result; + } +} diff --git a/HyperVExtension/src/DevSetupAgent/HostRegistryChannel.cs b/HyperVExtension/src/DevSetupAgent/HostRegistryChannel.cs new file mode 100644 index 0000000000..af77073108 --- /dev/null +++ b/HyperVExtension/src/DevSetupAgent/HostRegistryChannel.cs @@ -0,0 +1,246 @@ +// Copyright (c) Microsoft Corporation. +// Licensed under the MIT License. + +using System.Diagnostics.PerformanceData; +using System.Runtime.InteropServices; +using System.Text; +using DevHome.Logging; +using HyperVExtension.HostGuestCommunication; +using Microsoft.UI.Xaml.Controls; +using Microsoft.Win32; +using Windows.Foundation.Collections; + +namespace HyperVExtension.DevSetupAgent; + +/// +/// Implementation of IHostChannel using registry keys provided by Hyper-V Data Exchange Service (KVP). +/// https://learn.microsoft.com/virtualization/hyper-v-on-windows/reference/integration-services#hyper-v-data-exchange-service-kvp +/// https://learn.microsoft.com/previous-versions/windows/it-pro/windows-server-2012-R2-and-2012/dn798287(v=ws.11) +/// "HKLM\SOFTWARE\Microsoft\Virtual Machine\External" contains data pushed to the guest from the host by a user +/// "HKLM\SOFTWARE\Microsoft\Virtual Machine\Guest" contains data created on the guest. This data is available to the host as non-intrinsic data. +/// Host client will create registry value named "DevSetup{}~~" with JSON message as a string value. +/// The name of the registry value becomes "MessageId" and will be used for response so the client can match +/// request with response. +/// +public sealed class HostRegistryChannel : IHostChannel, IDisposable +{ + // Public documentation doesn't say that there is a limit on the size of the value + // smaller than registry key values. But in the sample code for linux integration services + // HV_KVP_EXCHANGE_MAX_KEY_SIZE is used as a limit. In Windows code it's defined as 2048 (bytes). + // We'll need to split the message into smaller parts if it's too long. + private const int MaxValueCount = 1000; + private readonly string _fromHostRegistryKeyPath; + private readonly string _toHostRegistryKeyPath; + private readonly RegistryWatcher _registryWatcher; + private readonly AutoResetEvent _registryKeyChangedEvent; + private readonly RegistryKey _registryHiveKey; + private bool _disposed; + + public HostRegistryChannel(IRegistryChannelSettings registryChannelSettings) + { + _fromHostRegistryKeyPath = registryChannelSettings.FromHostRegistryKeyPath; + _toHostRegistryKeyPath = registryChannelSettings.ToHostRegistryKeyPath; + + // If running x86 version on x64 OS, we need to open 64-bit registry view. + _registryHiveKey = RegistryKey.OpenBaseKey(registryChannelSettings.RegistryHive, RegistryView.Registry64); + + // Search and delete all existing registry values with name "DevSetup{}" + MessageHelper.DeleteAllMessages(_registryHiveKey, _toHostRegistryKeyPath, MessageHelper.MessageIdStart); + MessageHelper.DeleteAllMessages(_registryHiveKey, _fromHostRegistryKeyPath, MessageHelper.MessageIdStart); + + _registryKeyChangedEvent = new AutoResetEvent(true); + _registryWatcher = new RegistryWatcher(_registryHiveKey, _fromHostRegistryKeyPath, OnDevSetupKeyChanged); + } + + private void OnDevSetupKeyChanged() + { + _registryKeyChangedEvent.Set(); + } + + public async Task WaitForMessageAsync(CancellationToken stoppingToken) + { + try + { + var requestMessage = default(RequestMessage); + _registryWatcher.Start(); + + while (!stoppingToken.IsCancellationRequested) + { + requestMessage = TryReadMessage(); + if (!string.IsNullOrEmpty(requestMessage.RequestId)) + { + break; + } + + await Task.Run(() => WaitHandle.WaitAny(new[] { _registryKeyChangedEvent, stoppingToken.WaitHandle })); + } + + return requestMessage; + } + finally + { + _registryWatcher.Stop(); + } + } + + public async void SendMessageAsync(IResponseMessage responseMessage, CancellationToken stoppingToken) + { + await Task.Run( + () => + { + try + { + var regKey = _registryHiveKey.CreateSubKey(_toHostRegistryKeyPath); + if (regKey == null) + { + Logging.Logger()?.ReportError($"Cannot open {_toHostRegistryKeyPath} registry key. Error: {Marshal.GetLastWin32Error()}"); + return; + } + + // Split message into parts due to the Hyper-V KVP service 2048 byte registry value limit. + var numberOfParts = responseMessage.ResponseData.Length / MaxValueCount; + if (responseMessage.ResponseData.Length % MaxValueCount != 0) + { + numberOfParts++; + } + + var totalStr = $"{MessageHelper.Separator}{numberOfParts}"; + var index = 0; + foreach (var subString in responseMessage.ResponseData.SplitByLength(MaxValueCount)) + { + index++; + regKey.SetValue($"{responseMessage.ResponseId}{MessageHelper.Separator}{index}{totalStr}", subString, RegistryValueKind.String); + } + } + catch (Exception ex) + { + Logging.Logger()?.ReportError($"Could not write host message. Response ID: {responseMessage.ResponseId}", ex); + } + }, + stoppingToken); + } + + public async void DeleteResponseMessageAsync(string responseId, CancellationToken stoppingToken) + { + await Task.Run( + () => + { + try + { + MessageHelper.DeleteAllMessages(_registryHiveKey, _toHostRegistryKeyPath, responseId); + } + catch (Exception ex) + { + Logging.Logger()?.ReportError($"Could not delete host message. Response ID: {responseId}", ex); + } + }, + stoppingToken); + } + + private RequestMessage TryReadMessage() + { + var requestMessage = default(RequestMessage); + try + { + // Messages are split in parts to workaround HyperV KVP service the 2048 bytes limit of registry value. + // We need to merge all parts of the message before processing it. + // TODO: Modify this class to use MessageHelper.MergeMessageParts (requires changing return valu and handling in the caller). + HashSet ignoreMessages = new(); + var regKey = _registryHiveKey.OpenSubKey(_fromHostRegistryKeyPath, true); + var valueNames = regKey?.GetValueNames(); + if (valueNames != null) + { + foreach (var valueName in valueNames) + { + if (valueName.StartsWith(MessageHelper.MessageIdStart, StringComparison.InvariantCultureIgnoreCase)) + { + var s = valueName.Split(MessageHelper.Separator); + if (!MessageHelper.IsValidMessageName(s, out var index, out var total)) + { + continue; + } + + if (ignoreMessages.Contains(s[0])) + { + continue; + } + + // Count if we have all parts of the message + var count = 0; + foreach (var valueNameTmp in valueNames) + { + if (valueNameTmp.StartsWith(s[0] + $"{MessageHelper.Separator}", StringComparison.InvariantCultureIgnoreCase)) + { + if (!MessageHelper.IsValidMessageName(valueNameTmp.Split(MessageHelper.Separator), out var indeTmp, out var totalTmp)) + { + continue; + } + + count++; + } + } + + if (count != total) + { + // Ignore this message for now. We don't have all parts. + ignoreMessages.Add(s[0]); + continue; + } + + // Merge all parts of the message + // Preserve message GUID, delete the value and create response even if reading failed. + requestMessage.RequestId = s[0]; + try + { + var sb = new StringBuilder(); + for (var i = 1; i <= total; i++) + { + var value1 = (string?)regKey!.GetValue(s[0] + $"{MessageHelper.Separator}{i}{MessageHelper.Separator}{total}"); + if (value1 == null) + { + throw new InvalidOperationException($"Could not read guest message {valueName}"); + } + + sb.Append(value1); + } + + requestMessage.RequestData = sb.ToString(); + } + catch (Exception ex) + { + Logging.Logger()?.ReportError($"Could not read host message {valueName}", ex); + } + + MessageHelper.DeleteAllMessages(_registryHiveKey, _fromHostRegistryKeyPath, s[0]); + break; + } + } + } + } + catch (Exception ex) + { + Logging.Logger()?.ReportError("Could not read host message.", ex); + } + + return requestMessage; + } + + public void Dispose() + { + Dispose(true); + GC.SuppressFinalize(this); + } + + private void Dispose(bool disposing) + { + if (!_disposed) + { + if (disposing) + { + _registryKeyChangedEvent.Dispose(); + } + + _disposed = true; + } + } +} diff --git a/HyperVExtension/src/DevSetupAgent/IHostChannel.cs b/HyperVExtension/src/DevSetupAgent/IHostChannel.cs new file mode 100644 index 0000000000..14d8f7c1fd --- /dev/null +++ b/HyperVExtension/src/DevSetupAgent/IHostChannel.cs @@ -0,0 +1,16 @@ +// Copyright (c) Microsoft Corporation. +// Licensed under the MIT License. + +namespace HyperVExtension.DevSetupAgent; + +/// +/// Interface for communication channel between host and guest.. +/// +public interface IHostChannel +{ + Task WaitForMessageAsync(CancellationToken stoppingToken); + + void SendMessageAsync(IResponseMessage responseMessage, CancellationToken stoppingToken); + + void DeleteResponseMessageAsync(string responseId, CancellationToken stoppingToken); +} diff --git a/HyperVExtension/src/DevSetupAgent/IHostExtensions.cs b/HyperVExtension/src/DevSetupAgent/IHostExtensions.cs new file mode 100644 index 0000000000..110dad9458 --- /dev/null +++ b/HyperVExtension/src/DevSetupAgent/IHostExtensions.cs @@ -0,0 +1,39 @@ +// Copyright (c) Microsoft Corporation. +// Licensed under the MIT License. + +using System; +using Microsoft.Extensions.DependencyInjection; +using Microsoft.Extensions.Hosting; + +namespace HyperVExtension.DevSetupAgent; + +public static class IHostExtensions +{ + /// + /// + /// + public static T CreateInstance(this IHost host, params object[] parameters) + { + return ActivatorUtilities.CreateInstance(host.Services, parameters); + } + + /// + /// Gets the service object for the specified type, or throws an exception + /// if type was not registered. + /// + /// Service type + /// Host object + /// Service object + /// Throw an exception if the specified + /// type is not registered + public static T GetService(this IHost host) + where T : class + { + if (host.Services.GetService(typeof(T)) is not T service) + { + throw new ArgumentException($"{typeof(T)} needs to be registered in ConfigureServices."); + } + + return service; + } +} diff --git a/HyperVExtension/src/DevSetupAgent/IRegistryChannelSettings.cs b/HyperVExtension/src/DevSetupAgent/IRegistryChannelSettings.cs new file mode 100644 index 0000000000..518b5d9cdd --- /dev/null +++ b/HyperVExtension/src/DevSetupAgent/IRegistryChannelSettings.cs @@ -0,0 +1,18 @@ +// Copyright (c) Microsoft Corporation. +// Licensed under the MIT License. + +using Microsoft.Win32; + +namespace HyperVExtension.DevSetupAgent; + +/// +/// Interface providing registry channel settings. +/// +public interface IRegistryChannelSettings +{ + string FromHostRegistryKeyPath { get; } + + string ToHostRegistryKeyPath { get; } + + RegistryHive RegistryHive { get; } +} diff --git a/HyperVExtension/src/DevSetupAgent/IRequestManager.cs b/HyperVExtension/src/DevSetupAgent/IRequestManager.cs new file mode 100644 index 0000000000..03eeec1438 --- /dev/null +++ b/HyperVExtension/src/DevSetupAgent/IRequestManager.cs @@ -0,0 +1,14 @@ +// Copyright (c) Microsoft Corporation. +// Licensed under the MIT License. + +using HyperVExtension.DevSetupAgent; + +namespace HyperVExtension.DevSetupAgent; + +/// +/// Interface for request manager responsible for processing request messages from host. +/// +public interface IRequestManager +{ + void ProcessRequestMessage(IRequestMessage message, CancellationToken stoppingToken); +} diff --git a/HyperVExtension/src/DevSetupAgent/Logging.cs b/HyperVExtension/src/DevSetupAgent/Logging.cs new file mode 100644 index 0000000000..d8c87ba078 --- /dev/null +++ b/HyperVExtension/src/DevSetupAgent/Logging.cs @@ -0,0 +1,47 @@ +// Copyright (c) Microsoft Corporation. +// Licensed under the MIT License. + +using DevHome.Logging; +using Windows.Storage; + +namespace HyperVExtension.DevSetupAgent; + +public class Logging +{ + private static Logger? _logger; + + public static Logger? Logger() + { + try + { + _logger ??= new Logger("DevSetupAgent", GetLoggingOptions()); + } + catch + { + // Do nothing if logger fails. + } + + return _logger; + } + + public static Options GetLoggingOptions() + { + return new Options + { + LogFileFolderRoot = ApplicationData.Current.TemporaryFolder.Path, + LogFileName = "DevSetupAgent_{now}.log", + LogFileFolderName = "DevSetupAgent", + DebugListenerEnabled = true, +#if DEBUG + LogStdoutEnabled = true, + LogStdoutFilter = SeverityLevel.Debug, + LogFileFilter = SeverityLevel.Debug, +#else + LogStdoutEnabled = false, + LogStdoutFilter = SeverityLevel.Info, + LogFileFilter = SeverityLevel.Info, +#endif + FailFastSeverity = FailFastSeverityLevel.Critical, + }; + } +} diff --git a/HyperVExtension/src/DevSetupAgent/NativeMethods.txt b/HyperVExtension/src/DevSetupAgent/NativeMethods.txt new file mode 100644 index 0000000000..13b6a24b02 --- /dev/null +++ b/HyperVExtension/src/DevSetupAgent/NativeMethods.txt @@ -0,0 +1,13 @@ +RegNotifyChangeKeyValue +REG_NOTIFY_FILTER +CoInitializeSecurity +CoCreateInstance +CLSCTX +WIN32_ERROR +S_OK +E_FAIL +LsaEnumerateLogonSessions +LsaGetLogonSessionData +Windows.Win32.Security.Authentication.Identity.LsaFreeReturnBuffer +SECURITY_LOGON_TYPE +STATUS_SUCCESS diff --git a/HyperVExtension/src/DevSetupAgent/Program.cs b/HyperVExtension/src/DevSetupAgent/Program.cs new file mode 100644 index 0000000000..9bc8c6c5aa --- /dev/null +++ b/HyperVExtension/src/DevSetupAgent/Program.cs @@ -0,0 +1,46 @@ +// Copyright (c) Microsoft Corporation. +// Licensed under the MIT License. + +using System.Runtime.InteropServices; +using HyperVExtension.DevSetupAgent; +using Microsoft.Windows.DevHome.DevSetupEngine; +using Windows.Win32; +using Windows.Win32.Security; +using Windows.Win32.System.Com; + +unsafe +{ + // TODO: Set real security descriptor to allow access from System+Admns+Interactive Users + var hr = PInvoke.CoInitializeSecurity( + new(null), + -1, + null, + null, + RPC_C_AUTHN_LEVEL.RPC_C_AUTHN_LEVEL_DEFAULT, + RPC_C_IMP_LEVEL.RPC_C_IMP_LEVEL_IDENTIFY, + null, + EOLE_AUTHENTICATION_CAPABILITIES.EOAC_NONE); + + if (hr < 0) + { + Marshal.ThrowExceptionForHR(hr); + } +} + +var host = Host.CreateDefaultBuilder(args) + .UseWindowsService(options => + { + options.ServiceName = "DevAgent"; + }) + .ConfigureServices(services => + { + services.AddHostedService(); + services.AddSingleton(); + services.AddSingleton(); + services.AddSingleton(); + services.AddSingleton(); + services.AddSingleton(); + }) + .Build(); + +host.Run(); diff --git a/HyperVExtension/src/DevSetupAgent/Properties/PublishProfiles/win10-arm64.pubxml b/HyperVExtension/src/DevSetupAgent/Properties/PublishProfiles/win10-arm64.pubxml new file mode 100644 index 0000000000..08079c2934 --- /dev/null +++ b/HyperVExtension/src/DevSetupAgent/Properties/PublishProfiles/win10-arm64.pubxml @@ -0,0 +1,16 @@ + + + + + FileSystem + arm64 + win10-arm64 + true + False + False + True + True + + \ No newline at end of file diff --git a/HyperVExtension/src/DevSetupAgent/Properties/PublishProfiles/win10-x64.pubxml b/HyperVExtension/src/DevSetupAgent/Properties/PublishProfiles/win10-x64.pubxml new file mode 100644 index 0000000000..94861ecd4c --- /dev/null +++ b/HyperVExtension/src/DevSetupAgent/Properties/PublishProfiles/win10-x64.pubxml @@ -0,0 +1,16 @@ + + + + + FileSystem + x64 + win10-x64 + true + False + False + True + True + + \ No newline at end of file diff --git a/HyperVExtension/src/DevSetupAgent/Properties/PublishProfiles/win10-x86.pubxml b/HyperVExtension/src/DevSetupAgent/Properties/PublishProfiles/win10-x86.pubxml new file mode 100644 index 0000000000..3a63ea8fb9 --- /dev/null +++ b/HyperVExtension/src/DevSetupAgent/Properties/PublishProfiles/win10-x86.pubxml @@ -0,0 +1,16 @@ + + + + + FileSystem + x86 + win10-x86 + true + False + False + True + True + + \ No newline at end of file diff --git a/HyperVExtension/src/DevSetupAgent/Properties/launchSettings.json b/HyperVExtension/src/DevSetupAgent/Properties/launchSettings.json new file mode 100644 index 0000000000..13a7c36e3c --- /dev/null +++ b/HyperVExtension/src/DevSetupAgent/Properties/launchSettings.json @@ -0,0 +1,11 @@ +{ + "profiles": { + "DevSetupAgent": { + "commandName": "Project", + "dotnetRunMessages": true, + "environmentVariables": { + "DOTNET_ENVIRONMENT": "Development" + } + } + } +} diff --git a/HyperVExtension/src/DevSetupAgent/RegistryChannelSettings.cs b/HyperVExtension/src/DevSetupAgent/RegistryChannelSettings.cs new file mode 100644 index 0000000000..7bfc68f276 --- /dev/null +++ b/HyperVExtension/src/DevSetupAgent/RegistryChannelSettings.cs @@ -0,0 +1,22 @@ +// Copyright (c) Microsoft Corporation. +// Licensed under the MIT License. + +using Microsoft.Win32; + +namespace HyperVExtension.DevSetupAgent; + +/// +/// Registry keys provided by Hyper-V Data Exchange Service (KVP). +/// https://learn.microsoft.com/virtualization/hyper-v-on-windows/reference/integration-services#hyper-v-data-exchange-service-kvp +/// https://learn.microsoft.com/previous-versions/windows/it-pro/windows-server-2012-R2-and-2012/dn798287(v=ws.11) +/// "HKLM\SOFTWARE\Microsoft\Virtual Machine\External" contains data pushed to the guest from the host by a user +/// "HKLM\SOFTWARE\Microsoft\Virtual Machine\Guest" contains data created on the guest. This data is available to the host as non-intrinsic data. +/// +public class RegistryChannelSettings : IRegistryChannelSettings +{ + public string FromHostRegistryKeyPath => @"SOFTWARE\Microsoft\Virtual Machine\External"; + + public string ToHostRegistryKeyPath => @"SOFTWARE\Microsoft\Virtual Machine\Guest"; + + public RegistryHive RegistryHive => RegistryHive.LocalMachine; +} diff --git a/HyperVExtension/src/DevSetupAgent/RegistryWatcher.cs b/HyperVExtension/src/DevSetupAgent/RegistryWatcher.cs new file mode 100644 index 0000000000..56a3da6e0f --- /dev/null +++ b/HyperVExtension/src/DevSetupAgent/RegistryWatcher.cs @@ -0,0 +1,137 @@ +// Copyright (c) Microsoft Corporation. +// Licensed under the MIT License. + +using System.ComponentModel; +using System.Reflection; +using System.Reflection.Emit; +using System.Runtime.InteropServices; +using DevHome.Logging; +using Microsoft.Extensions.Logging; +using Microsoft.VisualBasic; +using Microsoft.Win32; +using Windows.Devices.Geolocation; +using Windows.Win32; +using Windows.Win32.Foundation; +using Windows.Win32.System.Registry; + +namespace HyperVExtension.DevSetupAgent; + +internal delegate void RegistryChangedEventHandler(); + +/// +/// Registry watcher class. +/// Utilizes RegNotifyChangeKeyValue Win32 API to watch for registry changes. +/// Calls RegistryChangedEventHandler delegate when registry change is detected. +/// +internal sealed class RegistryWatcher : IDisposable +{ + public event RegistryChangedEventHandler RegistryChanged; + + private readonly AutoResetEvent _waitEvent; + private readonly RegistryKey? _key; + private bool _started; + private bool _disposed; + + public RegistryWatcher(RegistryKey key, string keyPath, RegistryChangedEventHandler callback) + { + _waitEvent = new AutoResetEvent(true); + _key = key.CreateSubKey(keyPath); + if (_key == null) + { + Logging.Logger()?.ReportError($"Cannot open {keyPath} registry key. Error: {Marshal.GetLastWin32Error()}"); + throw new Win32Exception(Marshal.GetLastWin32Error()); + } + + RegistryChanged += callback; + + Logging.Logger()?.ReportInfo("Registry Watcher created."); + } + + public void Start() + { + lock (this) + { + if (!_started) + { + _started = true; + _waitEvent.Reset(); + Task.Run(() => + { + try + { + while (_started) + { + var notifyFilter = REG_NOTIFY_FILTER.REG_NOTIFY_CHANGE_LAST_SET | + REG_NOTIFY_FILTER.REG_NOTIFY_THREAD_AGNOSTIC; + var result = PInvoke.RegNotifyChangeKeyValue(_key!.Handle, true, notifyFilter, _waitEvent.SafeWaitHandle, true); + if (result != WIN32_ERROR.ERROR_SUCCESS) + { + throw new Win32Exception((int)result); + } + + _waitEvent.WaitOne(); + + if (_started) + { + try + { + RegistryChanged(); + } + catch (Exception ex) + { + Logging.Logger()?.ReportError("RegistryChanged delegate failed.", ex); + } + } + } + } + catch (Exception ex) + { + Logging.Logger()?.ReportError("Registry Watcher thread failed.", ex); + } + }); + Logging.Logger()?.ReportInfo("Registry Watcher thread started."); + } + } + } + + public void Stop() + { + lock (this) + { + if (_started) + { + _started = false; + _waitEvent.Set(); + Logging.Logger()?.ReportInfo("Registry Watcher thread stopped."); + } + } + } + + public void WaitForRegistryChange() + { + Logging.Logger()?.ReportInfo("Waiting for registry change."); + _waitEvent.WaitOne(); + Logging.Logger()?.ReportInfo("Registry change detected."); + RegistryChanged?.Invoke(); + } + + public void Dispose() + { + Dispose(true); + GC.SuppressFinalize(this); + } + + private void Dispose(bool disposing) + { + if (!_disposed) + { + if (disposing) + { + _key?.Dispose(); + _waitEvent.Dispose(); + } + + _disposed = true; + } + } +} diff --git a/HyperVExtension/src/DevSetupAgent/RequestManager.cs b/HyperVExtension/src/DevSetupAgent/RequestManager.cs new file mode 100644 index 0000000000..660c679f92 --- /dev/null +++ b/HyperVExtension/src/DevSetupAgent/RequestManager.cs @@ -0,0 +1,108 @@ +// Copyright (c) Microsoft Corporation. +// Licensed under the MIT License. + +using static System.Reflection.Metadata.BlobBuilder; + +namespace HyperVExtension.DevSetupAgent; + +/// +/// Request manager responsible for processing request messages from host. +/// +public class RequestManager : IRequestManager +{ + private const uint MaxRequestQueueSize = 3; + private readonly IRequestFactory _requestFactory; + private readonly IHostChannel _hostChannel; + private readonly Queue _requestQueue = new(); + private bool _asyncRequestRunning; + + public RequestManager(IRequestFactory requestFactory, IHostChannel hostChannel) + { + _requestFactory = requestFactory; + _hostChannel = hostChannel; + } + + private void ProgressHandler(IHostResponse progressResponse, CancellationToken stoppingToken) + { + _hostChannel.SendMessageAsync(progressResponse.GetResponseMessage(), stoppingToken); + } + + public void ProcessRequestMessage(IRequestMessage message, CancellationToken stoppingToken) + { + if (!string.IsNullOrEmpty(message.RequestId)) + { + var requestContext = new RequestContext(message, _hostChannel); + var request = _requestFactory.CreateRequest(requestContext); + if (request.IsStatusRequest) + { + // Status requests (like GetVersion) execute immediately and return response. + var response = request.Execute(ProgressHandler, stoppingToken); + if (response.SendResponse) + { + _hostChannel.SendMessageAsync(response.GetResponseMessage(), stoppingToken); + } + } + else + { + // Non-status request are queued and executed async in order one at a time. + int queueCount; + lock (_requestQueue) + { + queueCount = _requestQueue.Count; + } + + if (queueCount > MaxRequestQueueSize) + { + Logging.Logger()?.ReportError($"Too many requests."); + var response = new TooManyRequestsResponse(message.RequestId); + _hostChannel.SendMessageAsync(response.GetResponseMessage(), stoppingToken); + return; + } + + lock (_requestQueue) + { + // TODO: send response indicating that request is queued. + _requestQueue.Enqueue(request); + if (!_asyncRequestRunning) + { + _asyncRequestRunning = true; + Task.Run(() => ProcessRequestQueue(stoppingToken), stoppingToken); + } + } + } + } + else + { + // Shouldn't happen, Log error + Logging.Logger()?.ReportError($"Received empty message."); + } + } + + private void ProcessRequestQueue(CancellationToken stoppingToken) + { + while (!stoppingToken.IsCancellationRequested) + { + IHostRequest request; + lock (_requestQueue) + { + if (_requestQueue.Count == 0) + { + _asyncRequestRunning = false; + break; + } + + request = _requestQueue.Dequeue(); + } + + try + { + var response = request.Execute(ProgressHandler, stoppingToken); + _hostChannel.SendMessageAsync(response.GetResponseMessage(), stoppingToken); + } + catch (Exception ex) + { + Logging.Logger()?.ReportError($"Failed to execute request.", ex); + } + } + } +} diff --git a/HyperVExtension/src/DevSetupAgent/Requests/AckRequest.cs b/HyperVExtension/src/DevSetupAgent/Requests/AckRequest.cs new file mode 100644 index 0000000000..07a3210404 --- /dev/null +++ b/HyperVExtension/src/DevSetupAgent/Requests/AckRequest.cs @@ -0,0 +1,34 @@ +// Copyright (c) Microsoft Corporation. +// Licensed under the MIT License. + +using System.Text.Json.Nodes; + +namespace HyperVExtension.DevSetupAgent; + +/// +/// Class used to handle request for service version (RequestType = GetVersion). +/// +internal sealed class AckRequest : RequestBase +{ + public AckRequest(IRequestContext requestContext) + : base(requestContext) + { + AckRequestId = GetRequiredStringValue(nameof(AckRequestId)); + } + + private string AckRequestId { get; } + + public override bool IsStatusRequest => true; + + public override IHostResponse Execute(ProgressHandler progressHandler, CancellationToken stoppingToken) + { + Task.Run( + () => + { + RequestContext.HostChannel.DeleteResponseMessageAsync(AckRequestId, stoppingToken); + }, + stoppingToken); + + return new AckResponse(RequestMessage.RequestId!); + } +} diff --git a/HyperVExtension/src/DevSetupAgent/Requests/ConfigureRequest.cs b/HyperVExtension/src/DevSetupAgent/Requests/ConfigureRequest.cs new file mode 100644 index 0000000000..8f8b699055 --- /dev/null +++ b/HyperVExtension/src/DevSetupAgent/Requests/ConfigureRequest.cs @@ -0,0 +1,71 @@ +// Copyright (c) Microsoft Corporation. +// Licensed under the MIT License. + +using System.Runtime.InteropServices; +using System.Text.Json.Nodes; +using Microsoft.Windows.DevHome.DevSetupEngine; +using Windows.Win32; +using Windows.Win32.System.Com; +using WinRT; + +namespace HyperVExtension.DevSetupAgent; + +/// +/// WinGet Configure request from the client. +/// JSON payload is converted to request properties. +/// { +/// "RequestId": "DevSetup{10000000-1000-1000-1000-100000000000}", +/// "RequestType": "GetVersion", +/// "Timestamp":"2023-11-21T08:08:58.6287789Z" +/// "Configure": +/// } +/// +internal sealed class ConfigureRequest : RequestBase +{ + public ConfigureRequest(IRequestContext requestContext) + : base(requestContext) + { + ConfigureData = GetRequiredStringValue("Configure").Replace("\\n", System.Environment.NewLine); + } + + public string ConfigureData { get; } + + public override IHostResponse Execute(ProgressHandler progressHandler, CancellationToken stoppingToken) + { + // DevSetupEngine needs to be started manually from command line in the test. + var devSetupEnginePtr = IntPtr.Zero; + try + { + var hr = PInvoke.CoCreateInstance(Guid.Parse("82E86C64-A8B9-44F9-9323-C37982F2D8BE"), null, CLSCTX.CLSCTX_LOCAL_SERVER, typeof(IDevSetupEngine).GUID, out var devSetupEngineObj); + if (hr < 0) + { + Marshal.ThrowExceptionForHR(hr); + } + + devSetupEnginePtr = Marshal.GetIUnknownForObject(devSetupEngineObj); + + var devSetupEngine = MarshalInterface.FromAbi(devSetupEnginePtr); + var operation = devSetupEngine.ApplyConfigurationAsync(ConfigureData); + + uint progressCounter = 0; + operation.Progress = (operation, data) => + { + System.Diagnostics.Trace.WriteLine($" - Unit: {data.Unit.Type} [{data.UnitState}]"); + var progressResponse = new ProgressResponse(RequestMessage.RequestId!, data, ++progressCounter); + progressHandler(progressResponse, stoppingToken); + }; + + operation.AsTask().Wait(stoppingToken); + var result = operation.GetResults(); + + return new ConfigureResponse(RequestMessage.RequestId!, result); + } + finally + { + if (devSetupEnginePtr != IntPtr.Zero) + { + Marshal.Release(devSetupEnginePtr); + } + } + } +} diff --git a/HyperVExtension/src/DevSetupAgent/Requests/ErrorNoTypeRequest.cs b/HyperVExtension/src/DevSetupAgent/Requests/ErrorNoTypeRequest.cs new file mode 100644 index 0000000000..10eda12867 --- /dev/null +++ b/HyperVExtension/src/DevSetupAgent/Requests/ErrorNoTypeRequest.cs @@ -0,0 +1,24 @@ +// Copyright (c) Microsoft Corporation. +// Licensed under the MIT License. + +using System.Text.Json.Nodes; + +namespace HyperVExtension.DevSetupAgent; + +/// +/// Class used to handle requests that have no request type. It creates an error response to send back to the client. +/// +internal sealed class ErrorNoTypeRequest : ErrorRequest +{ + public ErrorNoTypeRequest(IRequestMessage requestMessage) + : base(requestMessage) + { + } + + public override string RequestType => "ErrorNoType"; + + public override IHostResponse Execute(ProgressHandler progressHandler, CancellationToken stoppingToken) + { + return new ErrorNoTypeResponse(RequestId); + } +} diff --git a/HyperVExtension/src/DevSetupAgent/Requests/ErrorRequest.cs b/HyperVExtension/src/DevSetupAgent/Requests/ErrorRequest.cs new file mode 100644 index 0000000000..dfe9ea8d29 --- /dev/null +++ b/HyperVExtension/src/DevSetupAgent/Requests/ErrorRequest.cs @@ -0,0 +1,34 @@ +// Copyright (c) Microsoft Corporation. +// Licensed under the MIT License. + +using System.Text.Json.Nodes; + +namespace HyperVExtension.DevSetupAgent; + +/// +/// Class used to handle invalid requests (for example an exception while parsing request JSON). +/// It creates an error response to send back to the client. +/// +internal class ErrorRequest : IHostRequest +{ + public ErrorRequest(IRequestMessage requestMessage) + { + Timestamp = DateTime.UtcNow; + RequestId = requestMessage.RequestId!; + } + + public virtual uint Version { get; set; } = 1; + + public virtual bool IsStatusRequest => true; + + public virtual string RequestId { get; } + + public virtual string RequestType => "ErrorNoData"; + + public DateTime Timestamp { get; } + + public virtual IHostResponse Execute(ProgressHandler progressHandler, CancellationToken stoppingToken) + { + return new ErrorResponse(RequestId); + } +} diff --git a/HyperVExtension/src/DevSetupAgent/Requests/ErrorUnsupportedRequest.cs b/HyperVExtension/src/DevSetupAgent/Requests/ErrorUnsupportedRequest.cs new file mode 100644 index 0000000000..867f0f5a67 --- /dev/null +++ b/HyperVExtension/src/DevSetupAgent/Requests/ErrorUnsupportedRequest.cs @@ -0,0 +1,24 @@ +// Copyright (c) Microsoft Corporation. +// Licensed under the MIT License. + +using System.Text.Json.Nodes; + +namespace HyperVExtension.DevSetupAgent; + +/// +/// Class used to handle unsupported requests. +/// +internal sealed class ErrorUnsupportedRequest : RequestBase +{ + public ErrorUnsupportedRequest(IRequestContext requestContext) + : base(requestContext) + { + } + + public override bool IsStatusRequest => true; + + public override IHostResponse Execute(ProgressHandler progressHandler, CancellationToken stoppingToken) + { + return new ErrorUnsupportedRequestResponse(RequestId, RequestType); + } +} diff --git a/HyperVExtension/src/DevSetupAgent/Requests/GetVersionRequest.cs b/HyperVExtension/src/DevSetupAgent/Requests/GetVersionRequest.cs new file mode 100644 index 0000000000..d7fc304f9c --- /dev/null +++ b/HyperVExtension/src/DevSetupAgent/Requests/GetVersionRequest.cs @@ -0,0 +1,24 @@ +// Copyright (c) Microsoft Corporation. +// Licensed under the MIT License. + +using System.Text.Json.Nodes; + +namespace HyperVExtension.DevSetupAgent; + +/// +/// Class used to handle request for service version (RequestType = GetVersion). +/// +internal sealed class GetVersionRequest : RequestBase +{ + public GetVersionRequest(IRequestContext requestContext) + : base(requestContext) + { + } + + public override bool IsStatusRequest => true; + + public override IHostResponse Execute(ProgressHandler progressHandler, CancellationToken stoppingToken) + { + return new GetVersionResponse(RequestMessage.RequestId!); + } +} diff --git a/HyperVExtension/src/DevSetupAgent/Requests/IHostRequest.cs b/HyperVExtension/src/DevSetupAgent/Requests/IHostRequest.cs new file mode 100644 index 0000000000..d4d8f51db9 --- /dev/null +++ b/HyperVExtension/src/DevSetupAgent/Requests/IHostRequest.cs @@ -0,0 +1,24 @@ +// Copyright (c) Microsoft Corporation. +// Licensed under the MIT License. + +namespace HyperVExtension.DevSetupAgent; + +public delegate void ProgressHandler(IHostResponse progressResponse, CancellationToken stoppingToken); + +/// +/// Interface for handling requests from client (host machine). +/// +public interface IHostRequest +{ + bool IsStatusRequest { get; } + + string RequestId { get; } + + string RequestType { get; } + + uint Version { get; set; } + + DateTime Timestamp { get; } + + IHostResponse Execute(ProgressHandler progressHandler, CancellationToken stoppingToken); +} diff --git a/HyperVExtension/src/DevSetupAgent/Requests/IRequestContext.cs b/HyperVExtension/src/DevSetupAgent/Requests/IRequestContext.cs new file mode 100644 index 0000000000..15fb38d81b --- /dev/null +++ b/HyperVExtension/src/DevSetupAgent/Requests/IRequestContext.cs @@ -0,0 +1,18 @@ +// Copyright (c) Microsoft Corporation. +// Licensed under the MIT License. + +using System.Text.Json.Nodes; + +namespace HyperVExtension.DevSetupAgent; + +/// +/// Interface for creating request handler based on request message. +/// +public interface IRequestContext +{ + IRequestMessage RequestMessage { get; set; } + + IHostChannel HostChannel { get; set; } + + JsonNode? JsonData { get; set; } +} diff --git a/HyperVExtension/src/DevSetupAgent/Requests/IRequestFactory.cs b/HyperVExtension/src/DevSetupAgent/Requests/IRequestFactory.cs new file mode 100644 index 0000000000..5050b79424 --- /dev/null +++ b/HyperVExtension/src/DevSetupAgent/Requests/IRequestFactory.cs @@ -0,0 +1,12 @@ +// Copyright (c) Microsoft Corporation. +// Licensed under the MIT License. + +namespace HyperVExtension.DevSetupAgent; + +/// +/// Interface for creating request handler based on request message. +/// +public interface IRequestFactory +{ + IHostRequest CreateRequest(IRequestContext requestContext); +} diff --git a/HyperVExtension/src/DevSetupAgent/Requests/IRequestMessage.cs b/HyperVExtension/src/DevSetupAgent/Requests/IRequestMessage.cs new file mode 100644 index 0000000000..84af15f5e6 --- /dev/null +++ b/HyperVExtension/src/DevSetupAgent/Requests/IRequestMessage.cs @@ -0,0 +1,14 @@ +// Copyright (c) Microsoft Corporation. +// Licensed under the MIT License. + +namespace HyperVExtension.DevSetupAgent; + +/// +/// Interface for providing request message data. +/// +public interface IRequestMessage +{ + string? RequestId { get; set; } + + string? RequestData { get; set; } +} diff --git a/HyperVExtension/src/DevSetupAgent/Requests/IsUserLoggedInRequest.cs b/HyperVExtension/src/DevSetupAgent/Requests/IsUserLoggedInRequest.cs new file mode 100644 index 0000000000..3419e5ff8e --- /dev/null +++ b/HyperVExtension/src/DevSetupAgent/Requests/IsUserLoggedInRequest.cs @@ -0,0 +1,120 @@ +// Copyright (c) Microsoft Corporation. +// Licensed under the MIT License. + +using System; +using System.Diagnostics; +using System.Security.Principal; +using System.Text.Json.Nodes; +using Windows.Win32; +using Windows.Win32.Foundation; +using Windows.Win32.Security.Authentication.Identity; + +namespace HyperVExtension.DevSetupAgent; + +/// +/// Class used to handle request for service version (RequestType = GetVersion). +/// +internal sealed class IsUserLoggedInRequest : RequestBase +{ + public IsUserLoggedInRequest(IRequestContext requestContext) + : base(requestContext) + { + } + + public override bool IsStatusRequest => true; + + public override IHostResponse Execute(ProgressHandler progressHandler, CancellationToken stoppingToken) + { + var loggedInUsers = EnumerateLogonSessions(); + return new IsUserLoggedInResponse(RequestMessage.RequestId!, loggedInUsers); + } + + private static List EnumerateLogonSessions() + { + // We'll take interactive sessions where explorer is running to filter out stale sessions + var interactiveSessions = new List(); + + var explorers = Process.GetProcessesByName("explorer"); + if (explorers.Length == 0) + { + return interactiveSessions; + } + + unsafe + { + var sessions = new List<(uint, string)>(); // (SessionId, UserName) + LUID* luidPtr = default; + SECURITY_LOGON_SESSION_DATA* sessionData = default; + try + { + uint count; + var status = PInvoke.LsaEnumerateLogonSessions(out count, out luidPtr); + if (status != NTSTATUS.STATUS_SUCCESS) + { + throw new NtStatusException("LsaEnumerateLogonSessions failed.", status); + } + + Logging.Logger()?.ReportDebug($"Number of logon sessions: {count}"); + for (var i = 0; i < count; i++) + { + var luid = luidPtr[i]; + status = PInvoke.LsaGetLogonSessionData(luid, out sessionData); + if (status != NTSTATUS.STATUS_SUCCESS) + { + throw new NtStatusException("LsaGetLogonSessionData failed.", status); + } + + if (sessionData->Session > 0) + { + switch (sessionData->LogonType) + { + case (uint)SECURITY_LOGON_TYPE.Interactive: + case (uint)SECURITY_LOGON_TYPE.RemoteInteractive: + case (uint)SECURITY_LOGON_TYPE.CachedRemoteInteractive: + var sid = new SecurityIdentifier((IntPtr)sessionData->Sid.Value); + Logging.Logger()?.ReportDebug( + $"Logged on user: {sessionData->UserName.Buffer}, " + + $"Domain: {sessionData->LogonDomain.Buffer}, " + + $"Session: {sessionData->Session}, " + + $"Logon type: {sessionData->LogonType}, " + + $"SID: {sid}, " + + $"UserFlags: {sessionData->UserFlags}"); + + sessions.Add((sessionData->Session, new string(sessionData->UserName.Buffer))); + break; + + default: + break; + } + } + + PInvoke.LsaFreeReturnBuffer(sessionData); + sessionData = default; + } + + // We'll take interactive sessions where explorer is running to filter out stale sessions + var interactiveSessionsWithExplorer = sessions.Where(s => explorers.Any(e => (uint)e.SessionId == s.Item1)); + foreach (var session in interactiveSessionsWithExplorer) + { + // TODO: We get OS user names like "DWM-2" or "UMFD-2" into this list. We need to filter them out. + Logging.Logger()?.ReportDebug($"Logged on user: {session.Item2}, Session: {session.Item1}"); + interactiveSessions.Add(session.Item2); + } + } + finally + { + if (sessionData != default) + { + PInvoke.LsaFreeReturnBuffer(sessionData); + } + + if (luidPtr != default) + { + PInvoke.LsaFreeReturnBuffer(luidPtr); + } + } + } + + return interactiveSessions; + } +} diff --git a/HyperVExtension/src/DevSetupAgent/Requests/NtStatusException.cs b/HyperVExtension/src/DevSetupAgent/Requests/NtStatusException.cs new file mode 100644 index 0000000000..cd6023a192 --- /dev/null +++ b/HyperVExtension/src/DevSetupAgent/Requests/NtStatusException.cs @@ -0,0 +1,31 @@ +// Copyright (c) Microsoft Corporation. +// Licensed under the MIT License. + +using System.Runtime.Serialization; + +namespace HyperVExtension.DevSetupAgent; + +internal sealed class NtStatusException : Exception +{ + public NtStatusException() + { + } + + public NtStatusException(string? message) + : base(message) + { + } + + public NtStatusException(string? message, Exception? innerException) + : base(message, innerException) + { + } + + public NtStatusException(string? message, int ntStatus) + : base(message) + { + // NTStatus is not an HRESULT, but we will uonly use it to pass error back to the caller + // for diagnostic. Conversion to HRESULT can be done in more that one way and can be not 1 to 1 mapping anyway + HResult = ntStatus; + } +} diff --git a/HyperVExtension/src/DevSetupAgent/Requests/RequestBase.cs b/HyperVExtension/src/DevSetupAgent/Requests/RequestBase.cs new file mode 100644 index 0000000000..aaab9e8681 --- /dev/null +++ b/HyperVExtension/src/DevSetupAgent/Requests/RequestBase.cs @@ -0,0 +1,88 @@ +// Copyright (c) Microsoft Corporation. +// Licensed under the MIT License. + +using System.Text.Json.Nodes; + +namespace HyperVExtension.DevSetupAgent; + +/// +/// Base class for requests from the client. +/// JSON payload is converted to request properties. +/// { +/// "RequestId": "DevSetup{10000000-1000-1000-1000-100000000000}", +/// "RequestType": "GetVersion", +/// "Timestamp":"2023-11-21T08:08:58.6287789Z" +/// +/// } +/// +internal abstract class RequestBase : IHostRequest +{ + public RequestBase(IRequestContext requestContext) + { + RequestContext = requestContext; + RequestId = GetRequiredStringValue(nameof(RequestId)); + RequestType = GetRequiredStringValue(nameof(RequestType)); + Version = GetRequiredUintValue(nameof(Version)); + Timestamp = GetRequiredDateTimeValue(nameof(Timestamp)); + } + + protected IRequestContext RequestContext { get; } + + public virtual bool IsStatusRequest => false; + + public virtual string RequestId { get; } + + public virtual string RequestType { get; } + + public virtual uint Version { get; set; } + + public virtual DateTime Timestamp { get; } + + public abstract IHostResponse Execute(ProgressHandler progressHandler, CancellationToken stoppingToken); + + public IRequestMessage RequestMessage => RequestContext.RequestMessage; + + public JsonNode JsonData => RequestContext.JsonData!; + + protected string GetRequiredStringValue(string valueName) + { + try + { + var value = (string?)JsonData[valueName]; + if (string.IsNullOrEmpty(value)) + { + throw new ArgumentException($"{valueName} cannot be empty."); + } + + return value; + } + catch (Exception ex) + { + throw new ArgumentException($"{valueName} cannot be empty.", ex); + } + } + + protected DateTime GetRequiredDateTimeValue(string valueName) + { + try + { + return (DateTime?)JsonData[valueName] ?? throw new ArgumentException($"{valueName} cannot be empty."); + } + catch (Exception ex) + { + throw new ArgumentException($"{valueName} cannot be empty.", ex); + } + } + + protected uint GetRequiredUintValue(string valueName) + { + try + { + return (uint?)JsonData[valueName] ?? throw new ArgumentException($"{valueName} cannot be empty."); + } + catch (Exception ex) + { + throw new ArgumentException($"{valueName} cannot be empty.", ex); + } + } +} diff --git a/HyperVExtension/src/DevSetupAgent/Requests/RequestContext.cs b/HyperVExtension/src/DevSetupAgent/Requests/RequestContext.cs new file mode 100644 index 0000000000..a8bfc26524 --- /dev/null +++ b/HyperVExtension/src/DevSetupAgent/Requests/RequestContext.cs @@ -0,0 +1,33 @@ +// Copyright (c) Microsoft Corporation. +// Licensed under the MIT License. + +using System.Text.Json.Nodes; + +namespace HyperVExtension.DevSetupAgent; + +/// +/// Helper class to hold the request context. +/// +internal sealed class RequestContext : IRequestContext +{ + public RequestContext(IRequestMessage requestMessage, IHostChannel channel) + { + RequestMessage = requestMessage; + HostChannel = channel; + } + + public IRequestMessage RequestMessage + { + get; set; + } + + public IHostChannel HostChannel + { + get; set; + } + + public JsonNode? JsonData + { + get; set; + } +} diff --git a/HyperVExtension/src/DevSetupAgent/Requests/RequestFactory.cs b/HyperVExtension/src/DevSetupAgent/Requests/RequestFactory.cs new file mode 100644 index 0000000000..5a8ef75383 --- /dev/null +++ b/HyperVExtension/src/DevSetupAgent/Requests/RequestFactory.cs @@ -0,0 +1,72 @@ +// Copyright (c) Microsoft Corporation. +// Licensed under the MIT License. + +using System.ServiceModel.Channels; +using System.Text.Json; +using System.Text.Json.Nodes; +using Windows.Storage; + +namespace HyperVExtension.DevSetupAgent; + +/// +/// Factory class for creating request handler based on request message. +/// +public class RequestFactory : IRequestFactory +{ + public delegate IHostRequest CreateRequestDelegate(IRequestContext requestContext); + + private static readonly Dictionary _requestFactories = new() + { + // TODO: Define request type constants in one place + { "GetVersion", (requestContext) => new GetVersionRequest(requestContext) }, + { "Configure", (requestContext) => new ConfigureRequest(requestContext) }, + { "Ack", (requestContext) => new AckRequest(requestContext) }, + { "IsUserLoggedIn", (requestContext) => new IsUserLoggedInRequest(requestContext) }, + }; + + public RequestFactory() + { + } + + public IHostRequest CreateRequest(IRequestContext requestContext) + { + // Parse message.RequestData and create appropriate request object + try + { + if (!string.IsNullOrEmpty(requestContext.RequestMessage.RequestData)) + { + Logging.Logger()?.ReportInfo($"Received message: ID: '{requestContext.RequestMessage.RequestId}' Data: '{requestContext.RequestMessage.RequestData}'"); + var requestJson = JsonNode.Parse(requestContext.RequestMessage.RequestData); + var requestType = (string?)requestJson?["RequestType"]; + if (requestType != null) + { + if (_requestFactories.TryGetValue(requestType, out var createRequest)) + { + // TODO: Try/catch error. + requestContext.JsonData = requestJson!; + return createRequest(requestContext); + } + else + { + return new ErrorUnsupportedRequest(requestContext); + } + } + + return new ErrorNoTypeRequest(requestContext.RequestMessage); + } + else + { + // We have message id but no data, log error. Send error response. + Logging.Logger()?.ReportInfo($"Received message with empty data: ID: {requestContext.RequestMessage.RequestId}"); + return new ErrorRequest(requestContext.RequestMessage); + } + } + catch (Exception ex) + { + var messageId = requestContext.RequestMessage.RequestId ?? ""; + var requestData = requestContext.RequestMessage.RequestData ?? ""; + Logging.Logger()?.ReportError($"Error processing message. Message ID: {messageId}. Request data: {requestData}", ex); + return new ErrorRequest(requestContext.RequestMessage); + } + } +} diff --git a/HyperVExtension/src/DevSetupAgent/Requests/RequestMessage.cs b/HyperVExtension/src/DevSetupAgent/Requests/RequestMessage.cs new file mode 100644 index 0000000000..938d735ef9 --- /dev/null +++ b/HyperVExtension/src/DevSetupAgent/Requests/RequestMessage.cs @@ -0,0 +1,14 @@ +// Copyright (c) Microsoft Corporation. +// Licensed under the MIT License. + +namespace HyperVExtension.DevSetupAgent; + +/// +/// Request message data. +/// +internal struct RequestMessage : IRequestMessage +{ + public string? RequestId { get; set; } + + public string? RequestData { get; set; } +} diff --git a/HyperVExtension/src/DevSetupAgent/Responses/AckResponse.cs b/HyperVExtension/src/DevSetupAgent/Responses/AckResponse.cs new file mode 100644 index 0000000000..4e75fe9aca --- /dev/null +++ b/HyperVExtension/src/DevSetupAgent/Responses/AckResponse.cs @@ -0,0 +1,18 @@ +// Copyright (c) Microsoft Corporation. +// Licensed under the MIT License. + +namespace HyperVExtension.DevSetupAgent; + +/// +/// Special dummy response class that doesn't generate any data to return. +/// AckRequest is used to acknowledge the receipt of a request and delete sent messages. +/// Nothing to send back in this case. +/// +internal sealed class AckResponse : ResponseBase +{ + public AckResponse(string requestId) + : base(requestId, "Ack") + { + SendResponse = false; + } +} diff --git a/HyperVExtension/src/DevSetupAgent/Responses/ConfigureResponse.cs b/HyperVExtension/src/DevSetupAgent/Responses/ConfigureResponse.cs new file mode 100644 index 0000000000..df029dbcd5 --- /dev/null +++ b/HyperVExtension/src/DevSetupAgent/Responses/ConfigureResponse.cs @@ -0,0 +1,75 @@ +// Copyright (c) Microsoft Corporation. +// Licensed under the MIT License. + +using System.Text.Json; +using System.Text.Json.Nodes; +using HyperVExtension.HostGuestCommunication; +using Microsoft.Windows.DevHome.DevSetupEngine; + +namespace HyperVExtension.DevSetupAgent; + +/// +/// Class to generate JSON data for Configure command responses. +/// +/// { +/// "ResponseId": "DevSetup{10000000-1000-1000-1000-100000000000}", +/// "ResponseType": "Configure", +/// "Timestamp":"2023-11-21T08:08:58.6287789Z", +/// "ApplyConfigurationResult": +/// { +/// "OpenConfigurationSetResult": +/// { +/// "ResultCode":"0", +/// "Field":"MyField", +/// "Value":"MyValue", +/// "Line":"2", +/// "Column":"5" +/// }, +/// "ApplyConfigurationSetResult" +/// { +/// "ResultCode":"0", +/// "UnitResults": +/// [ +/// { +/// "Unit": +/// { +/// "UnitName":"OsVersion", +/// "Identifier":"10000000-1000-1000-1000-100005550033", +/// "State":"Completed", +/// "IsGroup":"false", +/// "Units":[] +/// }, +/// "PreviouslyInDesiredState":"false", +/// "RebootRequired":"false", +/// "ResultInformation": +/// { +/// "ResultCode":"0", +/// "Description":"Error description", +/// "Details":"More Error Details", +/// "ResultSource":"UnitProcessing" +/// } +/// } +/// ] +/// } +/// } +/// } +/// +internal sealed class ConfigureResponse : ResponseBase +{ + private readonly ApplyConfigurationResult _applyConfigurationResult; + + public ConfigureResponse(string requestId, IApplyConfigurationResult applyConfigurationResult) + : base(requestId, "Configure") + { + _applyConfigurationResult = new ApplyConfigurationResult().Populate(applyConfigurationResult); + GenerateJsonData(); + } + + protected override void GenerateJsonData() + { + base.GenerateJsonData(); + + var result = JsonSerializer.Serialize(_applyConfigurationResult); + JsonData![nameof(ApplyConfigurationResult)] = result; + } +} diff --git a/HyperVExtension/src/DevSetupAgent/Responses/ErrorNoTypeResponse.cs b/HyperVExtension/src/DevSetupAgent/Responses/ErrorNoTypeResponse.cs new file mode 100644 index 0000000000..59dc66771a --- /dev/null +++ b/HyperVExtension/src/DevSetupAgent/Responses/ErrorNoTypeResponse.cs @@ -0,0 +1,19 @@ +// Copyright (c) Microsoft Corporation. +// Licensed under the MIT License. + +namespace HyperVExtension.DevSetupAgent; + +/// +/// Class used to handle requests that have no request type. +/// It creates an error response JSON to send back to the client. +/// +internal sealed class ErrorNoTypeResponse : ResponseBase +{ + public ErrorNoTypeResponse(string requestId) + : base(requestId) + { + Status = Windows.Win32.Foundation.HRESULT.E_FAIL; + ErrorDescription = "Missing Request type."; + GenerateJsonData(); + } +} diff --git a/HyperVExtension/src/DevSetupAgent/Responses/ErrorResponse.cs b/HyperVExtension/src/DevSetupAgent/Responses/ErrorResponse.cs new file mode 100644 index 0000000000..4428ebf76e --- /dev/null +++ b/HyperVExtension/src/DevSetupAgent/Responses/ErrorResponse.cs @@ -0,0 +1,19 @@ +// Copyright (c) Microsoft Corporation. +// Licensed under the MIT License. + +namespace HyperVExtension.DevSetupAgent; + +/// +/// Class used to handle requests that have no request type. +/// It creates an error response JSON to send back to the client. +/// +internal sealed class ErrorResponse : ResponseBase +{ + public ErrorResponse(string requestId) + : base(requestId) + { + Status = Windows.Win32.Foundation.HRESULT.E_FAIL; + ErrorDescription = "Missing Request data."; + GenerateJsonData(); + } +} diff --git a/HyperVExtension/src/DevSetupAgent/Responses/ErrorUnsupportedRequestResponse.cs b/HyperVExtension/src/DevSetupAgent/Responses/ErrorUnsupportedRequestResponse.cs new file mode 100644 index 0000000000..c0e0331f13 --- /dev/null +++ b/HyperVExtension/src/DevSetupAgent/Responses/ErrorUnsupportedRequestResponse.cs @@ -0,0 +1,19 @@ +// Copyright (c) Microsoft Corporation. +// Licensed under the MIT License. + +namespace HyperVExtension.DevSetupAgent; + +/// +/// Class used to handle requests that have unsupported request type. +/// It creates an error response JSON to send back to the client. +/// +internal sealed class ErrorUnsupportedRequestResponse : ResponseBase +{ + public ErrorUnsupportedRequestResponse(string requestId, string requestType) + : base(requestId, requestType) + { + Status = Windows.Win32.Foundation.HRESULT.E_FAIL; + ErrorDescription = "Missing Request type."; + GenerateJsonData(); + } +} diff --git a/HyperVExtension/src/DevSetupAgent/Responses/GetVersionResponse.cs b/HyperVExtension/src/DevSetupAgent/Responses/GetVersionResponse.cs new file mode 100644 index 0000000000..002de1ee37 --- /dev/null +++ b/HyperVExtension/src/DevSetupAgent/Responses/GetVersionResponse.cs @@ -0,0 +1,17 @@ +// Copyright (c) Microsoft Corporation. +// Licensed under the MIT License. + +namespace HyperVExtension.DevSetupAgent; + +/// +/// Class to generate response to GetVersion request. +/// +internal sealed class GetVersionResponse : ResponseBase +{ + public GetVersionResponse(string requestId) + : base(requestId) + { + RequestType = "GetVersion"; + GenerateJsonData(); + } +} diff --git a/HyperVExtension/src/DevSetupAgent/Responses/IHostResponse.cs b/HyperVExtension/src/DevSetupAgent/Responses/IHostResponse.cs new file mode 100644 index 0000000000..dd27742c1b --- /dev/null +++ b/HyperVExtension/src/DevSetupAgent/Responses/IHostResponse.cs @@ -0,0 +1,30 @@ +// Copyright (c) Microsoft Corporation. +// Licensed under the MIT License. + +namespace HyperVExtension.DevSetupAgent; + +/// +/// Interface for creating response to host request. +/// +public interface IHostResponse +{ + string RequestId { get; set; } + + string RequestType { get; set; } + + string ResponseId { get; set; } + + string ResponseType { get; set; } + + uint Status { get; set; } + + string ErrorDescription { get; set; } + + uint Version { get; set; } + + DateTime Timestamp { get; set; } + + IResponseMessage GetResponseMessage(); + + bool SendResponse { get; set; } +} diff --git a/HyperVExtension/src/DevSetupAgent/Responses/IResponseMessage.cs b/HyperVExtension/src/DevSetupAgent/Responses/IResponseMessage.cs new file mode 100644 index 0000000000..595bf5ed55 --- /dev/null +++ b/HyperVExtension/src/DevSetupAgent/Responses/IResponseMessage.cs @@ -0,0 +1,14 @@ +// Copyright (c) Microsoft Corporation. +// Licensed under the MIT License. + +namespace HyperVExtension.DevSetupAgent; + +/// +/// Interface for providing response message data. +/// +public interface IResponseMessage +{ + string ResponseId { get; set; } + + string ResponseData { get; set; } +} diff --git a/HyperVExtension/src/DevSetupAgent/Responses/IsUserLoggedInResponse.cs b/HyperVExtension/src/DevSetupAgent/Responses/IsUserLoggedInResponse.cs new file mode 100644 index 0000000000..bd8305ca14 --- /dev/null +++ b/HyperVExtension/src/DevSetupAgent/Responses/IsUserLoggedInResponse.cs @@ -0,0 +1,32 @@ +// Copyright (c) Microsoft Corporation. +// Licensed under the MIT License. + +using System.Text.Json; + +namespace HyperVExtension.DevSetupAgent; + +internal sealed class IsUserLoggedInResponse : ResponseBase +{ + public IsUserLoggedInResponse(string requestId, List loggedInUsers) + : base(requestId) + { + RequestType = "IsUserLoggedIn"; + + // Return empty list for now. Reserved for the future use to deal with multiple logged in users. + LoggedInUsers = new List(); + IsUserLoggedIn = loggedInUsers.Count > 0; + GenerateJsonData(); + } + + public List LoggedInUsers { get; } + + public bool IsUserLoggedIn { get; } + + protected override void GenerateJsonData() + { + base.GenerateJsonData(); + + JsonData![nameof(IsUserLoggedIn)] = IsUserLoggedIn; + JsonData![nameof(LoggedInUsers)] = JsonSerializer.Serialize(LoggedInUsers); + } +} diff --git a/HyperVExtension/src/DevSetupAgent/Responses/ProgressResponse.cs b/HyperVExtension/src/DevSetupAgent/Responses/ProgressResponse.cs new file mode 100644 index 0000000000..63ee212e65 --- /dev/null +++ b/HyperVExtension/src/DevSetupAgent/Responses/ProgressResponse.cs @@ -0,0 +1,44 @@ +// Copyright (c) Microsoft Corporation. +// Licensed under the MIT License. + +using System.Text.Json; +using HyperVExtension.HostGuestCommunication; +using Microsoft.Windows.DevHome.DevSetupEngine; + +namespace HyperVExtension.DevSetupAgent; + +/// +/// Class that generates JSON data for Configure command progress events.. +/// JSON payload is generated in GenerateJsonData virtual method. +/// { +/// "ResponseId": "DevSetup{10000000-1000-1000-1000-100000000000}_Progres_", +/// "ResponseType": "Progress", +/// "Timestamp":"2023-11-21T08:08:58.6287789Z", +/// "Version": "0.0.1", +/// +/// } +/// +internal sealed class ProgressResponse : ResponseBase +{ + private readonly ConfigurationSetChangeData _progressData; + + private uint ProgressCounter { get; } + + public ProgressResponse(string requestId, IConfigurationSetChangeData progressData, uint progressCounter) + : base(requestId, "Configure") + { + _progressData = new ConfigurationSetChangeData().Populate(progressData); + ProgressCounter = progressCounter; + ResponseId = RequestId + $"_Progress_{ProgressCounter}"; + ResponseType = "Progress"; + GenerateJsonData(); + } + + protected override void GenerateJsonData() + { + base.GenerateJsonData(); + var progress = JsonSerializer.Serialize(_progressData); + JsonData![nameof(ProgressCounter)] = ProgressCounter; + JsonData![nameof(ConfigurationSetChangeData)] = progress; + } +} diff --git a/HyperVExtension/src/DevSetupAgent/Responses/ResponseBase.cs b/HyperVExtension/src/DevSetupAgent/Responses/ResponseBase.cs new file mode 100644 index 0000000000..4e38dcf77b --- /dev/null +++ b/HyperVExtension/src/DevSetupAgent/Responses/ResponseBase.cs @@ -0,0 +1,79 @@ +// Copyright (c) Microsoft Corporation. +// Licensed under the MIT License. + +using System.Text.Json.Nodes; + +namespace HyperVExtension.DevSetupAgent; + +/// +/// Base class for responses to the client. +/// JSON payload is generated in GenerateJsonData virtual method. +/// { +/// "ResponseId": "DevSetup{10000000-1000-1000-1000-100000000000}", +/// "ResponseType": "GetVersion", +/// "Timestamp":"2023-11-21T08:08:58.6287789Z", +/// "Version": "1", +/// +/// } +/// +internal class ResponseBase : IHostResponse +{ + public ResponseBase(string requestId, string? requestType = null) + { + RequestId = requestId; + ResponseId = requestId; + Status = Windows.Win32.Foundation.HRESULT.S_OK; + Version = 1; // Update version when the response format changes and needs special handling based on version. + RequestType = requestType != null ? requestType : "Unknown"; + ResponseType = "Completed"; + Timestamp = DateTime.UtcNow; + ErrorDescription = string.Empty; + SendResponse = true; + } + + public virtual string RequestId { get; set; } + + public virtual string RequestType { get; set; } + + public virtual string ResponseId { get; set; } + + public virtual string ResponseType { get; set; } + + public virtual uint Status { get; set; } + + public virtual string ErrorDescription { get; set; } + + public virtual uint Version { get; set; } + + public virtual DateTime Timestamp { get; set; } + + public virtual IResponseMessage GetResponseMessage() + { + if (JsonData == null) + { + GenerateJsonData(); + } + + return new ResponseMessage(ResponseId, JsonData!.ToJsonString()); + } + + public virtual bool SendResponse { get; set; } + + protected JsonNode? JsonData { get; private set; } + + protected virtual void GenerateJsonData() + { + var jsonData = new JsonObject + { + [nameof(Version)] = Version, + [nameof(RequestId)] = RequestId, + [nameof(RequestType)] = RequestType, + [nameof(ResponseId)] = ResponseId, + [nameof(ResponseType)] = ResponseType, + [nameof(Status)] = Status, + [nameof(ErrorDescription)] = ErrorDescription, + [nameof(Timestamp)] = Timestamp, + }; + JsonData = jsonData; + } +} diff --git a/HyperVExtension/src/DevSetupAgent/Responses/ResponseMessage.cs b/HyperVExtension/src/DevSetupAgent/Responses/ResponseMessage.cs new file mode 100644 index 0000000000..b42ae4a077 --- /dev/null +++ b/HyperVExtension/src/DevSetupAgent/Responses/ResponseMessage.cs @@ -0,0 +1,20 @@ +// Copyright (c) Microsoft Corporation. +// Licensed under the MIT License. + +namespace HyperVExtension.DevSetupAgent; + +/// +/// Response message data. +/// +internal struct ResponseMessage : IResponseMessage +{ + public ResponseMessage(string requestId, string responseData) + { + ResponseId = requestId; + ResponseData = responseData; + } + + public string ResponseId { get; set; } + + public string ResponseData { get; set; } +} diff --git a/HyperVExtension/src/DevSetupAgent/Responses/TooManyRequestsResponse.cs b/HyperVExtension/src/DevSetupAgent/Responses/TooManyRequestsResponse.cs new file mode 100644 index 0000000000..6eaa4a1e1a --- /dev/null +++ b/HyperVExtension/src/DevSetupAgent/Responses/TooManyRequestsResponse.cs @@ -0,0 +1,18 @@ +// Copyright (c) Microsoft Corporation. +// Licensed under the MIT License. + +using System.Text.Json; + +namespace HyperVExtension.DevSetupAgent; + +internal sealed class TooManyRequestsResponse : ResponseBase +{ + public TooManyRequestsResponse(string requestId) + : base(requestId) + { + // TODO: Better error story + Status = Windows.Win32.Foundation.HRESULT.E_FAIL; + ErrorDescription = "Too many requests in the queue."; + GenerateJsonData(); + } +} diff --git a/HyperVExtension/src/DevSetupAgent/appsettings.json b/HyperVExtension/src/DevSetupAgent/appsettings.json new file mode 100644 index 0000000000..6901764644 --- /dev/null +++ b/HyperVExtension/src/DevSetupAgent/appsettings.json @@ -0,0 +1,8 @@ +{ + "Logging": { + "LogLevel": { + "Default": "Information", + "Microsoft.Hosting.Lifetime": "Information" + } + } +} diff --git a/HyperVExtension/src/DevSetupEngine/ApplyConfigurationProgressWatcher.cs b/HyperVExtension/src/DevSetupEngine/ApplyConfigurationProgressWatcher.cs new file mode 100644 index 0000000000..174362c222 --- /dev/null +++ b/HyperVExtension/src/DevSetupEngine/ApplyConfigurationProgressWatcher.cs @@ -0,0 +1,103 @@ +// Copyright (c) Microsoft Corporation. +// Licensed under the MIT License. + +using HyperVExtension.DevSetupEngine.ConfigurationResultTypes; +using Microsoft.CodeAnalysis.Emit; +using Windows.Foundation; + +using DevSetupEngineTypes = Microsoft.Windows.DevHome.DevSetupEngine; +using WinGet = Microsoft.Management.Configuration; + +namespace HyperVExtension.DevSetupEngine; + +/// +/// Class to handle WinGet Progress event. +/// Converts WinGet progress data (IConfigurationSetChangeData) into our own version of IConfigurationSetChangeData +/// to pass back to the caller. +/// +internal sealed class ApplyConfigurationProgressWatcher +{ + private readonly IProgress _progress; + private bool _isFirstProgress = true; + + public ApplyConfigurationProgressWatcher(IProgress progress) + { + _progress = progress; + } + + internal void Watcher(IAsyncOperationWithProgress operation, WinGet.ConfigurationSetChangeData data) + { + if (_isFirstProgress) + { + _isFirstProgress = false; + + // If our first progress callback contains partial results, output them as if they had been called through progress + WinGet.ApplyConfigurationSetResult partialResult = operation.GetResults(); + + foreach (var unitResult in partialResult.UnitResults) + { + HandleUnitProgress(unitResult.Unit, unitResult.State, unitResult.ResultInformation); + } + } + + switch (data.Change) + { + case WinGet.ConfigurationSetChangeEventType.SetStateChanged: + Logging.Logger()?.ReportInfo($" - Set State: {data.SetState}"); + break; + case WinGet.ConfigurationSetChangeEventType.UnitStateChanged: + HandleUnitProgress(data.Unit, data.UnitState, data.ResultInformation); + break; + } + } + + private void HandleUnitProgress(WinGet.ConfigurationUnit unit, WinGet.ConfigurationUnitState unitState, WinGet.IConfigurationUnitResultInformation resultInformation) + { + switch (unitState) + { + case WinGet.ConfigurationUnitState.Pending: + break; + case WinGet.ConfigurationUnitState.InProgress: + case WinGet.ConfigurationUnitState.Completed: + case WinGet.ConfigurationUnitState.Skipped: + Logging.Logger()?.ReportInfo($" - Unit: {unit.Identifier} [{unit.InstanceIdentifier}]"); + Logging.Logger()?.ReportInfo($" Unit State: {unitState}"); + if (resultInformation.ResultCode != null) + { + Logging.Logger()?.ReportInfo($" HRESULT: [0x{resultInformation.ResultCode.HResult:X8}]"); + Logging.Logger()?.ReportInfo($" Reason: {resultInformation.Description}"); + } + + break; + case WinGet.ConfigurationUnitState.Unknown: + break; + } + + if (_progress != null) + { + var resultInfo = new ConfigurationResultTypes.ConfigurationUnitResultInformation( + resultInformation.ResultCode, + resultInformation.Description, + resultInformation.Details, + (DevSetupEngineTypes.ConfigurationUnitResultSource)resultInformation.ResultSource); + + var configurationUnit = new ConfigurationResultTypes.ConfigurationUnit( + unit.Type, + unit.Identifier, + (DevSetupEngineTypes.ConfigurationUnitState)unit.State, + false, + null, + unit.Settings, + (DevSetupEngineTypes.ConfigurationUnitIntent)unit.Intent); + + var configurationSetChangeData = new ConfigurationSetChangeData( + DevSetupEngineTypes.ConfigurationSetChangeEventType.UnitStateChanged, + DevSetupEngineTypes.ConfigurationSetState.Unknown, + (DevSetupEngineTypes.ConfigurationUnitState)unitState, + resultInfo, + configurationUnit); + + _progress.Report(configurationSetChangeData); + } + } +} diff --git a/HyperVExtension/src/DevSetupEngine/ApplyConfigurationResult.cs b/HyperVExtension/src/DevSetupEngine/ApplyConfigurationResult.cs new file mode 100644 index 0000000000..be07d18dd4 --- /dev/null +++ b/HyperVExtension/src/DevSetupEngine/ApplyConfigurationResult.cs @@ -0,0 +1,209 @@ +// Copyright (c) Microsoft Corporation. +// Licensed under the MIT License. + +//// Implementation of the interfaces used to send progress information and +//// results of applying a configuration set via WinGet. +//// These interfaces and classes are similar to WinGet's interfaces, but simplified for our use case. + +using System.Runtime.InteropServices; +using Microsoft.Windows.DevHome.DevSetupEngine; +using Windows.Foundation.Collections; +using DevSetupEngineTypes = Microsoft.Windows.DevHome.DevSetupEngine; + +namespace HyperVExtension.DevSetupEngine.ConfigurationResultTypes; + +#pragma warning disable SA1402 // File may only contain a single type + +[ComVisible(true)] +[Guid("36DB1AC2-B5D0-462E-82E3-7AB474C6DEFD")] +[ComDefaultInterface(typeof(IApplyConfigurationResult))] +public class ApplyConfigurationResult : IApplyConfigurationResult +{ + public ApplyConfigurationResult(Exception? resultCode, string resultDescription, IOpenConfigurationSetResult? openConfigurationSetResult, IApplyConfigurationSetResult? applyConfigurationSetResult) + { + ResultCode = resultCode; + ResultDescription = resultDescription; + OpenConfigurationSetResult = openConfigurationSetResult; + ApplyConfigurationSetResult = applyConfigurationSetResult; + } + + public Exception? ResultCode { get; } + + public string ResultDescription { get; } + + public IOpenConfigurationSetResult? OpenConfigurationSetResult { get; } + + public IApplyConfigurationSetResult? ApplyConfigurationSetResult { get; } +} + +[ComVisible(true)] +[Guid("8eecacf2-d864-46c5-aeb1-e66e96a8f654")] +[ComDefaultInterface(typeof(IConfigurationUnitResultInformation))] +public class ConfigurationUnitResultInformation : IConfigurationUnitResultInformation +{ + public ConfigurationUnitResultInformation(Exception? resultCode, string description, string details, DevSetupEngineTypes.ConfigurationUnitResultSource resultSource) + { + ResultCode = resultCode; + Description = description; + Details = details; + ResultSource = resultSource; + } + + // The error code of the failure. + public Exception? ResultCode { get; } + + // The short description of the failure. + public string Description { get; } + + // A more detailed error message appropriate for diagnosing the root cause of an error. + public string Details { get; } + + // The source of the result. + public DevSetupEngineTypes.ConfigurationUnitResultSource ResultSource { get; } +} + +[ComVisible(true)] +[Guid("8c4db755-62e6-4c5e-b42a-cd8d27e3b675")] +[ComDefaultInterface(typeof(IOpenConfigurationSetResult))] +public class OpenConfigurationSetResult : IOpenConfigurationSetResult +{ + public OpenConfigurationSetResult(Exception? resultCode, string field, string fieldValue, uint line, uint column) + { + ResultCode = resultCode; + Field = field; + Value = fieldValue; + Line = line; + Column = column; + } + + // The result from opening the set. + public Exception? ResultCode { get; } + + // The field that is missing/invalid, if appropriate for the specific ResultCode. + public string Field { get; } + + // The value of the field, if appropriate for the specific ResultCode. + public string Value { get; } + + // The line number for the failure reason, if determined. + public uint Line { get; } + + // The column number for the failure reason, if determined. + public uint Column { get; } +} + +[ComVisible(true)] +[Guid("6a2a0231-ea0e-4d71-97a5-922f340af798")] +[ComDefaultInterface(typeof(IConfigurationUnit))] +public class ConfigurationUnit : IConfigurationUnit +{ + public ConfigurationUnit(string type, string identifier, DevSetupEngineTypes.ConfigurationUnitState state, bool isGroup, IList? units, ValueSet settings, ConfigurationUnitIntent intent) + { + Type = type; + Identifier = identifier; + State = state; + IsGroup = isGroup; + Units = units; + Settings = settings; + Intent = intent; + } + + // The type of the unit being configured; not a name for this instance. + public string Type { get; } + + // The identifier name of this instance within the set. + public string Identifier { get; } + + // The current state of the configuration unit. + public DevSetupEngineTypes.ConfigurationUnitState State { get; } + + // Determines if this configuration unit should be treated as a group. + // A configuration unit group treats its `Settings` as the definition of child units. + public bool IsGroup { get; } + + // The configuration units that are part of this unit (if IsGroup is true). + public IList? Units { get; } + + public DevSetupEngineTypes.ConfigurationUnitIntent Intent { get; } + + public ValueSet? Settings { get; } +} + +[ComVisible(true)] +[Guid("a4915b80-fbb1-4c8f-bf86-35fd071a8a50")] +[ComDefaultInterface(typeof(IConfigurationSetChangeData))] +public class ConfigurationSetChangeData : IConfigurationSetChangeData +{ + public ConfigurationSetChangeData(DevSetupEngineTypes.ConfigurationSetChangeEventType change, DevSetupEngineTypes.ConfigurationSetState setState, DevSetupEngineTypes.ConfigurationUnitState unitState, IConfigurationUnitResultInformation resultInformation, IConfigurationUnit unit) + { + Change = change; + SetState = setState; + UnitState = unitState; + ResultInformation = resultInformation; + Unit = unit; + } + + // The change event type that occurred. + public DevSetupEngineTypes.ConfigurationSetChangeEventType Change { get; } + + // The state of the configuration set for this event (the ConfigurationSet can be used to get the current state, which may be different). + public DevSetupEngineTypes.ConfigurationSetState SetState { get; } + + // The state of the configuration unit for this event (the ConfigurationUnit can be used to get the current state, which may be different). + public DevSetupEngineTypes.ConfigurationUnitState UnitState { get; } + + // Contains information on the result of the attempt to apply the configuration unit. + public IConfigurationUnitResultInformation ResultInformation { get; } + + // The configuration unit whose state changed. + public IConfigurationUnit Unit { get; } +} + +[ComVisible(true)] +[Guid("6bf246e5-d4a4-4593-9253-778027f18197")] +[ComDefaultInterface(typeof(IApplyConfigurationUnitResult))] +public class ApplyConfigurationUnitResult : IApplyConfigurationUnitResult +{ + public ApplyConfigurationUnitResult(IConfigurationUnit unit, ConfigurationUnitState state, bool previouslyInDesiredState, bool rebootRequired, IConfigurationUnitResultInformation resultInformation) + { + Unit = unit; + PreviouslyInDesiredState = previouslyInDesiredState; + RebootRequired = rebootRequired; + ResultInformation = resultInformation; + State = state; + } + + // The configuration unit that was applied. + public IConfigurationUnit Unit { get; } + + // Will be true if the configuration unit was in the desired state (Test returns true) prior to the apply action. + public bool PreviouslyInDesiredState { get; } + + // Indicates whether a reboot is required after the configuration unit was applied. + public bool RebootRequired { get; } + + // The result of applying the configuration unit. + public IConfigurationUnitResultInformation ResultInformation { get; } + + public ConfigurationUnitState State { get; } +} + +[ComVisible(true)] +[Guid("0ca281ff-537c-4919-a268-1bd67d83dbfb")] +[ComDefaultInterface(typeof(IApplyConfigurationSetResult))] +public class ApplyConfigurationSetResult : IApplyConfigurationSetResult +{ + public ApplyConfigurationSetResult(Exception? resultCode, IReadOnlyList unitResults) + { + ResultCode = resultCode; + UnitResults = unitResults; + } + + // Results for each configuration unit in the set. + public IReadOnlyList UnitResults { get; } + + // The overall result from applying the configuration set. + public Exception? ResultCode { get; } +} + +#pragma warning restore SA1402 // File may only contain a single type diff --git a/HyperVExtension/src/DevSetupEngine/ComServer.cs b/HyperVExtension/src/DevSetupEngine/ComServer.cs new file mode 100644 index 0000000000..91ab9f514d --- /dev/null +++ b/HyperVExtension/src/DevSetupEngine/ComServer.cs @@ -0,0 +1,63 @@ +// Copyright (c) Microsoft Corporation. +// Licensed under the MIT License. + +using System.Diagnostics; +using System.Runtime.InteropServices; +using Windows.Win32; +using Windows.Win32.System.Com; + +namespace HyperVExtension.DevSetupEngine; + +/// +/// Helper to register COM class factories at runtime. +/// +internal sealed class ComServer : IDisposable +{ + private readonly HashSet registrationCookies = new(); + + public void RegisterComServer(Func createExtension) + { + Trace.WriteLine($"Registering class object:"); + Trace.Indent(); + Trace.WriteLine($"CLSID: {typeof(T).GUID:B}"); + Trace.WriteLine($"Type: {typeof(T)}"); + + uint cookie; + var clsid = typeof(T).GUID; + var hr = PInvoke.CoRegisterClassObject( + in clsid, + new DevSetupEngineClassFactory(createExtension), + CLSCTX.CLSCTX_LOCAL_SERVER, + REGCLS.REGCLS_MULTIPLEUSE | REGCLS.REGCLS_SUSPENDED, + out cookie); + + if (hr < 0) + { + Marshal.ThrowExceptionForHR(hr); + } + + registrationCookies.Add(cookie); + Trace.WriteLine($"Cookie: {cookie}"); + Trace.Unindent(); + + hr = PInvoke.CoResumeClassObjects(); + if (hr < 0) + { + Marshal.ThrowExceptionForHR(hr); + } + } + + public void Dispose() + { + Trace.WriteLine($"Revoking class object registrations:"); + Trace.Indent(); + foreach (var cookie in registrationCookies) + { + Trace.WriteLine($"Cookie: {cookie}"); + var hr = PInvoke.CoRevokeClassObject(cookie); + Debug.Assert(hr >= 0, $"CoRevokeClassObject failed ({hr:x}). Cookie: {cookie}"); + } + + Trace.Unindent(); + } +} diff --git a/HyperVExtension/src/DevSetupEngine/ConfigurationFileHelper.cs b/HyperVExtension/src/DevSetupEngine/ConfigurationFileHelper.cs new file mode 100644 index 0000000000..4a98bdccf6 --- /dev/null +++ b/HyperVExtension/src/DevSetupEngine/ConfigurationFileHelper.cs @@ -0,0 +1,204 @@ +// Copyright (c) Microsoft Corporation. +// Licensed under the MIT License. + +using System.Buffers; +using Microsoft.Management.Configuration; +using Microsoft.Management.Configuration.Processor; +using Microsoft.Management.Configuration.SetProcessorFactory; +using Windows.Storage.Streams; +using WinRT; +using DevSetupEngineTypes = Microsoft.Windows.DevHome.DevSetupEngine; +using WinGet = Microsoft.Management.Configuration; + +namespace HyperVExtension.DevSetupEngine; + +/// +/// Helper for applying a configuration file. This exists so that we can +/// use it in an elevated or non-elevated context. +/// +public class ConfigurationFileHelper +{ + public class ApplicationResult + { + public WinGet.ApplyConfigurationSetResult Result + { + get; + } + + public bool Succeeded => Result.ResultCode == null; + + public bool RequiresReboot => Result.UnitResults.Any(result => result.RebootRequired); + + public Exception ResultException => Result.ResultCode; + + public ApplicationResult(WinGet.ApplyConfigurationSetResult result) + { + Result = result; + } + } + + private WinGet.ConfigurationProcessor? _processor; + private WinGet.ConfigurationSet? _configSet; + + public ConfigurationFileHelper() + { + } + + /// + /// Open configuration set from the provided . + /// + /// DSC configuration file content + private ConfigurationResultTypes.OpenConfigurationSetResult OpenConfigurationSet(string content) + { + try + { + var modulesPath = Path.Combine(AppContext.BaseDirectory, @"runtimes\win\lib\net8.0\Modules"); + var externalModulesPath = Path.Combine(AppContext.BaseDirectory, "ExternalModules"); + + PowerShellConfigurationSetProcessorFactory pwshFactory = new PowerShellConfigurationSetProcessorFactory(); + pwshFactory.Policy = PowerShellConfigurationProcessorPolicy.Unrestricted; + pwshFactory.AdditionalModulePaths = new List() { modulesPath, externalModulesPath }; + Logging.Logger()?.ReportInfo($"Additional module paths: {string.Join(", ", pwshFactory.AdditionalModulePaths)}"); + + _processor = new ConfigurationProcessor(pwshFactory); + _processor.MinimumLevel = WinGet.DiagnosticLevel.Verbose; + _processor.Diagnostics += (sender, args) => LogConfigurationDiagnostics(args); + _processor.Caller = nameof(DevSetupEngine); + + var inputStream = StringToStream(content); + var openResult = _processor.OpenConfigurationSet(inputStream); + _configSet = openResult.Set; + if (_configSet == null) + { + throw new OpenConfigurationSetException(openResult); + } + + return new ConfigurationResultTypes.OpenConfigurationSetResult(openResult.ResultCode, openResult.Field, openResult.Value, openResult.Line, openResult.Column); + } + catch (OpenConfigurationSetException ex) + { + ConfigurationResultTypes.OpenConfigurationSetResult result = + new(ex.OpenConfigurationSetResult.ResultCode, ex.OpenConfigurationSetResult.Field, ex.OpenConfigurationSetResult.Value, ex.OpenConfigurationSetResult.Line, ex.OpenConfigurationSetResult.Column); + + _processor = null; + _configSet = null; + return result; + } + catch (Exception ex) + { + ConfigurationResultTypes.OpenConfigurationSetResult result = + new(ex, string.Empty, string.Empty, 0, 0); + + _processor = null; + _configSet = null; + return result; + } + } + + private async Task ApplyConfigurationSetAsync(IProgress progress) + { + if (_processor == null || _configSet == null) + { + throw new InvalidOperationException(); + } + + Logging.Logger()?.ReportInfo("Starting to apply configuration set"); + var applySetOperation = _processor.ApplySetAsync(_configSet, WinGet.ApplyConfigurationSetFlags.None); + var progressWatcher = new ApplyConfigurationProgressWatcher(progress); + applySetOperation.Progress += progressWatcher.Watcher; + var result = await applySetOperation; + + Logging.Logger()?.ReportInfo($"Apply configuration finished. HResult: {result.ResultCode?.HResult}"); + + var unitResults = new List(); + foreach (var unitResult in result.UnitResults) + { + var unit = new ConfigurationResultTypes.ConfigurationUnit( + unitResult.Unit.Type, + unitResult.Unit.Identifier, + (DevSetupEngineTypes.ConfigurationUnitState)unitResult.Unit.State, + false, + null, + unitResult.Unit.Settings, + (DevSetupEngineTypes.ConfigurationUnitIntent)unitResult.Unit.Intent); + + var resultInfo = new ConfigurationResultTypes.ConfigurationUnitResultInformation( + unitResult.ResultInformation.ResultCode, + unitResult.ResultInformation.Description, + unitResult.ResultInformation.Details, + (DevSetupEngineTypes.ConfigurationUnitResultSource)unitResult.ResultInformation.ResultSource); + + var configurationUnitResult = new ConfigurationResultTypes.ApplyConfigurationUnitResult( + unit, + (DevSetupEngineTypes.ConfigurationUnitState)unitResult.State, + unitResult.PreviouslyInDesiredState, + unitResult.RebootRequired, + resultInfo); + + unitResults.Add(configurationUnitResult); + } + + var applyConfigurationSetResult = new ConfigurationResultTypes.ApplyConfigurationSetResult(result.ResultCode, unitResults); + + return applyConfigurationSetResult; + } + + public async Task ApplyConfigurationAsync(string content, IProgress progress) + { + var openConfigurationSetResult = OpenConfigurationSet(content); + if (openConfigurationSetResult.ResultCode != null) + { + return new ConfigurationResultTypes.ApplyConfigurationResult(openConfigurationSetResult.ResultCode, string.Empty, openConfigurationSetResult, null); + } + + var applyConfigurationSetResult = await ApplyConfigurationSetAsync(progress); + + return new ConfigurationResultTypes.ApplyConfigurationResult(applyConfigurationSetResult.ResultCode, string.Empty, openConfigurationSetResult, applyConfigurationSetResult); + } + + private void LogConfigurationDiagnostics(WinGet.IDiagnosticInformation diagnosticInformation) + { + Logging.Logger()?.ReportInfo($"WinGet: {diagnosticInformation.Message}"); + + var sourceComponent = nameof(WinGet.ConfigurationProcessor); + switch (diagnosticInformation.Level) + { + case WinGet.DiagnosticLevel.Warning: + Logging.Logger()?.ReportWarn(sourceComponent, diagnosticInformation.Message); + return; + case WinGet.DiagnosticLevel.Error: + Logging.Logger()?.ReportError(sourceComponent, diagnosticInformation.Message); + return; + case WinGet.DiagnosticLevel.Critical: + Logging.Logger()?.ReportCritical(sourceComponent, diagnosticInformation.Message); + return; + case WinGet.DiagnosticLevel.Verbose: + case WinGet.DiagnosticLevel.Informational: + default: + Logging.Logger()?.ReportInfo(sourceComponent, diagnosticInformation.Message); + return; + } + } + + /// + /// Convert a string to an input stream + /// + /// Target string + /// Input stream +#pragma warning disable CA1859 // Use concrete types when possible for improved performance + private IInputStream StringToStream(string str) +#pragma warning restore CA1859 // Use concrete types when possible for improved performance + { + InMemoryRandomAccessStream result = new(); + using (DataWriter writer = new(result)) + { + writer.UnicodeEncoding = UnicodeEncoding.Utf8; + writer.WriteString(str); + writer.StoreAsync().AsTask().Wait(); + writer.DetachStream(); + } + + result.Seek(0); + return result; + } +} diff --git a/HyperVExtension/src/DevSetupEngine/DevSetupEngine.csproj b/HyperVExtension/src/DevSetupEngine/DevSetupEngine.csproj new file mode 100644 index 0000000000..b77fc14459 --- /dev/null +++ b/HyperVExtension/src/DevSetupEngine/DevSetupEngine.csproj @@ -0,0 +1,50 @@ + + + + + Exe + + + WinExe + + + + enable + enable + HyperVExtension.DevSetupEngine.Program + false + false + true + Dev + x86;x64;arm64 + win10-x86;win10-x64;win10-arm64 + Properties\PublishProfiles\win10-$(Platform).pubxml + true + + + + $(DefineConstants);CANARY_BUILD + $(DefineConstants);STABLE_BUILD + + + + + + + all + runtime; build; native; contentfiles; analyzers; buildtransitive + + + + + + + + + + + + + + + diff --git a/HyperVExtension/src/DevSetupEngine/DevSetupEngineClassFactory.cs b/HyperVExtension/src/DevSetupEngine/DevSetupEngineClassFactory.cs new file mode 100644 index 0000000000..db3c3b5789 --- /dev/null +++ b/HyperVExtension/src/DevSetupEngine/DevSetupEngineClassFactory.cs @@ -0,0 +1,76 @@ +// Copyright (c) Microsoft Corporation. +// Licensed under the MIT License. + +using System; +using System.Collections.Generic; +using System.Reflection.Metadata; +using System.Runtime.InteropServices; +using Windows.Win32.Foundation; +using WinRT; + +namespace HyperVExtension.DevSetupEngine; + +/// +/// Helper COM class factory that creates a new instance of T. +/// +/// Class with a GUID matching COM class GUID. +[ComVisible(true)] +#pragma warning disable SA1649 // File name should match first type name +internal sealed class DevSetupEngineClassFactory : IClassFactory +#pragma warning restore SA1649 // File name should match first type name +{ +#pragma warning disable SA1310 // Field names should not contain underscore + + private static readonly Guid IID_IUnknown = Guid.Parse("00000000-0000-0000-C000-000000000046"); + +#pragma warning restore SA1310 // Field names should not contain underscore + + private readonly Func _createClassInstance; + + public DevSetupEngineClassFactory(Func createClassInstance) + { + _createClassInstance = createClassInstance; + } + + public void CreateInstance( + [MarshalAs(UnmanagedType.Interface)] object outerIUnknown, + ref Guid interfaceId, + out IntPtr resultObject) + { + resultObject = IntPtr.Zero; + + if (outerIUnknown != null) + { + Marshal.ThrowExceptionForHR(HRESULT.CLASS_E_NOAGGREGATION); + } + + if (interfaceId == typeof(T).GUID || interfaceId == IID_IUnknown) + { + // Create the instance of the .NET object + resultObject = MarshalInspectable.FromManaged(_createClassInstance()!); + } + else + { + Marshal.ThrowExceptionForHR(HRESULT.E_NOINTERFACE); + } + } + + public void LockServer([MarshalAs(UnmanagedType.Bool)] bool fLock) + { + } +} + +// https://docs.microsoft.com/windows/win32/api/unknwn/nn-unknwn-iclassfactory +[ComImport] +[ComVisible(false)] +[Guid("00000001-0000-0000-C000-000000000046")] +[InterfaceType(ComInterfaceType.InterfaceIsIUnknown)] +internal interface IClassFactory +{ + void CreateInstance( + [MarshalAs(UnmanagedType.Interface)] object pUnkOuter, + ref Guid riid, + out IntPtr ppvObject); + + void LockServer([MarshalAs(UnmanagedType.Bool)] bool fLock); +} diff --git a/HyperVExtension/src/DevSetupEngine/DevSetupEngineImpl.cs b/HyperVExtension/src/DevSetupEngine/DevSetupEngineImpl.cs new file mode 100644 index 0000000000..7ed92411da --- /dev/null +++ b/HyperVExtension/src/DevSetupEngine/DevSetupEngineImpl.cs @@ -0,0 +1,54 @@ +// Copyright (c) Microsoft Corporation. +// Licensed under the MIT License. + +using System.Runtime.InteropServices; +using System.Runtime.InteropServices.WindowsRuntime; +using Microsoft.Windows.DevHome.DevSetupEngine; +using Windows.Foundation; + +namespace HyperVExtension.DevSetupEngine; + +/// +/// Implementation of the COM interface IDevSetupEngine. +/// +[ComVisible(true)] +[Guid("82E86C64-A8B9-44F9-9323-C37982F2D8BE")] +[ComDefaultInterface(typeof(IDevSetupEngine))] +internal sealed class DevSetupEngineImpl : IDevSetupEngine, IDisposable +{ + private bool _disposed; + + /// + /// Gets the synchronization object that is used to prevent the main program from exiting + /// until the object is disposed. + /// + public ManualResetEvent ComServerDisposedEvent { get; } = new(false); + + public IAsyncOperationWithProgress ApplyConfigurationAsync(string content) + { + var configurationFileHelper = new ConfigurationFileHelper(); + return AsyncInfo.Run(async (cancellationToken, progress) => + { + return await configurationFileHelper.ApplyConfigurationAsync(content, progress); + }); + } + + public void Dispose() + { + Dispose(true); + GC.SuppressFinalize(this); + } + + private void Dispose(bool disposing) + { + if (!_disposed) + { + if (disposing) + { + ComServerDisposedEvent.Set(); + } + + _disposed = true; + } + } +} diff --git a/HyperVExtension/src/DevSetupEngine/IHostExtensions.cs b/HyperVExtension/src/DevSetupEngine/IHostExtensions.cs new file mode 100644 index 0000000000..19240d0398 --- /dev/null +++ b/HyperVExtension/src/DevSetupEngine/IHostExtensions.cs @@ -0,0 +1,39 @@ +// Copyright (c) Microsoft Corporation. +// Licensed under the MIT License. + +using System; +using Microsoft.Extensions.DependencyInjection; +using Microsoft.Extensions.Hosting; + +namespace HyperVExtension.DevSetupEngine; + +public static class IHostExtensions +{ + /// + /// + /// + public static T CreateInstance(this IHost host, params object[] parameters) + { + return ActivatorUtilities.CreateInstance(host.Services, parameters); + } + + /// + /// Gets the service object for the specified type, or throws an exception + /// if type was not registered. + /// + /// Service type + /// Host object + /// Service object + /// Throw an exception if the specified + /// type is not registered + public static T GetService(this IHost host) + where T : class + { + if (host.Services.GetService(typeof(T)) is not T service) + { + throw new ArgumentException($"{typeof(T)} needs to be registered in ConfigureServices."); + } + + return service; + } +} diff --git a/HyperVExtension/src/DevSetupEngine/Logging.cs b/HyperVExtension/src/DevSetupEngine/Logging.cs new file mode 100644 index 0000000000..c6ec24d042 --- /dev/null +++ b/HyperVExtension/src/DevSetupEngine/Logging.cs @@ -0,0 +1,47 @@ +// Copyright (c) Microsoft Corporation. +// Licensed under the MIT License. + +using DevHome.Logging; +using Windows.Storage; + +namespace HyperVExtension.DevSetupEngine; + +public class Logging +{ + private static Logger? _logger; + + public static Logger? Logger() + { + try + { + _logger ??= new Logger("DevSetupEngine", GetLoggingOptions()); + } + catch + { + // Do nothing if logger fails. + } + + return _logger; + } + + public static Options GetLoggingOptions() + { + return new Options + { + LogFileFolderRoot = ApplicationData.Current.TemporaryFolder.Path, + LogFileName = "DevSetupEngine_{now}.log", + LogFileFolderName = "DevSetup", + DebugListenerEnabled = true, +#if DEBUG + LogStdoutEnabled = true, + LogStdoutFilter = SeverityLevel.Debug, + LogFileFilter = SeverityLevel.Debug, +#else + LogStdoutEnabled = false, + LogStdoutFilter = SeverityLevel.Info, + LogFileFilter = SeverityLevel.Info, +#endif + FailFastSeverity = FailFastSeverityLevel.Critical, + }; + } +} diff --git a/HyperVExtension/src/DevSetupEngine/NativeMethods.txt b/HyperVExtension/src/DevSetupEngine/NativeMethods.txt new file mode 100644 index 0000000000..f7b4750657 --- /dev/null +++ b/HyperVExtension/src/DevSetupEngine/NativeMethods.txt @@ -0,0 +1,11 @@ +CoRegisterClassObject +CoResumeClassObjects +CoRevokeClassObject +CLSCTX +REGCLS +HANDLE +WIN32_ERROR +S_OK +E_NOINTERFACE +CLASS_E_NOAGGREGATION +E_ACCESSDENIED diff --git a/HyperVExtension/src/DevSetupEngine/OpenConfigurationSetException.cs b/HyperVExtension/src/DevSetupEngine/OpenConfigurationSetException.cs new file mode 100644 index 0000000000..58f4a9bcc5 --- /dev/null +++ b/HyperVExtension/src/DevSetupEngine/OpenConfigurationSetException.cs @@ -0,0 +1,24 @@ +// Copyright (c) Microsoft Corporation. +// Licensed under the MIT License. + +using System; +using Microsoft.Management.Configuration; + +namespace HyperVExtension.DevSetupEngine; + +public class OpenConfigurationSetException : Exception +{ + // Open configuration error codes: + // Reference: https://github.com/microsoft/winget-cli/blob/master/src/AppInstallerSharedLib/Public/AppInstallerErrors.h + public const int WingetConfigErrorInvalidConfigurationFile = unchecked((int)0x8A15C001); + public const int WingetConfigErrorInvalidYaml = unchecked((int)0x8A15C002); + public const int WingetConfigErrorInvalidField = unchecked((int)0x8A15C003); + public const int WingetConfigErrorUnknownConfigurationFileVersion = unchecked((int)0x8A15C004); + + public OpenConfigurationSetResult OpenConfigurationSetResult { get; } + + public OpenConfigurationSetException(OpenConfigurationSetResult openConfigurationSetResult) + { + OpenConfigurationSetResult = openConfigurationSetResult; + } +} diff --git a/HyperVExtension/src/DevSetupEngine/Program.cs b/HyperVExtension/src/DevSetupEngine/Program.cs new file mode 100644 index 0000000000..44816e9348 --- /dev/null +++ b/HyperVExtension/src/DevSetupEngine/Program.cs @@ -0,0 +1,121 @@ +// Copyright (c) Microsoft Corporation. +// Licensed under the MIT License. + +using System.ComponentModel; +using HyperVExtension.DevSetupEngine; +using Microsoft.Extensions.DependencyInjection; +using Microsoft.Extensions.Hosting; +using Microsoft.Win32; + +namespace HyperVExtension.DevSetupEngine; + +internal sealed class Program +{ + private const string AppIdPath = @"SOFTWARE\Classes\AppID\"; + private const string ClsIdIdPath = @"SOFTWARE\Classes\ClSID\"; + + public static IHost? Host + { + get; set; + } + + [MTAThread] + public static int Main([System.Runtime.InteropServices.WindowsRuntime.ReadOnlyArray] string[] args) + { + try + { + Logging.Logger()?.ReportInfo($"Launched with args: {string.Join(' ', args.ToArray())}"); + + BuildHostContainer(); + + if ((args.Length > 0) && string.Equals(args[0], "-RegisterProcessAsComServer", StringComparison.OrdinalIgnoreCase)) + { + RegisterProcessAsComServer(); + } + else if ((args.Length > 0) && string.Equals(args[0], "-RegisterComServer", StringComparison.OrdinalIgnoreCase)) + { + RegisterComServer(); + } + else if ((args.Length > 0) && string.Equals(args[0], "-UnregisterComServer", StringComparison.OrdinalIgnoreCase)) + { + UnregisterComServer(); + } + else + { + Logging.Logger()?.ReportWarn("Unknown arguments... exiting."); + } + } + catch (Exception ex) + { + Logging.Logger()?.ReportError($"Exception: {ex}"); + return ex.HResult; + } + + return 0; + } + + private static void RegisterProcessAsComServer() + { + Logging.Logger()?.ReportInfo($"Activating COM Server"); + + // Register and run COM server. + // This could be called by either of the COM registrations, we will do them all to avoid deadlock and bind all on the extension's lifetime. + using var comServer = new ComServer(); + var devSetupEngine = Host!.GetService(); + + // We are instantiating extension instance once above, and returning it every time the callback in RegisterExtension below is called. + // This makes sure that only one instance of the extension is alive, which is returned every time the host asks for the IExtension object. + // If you want to instantiate a new instance each time the host asks, create the new instance inside the delegate. + comServer.RegisterComServer(() => devSetupEngine); + + // This will make the main thread wait until the event is signaled by the extension class. + // Since we have single instance of the extension object, we exit as soon as it is disposed. + devSetupEngine.ComServerDisposedEvent.WaitOne(); + Logging.Logger()?.ReportInfo($"Extension is disposed."); + } + + private static void RegisterComServer() + { + var appId = typeof(DevSetupEngineImpl).GUID.ToString("B"); + + var appIdKey = Registry.LocalMachine.CreateSubKey(AppIdPath + appId, true) ?? throw new Win32Exception(); + appIdKey.SetValue("RunAs", "Interactive User", RegistryValueKind.String); + + var clsIdKey = Registry.LocalMachine.CreateSubKey(ClsIdIdPath + appId, true) ?? throw new Win32Exception(); + clsIdKey.SetValue("AppID", appId); + + var localServer32Key = clsIdKey.CreateSubKey("LocalServer32", true) ?? throw new Win32Exception(); + + var exePath = Environment.ProcessPath!; + + localServer32Key.SetValue(string.Empty, "\"" + exePath + "\"" + " -RegisterProcessAsComServer"); + localServer32Key.SetValue("ServerExecutable", exePath); + } + + private static void UnregisterComServer() + { + var appId = typeof(DevSetupEngineImpl).GUID.ToString("B"); + Registry.LocalMachine.DeleteSubKeyTree(AppIdPath + appId, false); + Registry.LocalMachine.DeleteSubKeyTree(ClsIdIdPath + appId, false); + } + + /// + /// Creates the host container for the application. This can be used to register + /// services and other dependencies throughout the application. + /// + private static void BuildHostContainer() + { + Host = Microsoft.Extensions.Hosting.Host.CreateDefaultBuilder(). + UseContentRoot(AppContext.BaseDirectory). + UseDefaultServiceProvider((context, options) => + { + options.ValidateOnBuild = true; + }). + ConfigureServices((context, services) => + { + // Services + services.AddSingleton(); + }). + Build(); + } +} diff --git a/HyperVExtension/src/DevSetupEngine/Properties/PublishProfiles/win10-arm64.pubxml b/HyperVExtension/src/DevSetupEngine/Properties/PublishProfiles/win10-arm64.pubxml new file mode 100644 index 0000000000..08079c2934 --- /dev/null +++ b/HyperVExtension/src/DevSetupEngine/Properties/PublishProfiles/win10-arm64.pubxml @@ -0,0 +1,16 @@ + + + + + FileSystem + arm64 + win10-arm64 + true + False + False + True + True + + \ No newline at end of file diff --git a/HyperVExtension/src/DevSetupEngine/Properties/PublishProfiles/win10-x64.pubxml b/HyperVExtension/src/DevSetupEngine/Properties/PublishProfiles/win10-x64.pubxml new file mode 100644 index 0000000000..94861ecd4c --- /dev/null +++ b/HyperVExtension/src/DevSetupEngine/Properties/PublishProfiles/win10-x64.pubxml @@ -0,0 +1,16 @@ + + + + + FileSystem + x64 + win10-x64 + true + False + False + True + True + + \ No newline at end of file diff --git a/HyperVExtension/src/DevSetupEngine/Properties/PublishProfiles/win10-x86.pubxml b/HyperVExtension/src/DevSetupEngine/Properties/PublishProfiles/win10-x86.pubxml new file mode 100644 index 0000000000..3a63ea8fb9 --- /dev/null +++ b/HyperVExtension/src/DevSetupEngine/Properties/PublishProfiles/win10-x86.pubxml @@ -0,0 +1,16 @@ + + + + + FileSystem + x86 + win10-x86 + true + False + False + True + True + + \ No newline at end of file diff --git a/HyperVExtension/src/DevSetupEngine/Properties/launchSettings.json b/HyperVExtension/src/DevSetupEngine/Properties/launchSettings.json new file mode 100644 index 0000000000..ce061b2e84 --- /dev/null +++ b/HyperVExtension/src/DevSetupEngine/Properties/launchSettings.json @@ -0,0 +1,8 @@ +{ + "profiles": { + "DevSetupEngine": { + "commandName": "Project", + "commandLineArgs": "-RegisterProcessAsComServer" + } + } +} \ No newline at end of file diff --git a/HyperVExtension/src/DevSetupEngineIdl/DevSetupEngineIdl.filters b/HyperVExtension/src/DevSetupEngineIdl/DevSetupEngineIdl.filters new file mode 100644 index 0000000000..0c2990f0eb --- /dev/null +++ b/HyperVExtension/src/DevSetupEngineIdl/DevSetupEngineIdl.filters @@ -0,0 +1,15 @@ + + + + + A0BEA261-7EF4-47B0-8354-1ED46FF7B2B3 + rc;ico;cur;bmp;dlg;rc2;rct;bin;rgs;gif;jpg;jpeg;jpe;resx;tga;tiff;tif;png;wav;mfcribbon-ms + + + {BBC21554-4110-413A-B651-796F37C621D6} + + + + + + \ No newline at end of file diff --git a/HyperVExtension/src/DevSetupEngineIdl/DevSetupEngineIdl.vcxproj b/HyperVExtension/src/DevSetupEngineIdl/DevSetupEngineIdl.vcxproj new file mode 100644 index 0000000000..2463f6251e --- /dev/null +++ b/HyperVExtension/src/DevSetupEngineIdl/DevSetupEngineIdl.vcxproj @@ -0,0 +1,163 @@ + + + + + + true + true + true + true + {D6B0C16D-858A-4C1B-99CF-D6F4CF5BCD5F} + DevSetupEngineIdl + Microsoft.Windows.DevHome.DevSetupEngine + en-US + 14.0 + true + Windows Store + 10.0 + 10.0.22000.0 + 10.0.17763.0 + + + + + Debug + ARM64 + + + Debug + Win32 + + + Debug + x64 + + + Release + ARM64 + + + Release + Win32 + + + Release + x64 + + + + DynamicLibrary + v143 + Unicode + false + + + true + true + + + false + true + false + + + true + + + true + + + true + + + true + + + true + + + true + + + + + + + + + + + + bin\x86\$(Configuration)\ + obj\x86\$(Configuration)\ + + + bin\x86\$(Configuration)\ + obj\x86\$(Configuration)\ + + + bin\$(Platform)\$(Configuration)\ + obj\$(Platform)\$(Configuration)\ + + + bin\$(Platform)\$(Configuration)\ + obj\$(Platform)\$(Configuration)\ + + + bin\$(Platform)\$(Configuration)\ + obj\$(Platform)\$(Configuration)\ + + + bin\$(Platform)\$(Configuration)\ + obj\$(Platform)\$(Configuration)\ + + + + Use + pch.h + $(IntDir)pch.pch + Level4 + %(AdditionalOptions) /bigobj + + /DWINRT_NO_MAKE_DETECTION %(AdditionalOptions) + + + _WINRT_DLL;WIN32_LEAN_AND_MEAN;WINRT_LEAN_AND_MEAN;%(PreprocessorDefinitions) + $(WindowsSDK_WindowsMetadata);$(AdditionalUsingDirectories) + + + + + _DEBUG;%(PreprocessorDefinitions) + + + + + NDEBUG;%(PreprocessorDefinitions) + + + true + true + + + + + + + + + + + + + + + This project references NuGet package(s) that are missing on this computer. Use NuGet Package Restore to download them. For more information, see http://go.microsoft.com/fwlink/?LinkID=322105. The missing file is {0}. + + + + + + + + \ No newline at end of file diff --git a/HyperVExtension/src/DevSetupEngineIdl/Microsoft.Windows.DevHome.DevSetupEngine.idl b/HyperVExtension/src/DevSetupEngineIdl/Microsoft.Windows.DevHome.DevSetupEngine.idl new file mode 100644 index 0000000000..a2dc1707b7 --- /dev/null +++ b/HyperVExtension/src/DevSetupEngineIdl/Microsoft.Windows.DevHome.DevSetupEngine.idl @@ -0,0 +1,221 @@ +// Copyright (c) Microsoft Corporation. +// Licensed under the MIT License. + +// import "Microsoft.Management.Configuration.idl"; + +namespace Microsoft.Windows.DevHome.DevSetupEngine +{ + [contractversion(1)] + apicontract DevSetupEngineContract {} + + // The current state of a configuration set. + [contract(Microsoft.Windows.DevHome.DevSetupEngine.DevSetupEngineContract, 1)] + enum ConfigurationSetState + { + // The state of the configuration set is unknown. + Unknown, + // The configuration set is in the queue to be applied. + Pending, + // The configuration set is actively being applied. + InProgress, + // The configuration set has completed being applied. + Completed, + }; + + // The current state of a configuration unit. + [contract(Microsoft.Windows.DevHome.DevSetupEngine.DevSetupEngineContract, 1)] + enum ConfigurationUnitState + { + // The state of the configuration unit is unknown. + Unknown, + // The configuration unit is in the queue to be applied. + Pending, + // The configuration unit is actively being applied. + InProgress, + // The configuration unit has completed being applied. + Completed, + // The configuration unit was not applied due to external factors. + Skipped, + }; + + // The source of a result; for instance, the part of the system that generated a failure. + [contract(Microsoft.Windows.DevHome.DevSetupEngine.DevSetupEngineContract, 1)] + enum ConfigurationUnitResultSource + { + // The source is not known, or more likely, there was no failure. + None, + // The result came from inside the configuration system; this is likely a bug. + Internal, + // The configuration set was ill formed. For instance, referencing a configuration unit + // that does not exist or a dependency that is not present. + ConfigurationSet, + // The external module that processes the configuration unit generated the result. + UnitProcessing, + // The system state is causing the error. + SystemState, + // The configuration unit was not run due to a precondition not being met. + // For example, if a dependency fails to be applied, this will be set. + Precondition, + }; + + // Defines how the configuration unit is to be used within the configuration system. + [contract(Microsoft.Windows.DevHome.DevSetupEngine.DevSetupEngineContract, 1)] + enum ConfigurationUnitIntent + { + // The configuration unit will only be used to Test the current system state. + Assert, + // The configuration unit will only be used to Get the current system state. + Inform, + // The configuration unit will be used to Apply the current system state. + // The configuration unit will be used to Test and Get the current system state as part of that process. + Apply, + // The configuration unit's intent is unknown. This maps to WinGets unknown type but is currently not + // not in use by WinGet. + Unknown, + }; + + // Information on a result for a single unit of configuration. + [contract(Microsoft.Windows.DevHome.DevSetupEngine.DevSetupEngineContract, 1)] + interface IConfigurationUnitResultInformation + { + // The error code of the failure. + HRESULT ResultCode{ get; }; + + // The short description of the failure. + String Description{ get; }; + + // A more detailed error message appropriate for diagnosing the root cause of an error. + String Details{ get; }; + + // The source of the result. + ConfigurationUnitResultSource ResultSource{ get; }; + } + + // The result of calling OpenConfigurationSet, containing either the set or details about the failure. + [contract(Microsoft.Windows.DevHome.DevSetupEngine.DevSetupEngineContract, 1)] + interface IOpenConfigurationSetResult + { + // The result from opening the set. + HRESULT ResultCode{ get; }; + + // The field that is missing/invalid, if appropriate for the specific ResultCode. + String Field{ get; }; + + // The value of the field, if appropriate for the specific ResultCode. + String Value{ get; }; + + // The line number for the failure reason, if determined. + UInt32 Line{ get; }; + + // The column number for the failure reason, if determined. + UInt32 Column{ get; }; + } + + // A single unit of configuration. + [contract(Microsoft.Windows.DevHome.DevSetupEngine.DevSetupEngineContract, 1)] + interface IConfigurationUnit + { + // The type of the unit being configured; not a name for this instance. + String Type{ get; }; + + // The identifier name of this instance within the set. + String Identifier{ get; }; + + // The current state of the configuration unit. + ConfigurationUnitState State{ get; }; + + // Determines if this configuration unit should be treated as a group. + // A configuration unit group treats its `Settings` as the definition of child units. + Boolean IsGroup{ get; }; + + // The configuration units that are part of this unit (if IsGroup is true). + Windows.Foundation.Collections.IVector Units{ get; }; + + // Contains the values that are for use by the configuration unit itself. + Windows.Foundation.Collections.ValueSet Settings { get; }; + + // Describes how this configuration unit will be used. + ConfigurationUnitIntent Intent { get; }; + } + + // The change event type that has occurred for a configuration set change. + [contract(Microsoft.Windows.DevHome.DevSetupEngine.DevSetupEngineContract, 1)] + enum ConfigurationSetChangeEventType + { + Unknown, + // The change event was for the set state. Only ConfigurationSetChangeData.SetState is valid. + SetStateChanged, + // The change event was for the unit state. All ConfigurationSetChangeData properties are valid. + UnitStateChanged, + }; + + // The change data sent about changes to a specific set. + [contract(Microsoft.Windows.DevHome.DevSetupEngine.DevSetupEngineContract, 1)] + interface IConfigurationSetChangeData + { + // The change event type that occurred. + ConfigurationSetChangeEventType Change{ get; }; + + // The state of the configuration set for this event (the ConfigurationSet can be used to get the current state, which may be different). + ConfigurationSetState SetState{ get; }; + + // The state of the configuration unit for this event (the ConfigurationUnit can be used to get the current state, which may be different). + ConfigurationUnitState UnitState{ get; }; + + // Contains information on the result of the attempt to apply the configuration unit. + IConfigurationUnitResultInformation ResultInformation{ get; }; + + // The configuration unit whose state changed. + IConfigurationUnit Unit{ get; }; + } + + [contract(Microsoft.Windows.DevHome.DevSetupEngine.DevSetupEngineContract, 1)] + interface IApplyConfigurationUnitResult + { + // The configuration unit that was applied. + IConfigurationUnit Unit{ get; }; + + // The state of the configuration unit with regards to the current execution of ApplySet. + ConfigurationUnitState State { get; }; + + // Will be true if the configuration unit was in the desired state (Test returns true) prior to the apply action. + Boolean PreviouslyInDesiredState{ get; }; + + // Indicates whether a reboot is required after the configuration unit was applied. + Boolean RebootRequired{ get; }; + + // The result of applying the configuration unit. + IConfigurationUnitResultInformation ResultInformation{ get; }; + } + + // The result of applying the settings for a configuration set. + [contract(Microsoft.Windows.DevHome.DevSetupEngine.DevSetupEngineContract, 1)] + interface IApplyConfigurationSetResult + { + // Results for each configuration unit in the set. + Windows.Foundation.Collections.IVectorView UnitResults{ get; }; + + // The overall result from applying the configuration set. + HRESULT ResultCode{ get; }; + } + + [contract(Microsoft.Windows.DevHome.DevSetupEngine.DevSetupEngineContract, 1)] + interface IApplyConfigurationResult + { + // The overall result from applying the configuration set (Open configuration, apply configuration and anything in between). + HRESULT ResultCode{ get; }; + + String ResultDescription{ get; }; + + IOpenConfigurationSetResult OpenConfigurationSetResult{ get; }; + + IApplyConfigurationSetResult ApplyConfigurationSetResult { get; }; + }; + + [contract(Microsoft.Windows.DevHome.DevSetupEngine.DevSetupEngineContract, 1)] + interface IDevSetupEngine + { + // Applies the configuration set state. + Windows.Foundation.IAsyncOperationWithProgress ApplyConfigurationAsync(String content); + }; +} \ No newline at end of file diff --git a/HyperVExtension/src/DevSetupEngineIdl/packages.config b/HyperVExtension/src/DevSetupEngineIdl/packages.config new file mode 100644 index 0000000000..0b78db595c --- /dev/null +++ b/HyperVExtension/src/DevSetupEngineIdl/packages.config @@ -0,0 +1,4 @@ + + + + \ No newline at end of file diff --git a/HyperVExtension/src/DevSetupEngineProjection/DevSetupEngineProjection.csproj b/HyperVExtension/src/DevSetupEngineProjection/DevSetupEngineProjection.csproj new file mode 100644 index 0000000000..e2f51f3e6b --- /dev/null +++ b/HyperVExtension/src/DevSetupEngineProjection/DevSetupEngineProjection.csproj @@ -0,0 +1,31 @@ + + + + + + + None + x86;x64;arm64 + win10-x86;win10-x64;win10-arm64 + + + + + + + + + + + + + + + + + + Microsoft.Windows.DevHome.DevSetupEngine + $(SolutionDir)HyperVExtension\src\$(MSBuildProjectName)\bin\$(Platform)\$(Configuration)\ + + + diff --git a/HyperVExtension/src/HyperVExtension.Common/Extensions/IHostExtensions.cs b/HyperVExtension/src/HyperVExtension.Common/Extensions/IHostExtensions.cs new file mode 100644 index 0000000000..0a274bcada --- /dev/null +++ b/HyperVExtension/src/HyperVExtension.Common/Extensions/IHostExtensions.cs @@ -0,0 +1,37 @@ +// Copyright (c) Microsoft Corporation. +// Licensed under the MIT License. + +using System; +using Microsoft.Extensions.DependencyInjection; +using Microsoft.Extensions.Hosting; + +namespace HyperVExtension.Common.Extensions; + +public static class IHostExtensions +{ + /// + public static T CreateInstance(this IHost host, params object[] parameters) + { + return ActivatorUtilities.CreateInstance(host.Services, parameters); + } + + /// + /// Gets the service object for the specified type, or throws an exception + /// if type was not registered. + /// + /// Service type + /// Host object + /// Service object + /// Throw an exception if the specified + /// type is not registered + public static T GetService(this IHost host) + where T : class + { + if (host.Services.GetService(typeof(T)) is not T service) + { + throw new ArgumentException($"{typeof(T)} needs to be registered in ConfigureServices."); + } + + return service; + } +} diff --git a/HyperVExtension/src/HyperVExtension.Common/Extensions/ResourceExtensions.cs b/HyperVExtension/src/HyperVExtension.Common/Extensions/ResourceExtensions.cs new file mode 100644 index 0000000000..3e1bfbb55e --- /dev/null +++ b/HyperVExtension/src/HyperVExtension.Common/Extensions/ResourceExtensions.cs @@ -0,0 +1,13 @@ +// Copyright (c) Microsoft Corporation. +// Licensed under the MIT License. + +using Microsoft.Windows.ApplicationModel.Resources; + +namespace HyperVExtension.Common.Extensions; + +public static class ResourceExtensions +{ + private static readonly ResourceLoader _resourceLoader = new(); + + public static string GetLocalized(this string resourceKey) => _resourceLoader.GetString(resourceKey); +} diff --git a/HyperVExtension/src/HyperVExtension.Common/Extensions/ServiceExtensions.cs b/HyperVExtension/src/HyperVExtension.Common/Extensions/ServiceExtensions.cs new file mode 100644 index 0000000000..b7d79518b9 --- /dev/null +++ b/HyperVExtension/src/HyperVExtension.Common/Extensions/ServiceExtensions.cs @@ -0,0 +1,24 @@ +// Copyright (c) Microsoft Corporation. +// Licensed under the MIT License. + +using Microsoft.Extensions.DependencyInjection; +using Microsoft.Extensions.Hosting; + +namespace HyperVExtension.Common.Extensions; + +/// +/// A class that contains extension methods for . +/// +/// +/// Used to return common services for all projects. +/// +public static class ServiceExtensions +{ + public static IServiceCollection AddCommonProjectServices(this IServiceCollection services, HostBuilderContext context) + { + // Services + services.AddSingleton(); + + return services; + } +} diff --git a/HyperVExtension/src/HyperVExtension.Common/HyperVExtension.Common.csproj b/HyperVExtension/src/HyperVExtension.Common/HyperVExtension.Common.csproj new file mode 100644 index 0000000000..ae2f474b00 --- /dev/null +++ b/HyperVExtension/src/HyperVExtension.Common/HyperVExtension.Common.csproj @@ -0,0 +1,14 @@ + + + + HyperVExtension.Common + x86;x64;arm64 + win10-x86;win10-x64;win10-arm64 + + + + + + + + diff --git a/HyperVExtension/src/HyperVExtension.Common/Services/IStringResource.cs b/HyperVExtension/src/HyperVExtension.Common/Services/IStringResource.cs new file mode 100644 index 0000000000..8a121288f2 --- /dev/null +++ b/HyperVExtension/src/HyperVExtension.Common/Services/IStringResource.cs @@ -0,0 +1,9 @@ +// Copyright (c) Microsoft Corporation. +// Licensed under the MIT License. + +namespace HyperVExtension.Common; + +public interface IStringResource +{ + public string GetLocalized(string key, params object[] args); +} diff --git a/HyperVExtension/src/HyperVExtension.Common/Services/StringResource.cs b/HyperVExtension/src/HyperVExtension.Common/Services/StringResource.cs new file mode 100644 index 0000000000..4adb1695d0 --- /dev/null +++ b/HyperVExtension/src/HyperVExtension.Common/Services/StringResource.cs @@ -0,0 +1,43 @@ +// Copyright (c) Microsoft Corporation. +// Licensed under the MIT License. + +using System.Globalization; +using Windows.ApplicationModel.Resources; + +namespace HyperVExtension.Common; + +public class StringResource : IStringResource +{ + private readonly ResourceLoader _resourceLoader; + + public StringResource() + { + _resourceLoader = new ResourceLoader("HyperVExtension/Resources"); + } + + public StringResource(string name) + { + _resourceLoader = new ResourceLoader(name); + } + + /// Gets the localized string of a resource key. + /// Resource key. + /// Placeholder arguments. + /// Localized value, or resource key if the value is empty or an exception occurred. + public string GetLocalized(string key, params object[] args) + { + string value; + + try + { + value = _resourceLoader.GetString(key); + value = string.Format(CultureInfo.CurrentCulture, value, args); + } + catch + { + value = string.Empty; + } + + return string.IsNullOrEmpty(value) ? key : value; + } +} diff --git a/HyperVExtension/src/HyperVExtension.HostGuestCommunication/ApplyConfigurationResult.cs b/HyperVExtension/src/HyperVExtension.HostGuestCommunication/ApplyConfigurationResult.cs new file mode 100644 index 0000000000..202710635b --- /dev/null +++ b/HyperVExtension/src/HyperVExtension.HostGuestCommunication/ApplyConfigurationResult.cs @@ -0,0 +1,204 @@ +// Copyright (c) Microsoft Corporation. +// Licensed under the MIT License. + +using System; +using System.Collections.Generic; +using System.Runtime.InteropServices; +using System.Text; +using System.Text.Json.Serialization; +using Windows.Foundation.Collections; + +namespace HyperVExtension.HostGuestCommunication; + +// Helper class to convert from the DevSetupEngine COM types to the .NET types and use them +// to serialize the results to JSON. +#pragma warning disable SA1402 // File may only contain a single type + +public enum ConfigurationSetChangeEventType : int +{ + Unknown = 0, + SetStateChanged = 0x1, + UnitStateChanged = 0x2, +} + +public enum ConfigurationSetState : int +{ + Unknown = 0, + Pending = 0x1, + InProgress = 0x2, + Completed = 0x3, +} + +public enum ConfigurationUnitResultSource : int +{ + None = 0, + Internal = 0x1, + ConfigurationSet = 0x2, + UnitProcessing = 0x3, + SystemState = 0x4, + Precondition = 0x5, +} + +public enum ConfigurationUnitState : int +{ + Unknown = 0, + Pending = 0x1, + InProgress = 0x2, + Completed = 0x3, + Skipped = 0x4, +} + +public enum ConfigurationUnitIntent : int +{ + Assert, + Inform, + Apply, + Unknown, +} + +public class ApplyConfigurationResult +{ + public ApplyConfigurationResult() + { + } + + public ApplyConfigurationResult(int resultCode, string? resultDescription = null) + { + ResultCode = resultCode; + ResultDescription = resultDescription ?? string.Empty; + } + + public int ResultCode { get; set; } + + public string ResultDescription { get; set; } = string.Empty; + + public OpenConfigurationSetResult? OpenConfigurationSetResult { get; set; } + + public ApplyConfigurationSetResult? ApplyConfigurationSetResult { get; set; } +} + +public class ConfigurationUnitResultInformation +{ + public ConfigurationUnitResultInformation() + { + } + + // The error code of the failure. + public int ResultCode { get; set; } + + // The short description of the failure. + public string? Description { get; set; } + + // A more detailed error message appropriate for diagnosing the root cause of an error. + public string? Details { get; set; } + + // The source of the result. + public ConfigurationUnitResultSource ResultSource { get; set; } +} + +public class OpenConfigurationSetResult +{ + public OpenConfigurationSetResult() + { + } + + // The result from opening the set. + public int ResultCode { get; set; } + + // The field that is missing/invalid, if appropriate for the specific ResultCode. + public string? Field { get; set; } + + // The value of the field, if appropriate for the specific ResultCode. + public string? Value { get; set; } + + // The line number for the failure reason, if determined. + public uint Line { get; set; } + + // The column number for the failure reason, if determined. + public uint Column { get; set; } +} + +public class ConfigurationUnit +{ + public ConfigurationUnit() + { + } + + // The type of the unit being configured; not a name for this instance. + public string? Type { get; set; } + + // The identifier name of this instance within the set. + public string? Identifier { get; set; } + + // The current state of the configuration unit. + public ConfigurationUnitState State { get; set; } + + // Determines if this configuration unit should be treated as a group. + // A configuration unit group treats its `Settings` as the definition of child units. + public bool IsGroup { get; set; } + + // The configuration units that are part of this unit (if IsGroup is true). + public List? Units { get; set; } + + // Contains the values that are for use by the configuration unit itself. + public Dictionary? Settings { get; set; } + + // Describes how this configuration unit will be used. + public ConfigurationUnitIntent Intent { get; set; } +} + +public class ConfigurationSetChangeData +{ + public ConfigurationSetChangeData() + { + } + + // The change event type that occurred. + public ConfigurationSetChangeEventType Change { get; set; } + + // The state of the configuration set for this event (the ConfigurationSet can be used to get the current state, which may be different). + public ConfigurationSetState SetState { get; set; } + + // The state of the configuration unit for this event (the ConfigurationUnit can be used to get the current state, which may be different). + public ConfigurationUnitState UnitState { get; set; } + + // Contains information on the result of the attempt to apply the configuration unit. + public ConfigurationUnitResultInformation? ResultInformation { get; set; } + + // The configuration unit whose state changed. + public ConfigurationUnit? Unit { get; set; } +} + +public class ApplyConfigurationUnitResult +{ + public ApplyConfigurationUnitResult() + { + } + + // The configuration unit that was applied. + public ConfigurationUnit? Unit { get; set; } + + // Will be true if the configuration unit was in the desired state (Test returns true) prior to the apply action. + public bool PreviouslyInDesiredState { get; set; } + + // Indicates whether a reboot is required after the configuration unit was applied. + public bool RebootRequired { get; set; } + + // The result of applying the configuration unit. + public ConfigurationUnitResultInformation? ResultInformation { get; set; } +} + +public class ApplyConfigurationSetResult +{ + public ApplyConfigurationSetResult() + { + } + + // Results for each configuration unit in the set. + public List? UnitResults { get; set; } + + // The overall result from applying the configuration set. + public int ResultCode { get; set; } +} + +#pragma warning restore SA1402 // File may only contain a single type diff --git a/HyperVExtension/src/HyperVExtension.HostGuestCommunication/Extensions/StringExtensions.cs b/HyperVExtension/src/HyperVExtension.HostGuestCommunication/Extensions/StringExtensions.cs new file mode 100644 index 0000000000..eaa76cb633 --- /dev/null +++ b/HyperVExtension/src/HyperVExtension.HostGuestCommunication/Extensions/StringExtensions.cs @@ -0,0 +1,25 @@ +// Copyright (c) Microsoft Corporation. +// Licensed under the MIT License. + +using System; +using System.Collections.Generic; +using Microsoft.Extensions.DependencyInjection; + +namespace HyperVExtension.HostGuestCommunication; + +/// +/// A class that contains extension methods for . +/// +/// +/// Returns enumerator to get substrings of a give length. +/// +public static class StringExtensions +{ + public static IEnumerable SplitByLength(this string str, int maxLength) + { + for (var startIndex = 0; startIndex < str.Length; startIndex += maxLength) + { + yield return str.Substring(startIndex, Math.Min(maxLength, str.Length - startIndex)); + } + } +} diff --git a/HyperVExtension/src/HyperVExtension.HostGuestCommunication/HyperVExtension.HostGuestCommunication.csproj b/HyperVExtension/src/HyperVExtension.HostGuestCommunication/HyperVExtension.HostGuestCommunication.csproj new file mode 100644 index 0000000000..71796cb42d --- /dev/null +++ b/HyperVExtension/src/HyperVExtension.HostGuestCommunication/HyperVExtension.HostGuestCommunication.csproj @@ -0,0 +1,21 @@ + + + + HyperVExtension.HostGuestCommunication + x86;x64;arm64 + win10-x86;win10-x64;win10-arm64 + enable + enable + + + + + + + + + + + + + diff --git a/HyperVExtension/src/HyperVExtension.HostGuestCommunication/Providers/Logging.cs b/HyperVExtension/src/HyperVExtension.HostGuestCommunication/Providers/Logging.cs new file mode 100644 index 0000000000..bfa2b68315 --- /dev/null +++ b/HyperVExtension/src/HyperVExtension.HostGuestCommunication/Providers/Logging.cs @@ -0,0 +1,47 @@ +// Copyright (c) Microsoft Corporation. +// Licensed under the MIT License. + +using DevHome.Logging; +using Windows.Storage; + +namespace HyperVExtension.HostGuestCommunication; + +internal sealed class Logging +{ + private static Logger? _logger; + + public static Logger? Logger() + { + try + { + _logger ??= new Logger("HyperVCommunication", GetLoggingOptions()); + } + catch + { + // Do nothing if logger fails. + } + + return _logger; + } + + public static Options GetLoggingOptions() + { + return new Options + { + LogFileFolderRoot = ApplicationData.Current.TemporaryFolder.Path, + LogFileName = "HyperVCommunication_{now}.log", + LogFileFolderName = "HyperVCommunication", + DebugListenerEnabled = true, +#if DEBUG + LogStdoutEnabled = true, + LogStdoutFilter = SeverityLevel.Debug, + LogFileFilter = SeverityLevel.Debug, +#else + LogStdoutEnabled = false, + LogStdoutFilter = SeverityLevel.Info, + LogFileFilter = SeverityLevel.Info, +#endif + FailFastSeverity = FailFastSeverityLevel.Critical, + }; + } +} diff --git a/HyperVExtension/src/HyperVExtension.HostGuestCommunication/Providers/MessageHelper.cs b/HyperVExtension/src/HyperVExtension.HostGuestCommunication/Providers/MessageHelper.cs new file mode 100644 index 0000000000..a552d2e362 --- /dev/null +++ b/HyperVExtension/src/HyperVExtension.HostGuestCommunication/Providers/MessageHelper.cs @@ -0,0 +1,165 @@ +// Copyright (c) Microsoft Corporation. +// Licensed under the MIT License. + +using System; +using System.Collections.Generic; +using System.Linq; +using System.Text; +using Microsoft.Extensions.DependencyInjection; +using Microsoft.Win32; + +namespace HyperVExtension.HostGuestCommunication; + +/// +/// A class that contains extension methods for . +/// +/// +/// Returns enumerator to get substrings of a give length. +/// +public static class MessageHelper +{ + public const char Separator = '~'; + public const string MessageIdStart = "DevSetup{"; + + public static bool IsValidMessageName(string[]? message, out int index, out int total) + { + // Number of parts separated by '-' DevSetup{}-- + const int ValueNamePartsCount = 3; + index = 0; + total = 0; + if (message == null) + { + return false; + } + + if (message.Length != ValueNamePartsCount) + { + return false; + } + + if (!int.TryParse(message[1], out index) || !int.TryParse(message[2], out total)) + { + return false; + } + + return true; + } + + public static Dictionary MergeMessageParts(Dictionary messageParts) + { + var messages = new Dictionary(); + var guestMessages = new Dictionary(); + var valueNames = messageParts.Keys.Where(k => k.StartsWith(MessageHelper.MessageIdStart, StringComparison.OrdinalIgnoreCase)).ToList(); + HashSet ignoreMessages = new(); + + foreach (var valueName in valueNames) + { + var s = valueName.Split(Separator); + if (!MessageHelper.IsValidMessageName(s, out var index, out var total)) + { + continue; + } + + if (ignoreMessages.Contains(s[0])) + { + continue; + } + + // Count if we have all parts of the message + var count = 0; + foreach (var valueNameTmp in valueNames) + { + if (valueNameTmp.StartsWith(s[0] + $"{Separator}", StringComparison.InvariantCultureIgnoreCase)) + { + if (!MessageHelper.IsValidMessageName(valueNameTmp.Split(Separator), out var indeTmp, out var totalTmp)) + { + continue; + } + + count++; + } + } + + // Either we will process all parts of the message below + // or will ignore it because we don't have all parts. + // In both cases we don't want to iterate trough messages with the same id. + ignoreMessages.Add(s[0]); + if (count != total) + { + // Ignore this message for now. We don't have all parts. + continue; + } + + // Merge all parts of the message + // Preserve message GUID, delete the value and create response even if reading failed. + var name = s[0]; + var value = string.Empty; + try + { + var sb = new StringBuilder(); + for (var i = 1; i <= total; i++) + { + var value1 = messageParts[s[0] + $"{Separator}{i}{Separator}{total}"]; + if (value1 == null) + { + throw new InvalidOperationException($"Could not read guest message {valueName}"); + } + + sb.Append(value1); + } + + value = sb.ToString(); + } + catch (Exception ex) + { + Logging.Logger()?.ReportError($"Could not read guest message {valueName}", ex); + } + + messages.Add(name, value); + } + + return messages; + } + + public static Dictionary GetRegistryMessageKvp(RegistryKey regKey) + { + var messageParts = new Dictionary(); + foreach (var valueName in regKey.GetValueNames()) + { + if (!valueName.StartsWith(MessageIdStart, StringComparison.OrdinalIgnoreCase)) + { + continue; + } + + var value = regKey.GetValue(valueName); + if (value is string str) + { + messageParts.Add(valueName, str); + } + } + + return messageParts; + } + + /// + /// Search and delete all existing registry values with names starting with startsWith + /// + /// Parent registry key. + /// Registry key sub-path to search. + /// Beginning of the value name. + public static void DeleteAllMessages(RegistryKey registryKey, string registryKeyPath, string startsWith) + { + var regKey = registryKey.OpenSubKey(registryKeyPath, true); + var values = regKey?.GetValueNames(); + if (values != null) + { + foreach (var value in values) + { + if (value.StartsWith(startsWith, StringComparison.InvariantCultureIgnoreCase)) + { + regKey!.DeleteValue(value, false); + } + } + } + } +} diff --git a/HyperVExtension/src/HyperVExtension/Assets/hyper-v-provider-icon.png b/HyperVExtension/src/HyperVExtension/Assets/hyper-v-provider-icon.png new file mode 100644 index 0000000000..e4019ec27c Binary files /dev/null and b/HyperVExtension/src/HyperVExtension/Assets/hyper-v-provider-icon.png differ diff --git a/HyperVExtension/src/HyperVExtension/Assets/hyper-v-windows-default-image.jpg b/HyperVExtension/src/HyperVExtension/Assets/hyper-v-windows-default-image.jpg new file mode 100644 index 0000000000..eee923efb7 Binary files /dev/null and b/HyperVExtension/src/HyperVExtension/Assets/hyper-v-windows-default-image.jpg differ diff --git a/HyperVExtension/src/HyperVExtension/CommunicationWithGuest/ApplyConfigurationOperation.cs b/HyperVExtension/src/HyperVExtension/CommunicationWithGuest/ApplyConfigurationOperation.cs new file mode 100644 index 0000000000..ea4260d9fe --- /dev/null +++ b/HyperVExtension/src/HyperVExtension/CommunicationWithGuest/ApplyConfigurationOperation.cs @@ -0,0 +1,287 @@ +// Copyright (c) Microsoft Corporation. +// Licensed under the MIT License. + +using System.ComponentModel; +using System.Globalization; +using System.Runtime.InteropServices; +using System.Runtime.InteropServices.WindowsRuntime; +using System.Threading; +using HyperVExtension.HostGuestCommunication; +using HyperVExtension.Models; +using HyperVExtension.Providers; +using Microsoft.Windows.DevHome.SDK; +using Windows.Foundation; +using Windows.Foundation.Collections; +using Windows.Storage; +using Windows.Storage.Streams; +using Windows.Win32.Foundation; +using SDK = Microsoft.Windows.DevHome.SDK; + +namespace HyperVExtension.CommunicationWithGuest; + +public sealed class ApplyConfigurationOperation : IApplyConfigurationOperation, IDisposable +{ + private readonly HyperVVirtualMachine _virtualMachine; + private readonly CancellationTokenSource _cancellationTokenSource = new(); + + private bool _disposed; + + public string Configuration { get; private set; } = string.Empty; + + public ApplyConfigurationOperation(HyperVVirtualMachine virtualMachine, string configuration) + { + _virtualMachine = virtualMachine; + Configuration = configuration; + } + + public ApplyConfigurationOperation(HyperVVirtualMachine virtualMachine, Exception result, string? resultDescription = null) + { + _virtualMachine = virtualMachine; + CompletionStatus = new SDK.ApplyConfigurationResult(result, result.Message, result.Message); + } + + public CancellationToken CancellationToken => _cancellationTokenSource.Token; + + public event TypedEventHandler ActionRequired = (s, e) => { }; + + public event TypedEventHandler ConfigurationSetStateChanged = (s, e) => { }; + + public SDK.ApplyConfigurationResult? CompletionStatus { get; private set; } + + public SDK.ConfigurationSetChangeData ProgressData { get; private set; } = + new SDK.ConfigurationSetChangeData( + SDK.ConfigurationSetChangeEventType.Unknown, + SDK.ConfigurationSetState.Unknown, + SDK.ConfigurationUnitState.Unknown, + new SDK.ConfigurationUnitResultInformation(null, null, null, SDK.ConfigurationUnitResultSource.None), + new SDK.ConfigurationUnit(null, null, SDK.ConfigurationUnitState.Unknown, false, null, null, SDK.ConfigurationUnitIntent.Unknown)); + + public void SetProgress( + SDK.ConfigurationSetState state, + HostGuestCommunication.ConfigurationSetChangeData? progressData, + SDK.IExtensionAdaptiveCardSession2? adaptiveCardSession) + { + var sdkProgressData = GetSdkProgressData(state, progressData); + + if (sdkProgressData != null) + { + ProgressData = sdkProgressData; + ConfigurationSetStateChanged?.Invoke(this, new(ProgressData)); + } + + if (adaptiveCardSession != null) + { + ActionRequired?.Invoke(this, new(adaptiveCardSession)); + } + } + + public SDK.ApplyConfigurationResult CompleteOperation(HostGuestCommunication.ApplyConfigurationResult? completionStatus) + { + // If the completionStatus is not null, then the operation is completed. + // if ((completionStatus != null) || (state == SDK.ConfigurationSetState.Completed)) + var sdkCompletionStatus = GetSdkConfigurationResult(completionStatus); + if (sdkCompletionStatus == null) + { + // No apply configuration result was provided, but state is "Completed" + // so create ApplyConfigurationResult with no error (meaning operation is completed). + sdkCompletionStatus = new SDK.ApplyConfigurationResult(null, null); + } + + return sdkCompletionStatus; + } + + private SDK.ConfigurationSetChangeData GetSdkProgressData( + SDK.ConfigurationSetState state, + HostGuestCommunication.ConfigurationSetChangeData? progressData) + { + SDK.ConfigurationSetChangeData sdkProgressData; + if (progressData != null) + { + SDK.ConfigurationUnitResultInformation resultInfo; + if (progressData.ResultInformation != null) + { + resultInfo = new( + progressData.ResultInformation.ResultCode == HRESULT.S_OK ? null : new HResultException(progressData.ResultInformation.ResultCode, progressData.ResultInformation.Description), + progressData.ResultInformation.Description, + progressData.ResultInformation.Details, + (SDK.ConfigurationUnitResultSource)progressData.ResultInformation.ResultSource); + } + else + { + resultInfo = new(null, null, null, SDK.ConfigurationUnitResultSource.None); + } + + SDK.ConfigurationUnit? sdkUnit = ConfigurationUnit(progressData.Unit); + + sdkProgressData = new SDK.ConfigurationSetChangeData( + (SDK.ConfigurationSetChangeEventType)progressData.Change, + (SDK.ConfigurationSetState)progressData.SetState, + (SDK.ConfigurationUnitState)progressData.UnitState, + resultInfo, + sdkUnit); + } + else + { + sdkProgressData = new SDK.ConfigurationSetChangeData( + SDK.ConfigurationSetChangeEventType.SetStateChanged, + state, + SDK.ConfigurationUnitState.Unknown, + null, + null); + } + + return sdkProgressData; + } + + private SDK.ApplyConfigurationResult? GetSdkConfigurationResult(HostGuestCommunication.ApplyConfigurationResult? completionStatus) + { + if (completionStatus != null) + { + SDK.OpenConfigurationSetResult? sdkOpenConfigurationSetResult = null; + if (completionStatus.OpenConfigurationSetResult != null) + { + sdkOpenConfigurationSetResult = new( + completionStatus.OpenConfigurationSetResult.ResultCode == HRESULT.S_OK ? + null : + new HResultException(completionStatus.OpenConfigurationSetResult.ResultCode), + completionStatus.OpenConfigurationSetResult.Field, + completionStatus.OpenConfigurationSetResult.Value, + completionStatus.OpenConfigurationSetResult.Line, + completionStatus.OpenConfigurationSetResult.Column); + } + + SDK.ApplyConfigurationSetResult? sdkApplyConfigurationSetResult = null; + if (completionStatus.ApplyConfigurationSetResult != null) + { + var sdkUnitResults = new List(); + if (completionStatus.ApplyConfigurationSetResult.UnitResults != null) + { + foreach (var unitResult in completionStatus.ApplyConfigurationSetResult.UnitResults) + { + SDK.ConfigurationUnit? sdkUnit = ConfigurationUnit(unitResult.Unit); + + SDK.ConfigurationUnitResultInformation? sdkResultInfo = null; + if (unitResult.ResultInformation != null) + { + sdkResultInfo = new( + unitResult.ResultInformation.ResultCode == HRESULT.S_OK ? + null : + new HResultException(unitResult.ResultInformation.ResultCode, unitResult.ResultInformation.Description), + unitResult.ResultInformation.Description, + unitResult.ResultInformation.Details, + (SDK.ConfigurationUnitResultSource)unitResult.ResultInformation.ResultSource); + } + + var configurationUnitState = sdkUnit != null ? sdkUnit.State : SDK.ConfigurationUnitState.Unknown; + + sdkUnitResults.Add(new SDK.ApplyConfigurationUnitResult( + sdkUnit, + configurationUnitState, + unitResult.PreviouslyInDesiredState, + unitResult.RebootRequired, + sdkResultInfo)); + } + } + + sdkApplyConfigurationSetResult = new( + completionStatus.ApplyConfigurationSetResult.ResultCode == HRESULT.S_OK ? + null : + new HResultException(completionStatus.ApplyConfigurationSetResult.ResultCode), + sdkUnitResults.AsReadOnly()); + } + + var wasConfigurationSuccessful = completionStatus.ResultCode == HRESULT.S_OK; + var isUnitResultsPresent = sdkApplyConfigurationSetResult?.UnitResults?.Count > 0; + + // If there was no error in the completionStatus or there are unit results we'll say our operation was successful. + // Even if a unit result has errors, we will display this to the user. + if (wasConfigurationSuccessful || isUnitResultsPresent) + { + return new SDK.ApplyConfigurationResult(sdkOpenConfigurationSetResult, sdkApplyConfigurationSetResult); + } + + var hresultException = new HResultException(completionStatus.ResultCode); + + return new SDK.ApplyConfigurationResult(hresultException, completionStatus.ResultDescription, hresultException.Message); + } + + return null; + } + + private SDK.ConfigurationUnit? ConfigurationUnit(HostGuestCommunication.ConfigurationUnit? configurationUnit) + { + if (configurationUnit != null) + { + List? units = null; + if (configurationUnit.Units != null) + { + units = new(); + foreach (var hostAndGuestUnit in configurationUnit.Units) + { + units.Add(new( + hostAndGuestUnit.Type, + hostAndGuestUnit.Identifier, + (SDK.ConfigurationUnitState)hostAndGuestUnit.State, + hostAndGuestUnit.IsGroup, + null, + Dictionary2ValueSet(hostAndGuestUnit.Settings), + (SDK.ConfigurationUnitIntent)hostAndGuestUnit.Intent)); + } + } + + return new( + configurationUnit.Type, + configurationUnit.Identifier, + (SDK.ConfigurationUnitState)configurationUnit.State, + configurationUnit.IsGroup, + units, + Dictionary2ValueSet(configurationUnit.Settings), + (SDK.ConfigurationUnitIntent)configurationUnit.Intent); + } + + return null; + } + + private ValueSet? Dictionary2ValueSet(Dictionary? dictionary) + { + if (dictionary == null) + { + return null; + } + + var valueSet = new ValueSet(); + foreach (var kvp in dictionary) + { + valueSet.Add(kvp.Key, kvp.Value); + } + + return valueSet; + } + + public void Dispose() + { + Dispose(true); + GC.SuppressFinalize(this); + } + + private void Dispose(bool disposing) + { + if (!_disposed) + { + if (disposing) + { + _cancellationTokenSource?.Dispose(); + } + + _disposed = true; + } + } + + public IAsyncOperation StartAsync() + { + return Task.Run(() => + { + return _virtualMachine.ApplyConfiguration(this); + }).AsAsyncOperation(); + } +} diff --git a/HyperVExtension/src/HyperVExtension/CommunicationWithGuest/GuestKvpChannel.cs b/HyperVExtension/src/HyperVExtension/CommunicationWithGuest/GuestKvpChannel.cs new file mode 100644 index 0000000000..82c9b0d036 --- /dev/null +++ b/HyperVExtension/src/HyperVExtension/CommunicationWithGuest/GuestKvpChannel.cs @@ -0,0 +1,228 @@ +// Copyright (c) Microsoft Corporation. +// Licensed under the MIT License. + +using System.Globalization; +using System.Linq; +using System.Management; +using System.Runtime.InteropServices; +using System.Text; +using System.Threading; +using System.Xml; +using System.Xml.Linq; +using System.Xml.XPath; +using HyperVExtension.HostGuestCommunication; +using HyperVExtension.Providers; +using Microsoft.Win32; + +namespace HyperVExtension.CommunicationWithGuest; + +/// +/// Class used to handle communication with guest VM using Hyper-V KVP service. +/// +internal sealed class GuestKvpChannel : IDisposable +{ + // Public documentation doesn't say that there is a limit on the size of the value + // smaller than registry key values. But in the sample code for linux integration services + // HV_KVP_EXCHANGE_MAX_KEY_SIZE is used as a limit. In Windows code it's defined as 2048 (bytes). + // We'll need to split the message into smaller parts if it's too long. + private const int MaxValueCount = 1000; + private readonly Guid _vmId; + private readonly ManagementObject _virtualSystemService; + private readonly ManagementScope _scope; + private readonly ManagementObject _vmWmi; + private bool _disposed; + + public GuestKvpChannel(Guid vmId) + { + _vmId = vmId; + _scope = new ManagementScope(@"root\virtualization\v2", null); + _virtualSystemService = WmiUtility.GetServiceObject(_scope, "Msvm_VirtualSystemManagementService"); + _vmWmi = WmiUtility.GetTargetComputer(_vmId, _scope); + } + + public void SendMessage(IRequestMessage requestMessage, CancellationToken stoppingToken) + { + // Check if message is too large and split into multiple parts. + var numberOfParts = requestMessage.RequestData.Length / MaxValueCount; + if (requestMessage.RequestData.Length % MaxValueCount != 0) + { + numberOfParts++; + } + + var totalStr = $"{MessageHelper.Separator}{numberOfParts}"; + var index = 0; + foreach (var subString in requestMessage.RequestData.SplitByLength(MaxValueCount)) + { + index++; + SendMessage(requestMessage.RequestId + $"{MessageHelper.Separator}{index}" + totalStr, subString, stoppingToken); + } + } + + private void SendMessage(string name, string value, CancellationToken stoppingToken) + { + using ManagementClass kvpExchangeDataItem = new ManagementClass(_scope, new ManagementPath("Msvm_KvpExchangeDataItem"), null); + using ManagementObject dataItem = kvpExchangeDataItem.CreateInstance(); + dataItem["Data"] = value; + dataItem["Name"] = name; + dataItem["Source"] = 0; + + var dataItems = new string[1]; + dataItems[0] = dataItem.GetText(TextFormat.CimDtd20); + + using ManagementBaseObject inParams = _virtualSystemService.GetMethodParameters("AddKvpItems"); + inParams["TargetSystem"] = _vmWmi.Path.Path; + inParams["DataItems"] = dataItems; + + using ManagementBaseObject outParams = _virtualSystemService.InvokeMethod("AddKvpItems", inParams, null); + if ((uint)outParams["ReturnValue"] == (uint)WmiUtility.ReturnCode.Started) + { + uint errorCode; + string errorDescription; + if (!WmiUtility.JobCompleted(outParams, _scope, out errorCode, out errorDescription)) + { + throw new System.ComponentModel.Win32Exception((int)errorCode, $"Cannot send message to '{_vmId.ToString("D")}' VM: '{errorDescription}'."); + } + } + else if ((uint)outParams["ReturnValue"] != (uint)WmiUtility.ReturnCode.Completed) + { + throw new System.ComponentModel.Win32Exception((int)outParams["ReturnValue"], $"Cannot send message to '{_vmId.ToString("D")}' VM: '{outParams["ReturnValue"]}'."); + } + else + { + Logging.Logger()?.ReportInfo($"Sent message to '{_vmId.ToString("D")}' VM. Message ID: '{name}'."); + } + } + + public List WaitForResponseMessages(string responseId, TimeSpan timeout, bool expectProgressResponse, CancellationToken stoppingToken) + { + var waitTime = TimeSpan.FromMilliseconds(500); + var waitTimeLeft = timeout; + while ((waitTimeLeft > TimeSpan.Zero) && !stoppingToken.IsCancellationRequested) + { + var messages = TryReadResponseMessages(responseId, expectProgressResponse, stoppingToken); + if (messages.Count > 0) + { + return messages; + } + + stoppingToken.WaitHandle.WaitOne(waitTime); + waitTimeLeft -= waitTime; + } + + return new List(); + } + + private List TryReadResponseMessages(string responseId, bool expectProgressResponse, CancellationToken stoppingToken) + { + var guestKvps = ReadGuestKvps(); + var result = new List(); + guestKvps.TryGetValue(responseId, out var responseData); + + IResponseMessage? progressResponse = null; + if (responseData != null) + { + progressResponse = new ResponseMessage(responseId, responseData); + } + + if (!expectProgressResponse) + { + if (progressResponse != null) + { + result.Add(progressResponse); + } + } + else + { + // Find all progress message in "_Progress_" + // Then sort them by sequence number and add to the result list. + var progressResponseId = $"{responseId}_Progress_"; + var kvps = guestKvps.Where(kvp => kvp.Key.StartsWith(progressResponseId, StringComparison.OrdinalIgnoreCase)); + var orderedResponses = new Dictionary>(); + foreach (var kvp in kvps) + { + if (stoppingToken.IsCancellationRequested) + { + return result; + } + + var progressIdParts = kvp.Key.Split('_'); + if (uint.TryParse(progressIdParts[2], out var order)) + { + orderedResponses.Add(order, kvp); + } + } + + foreach (var kvp in orderedResponses.OrderBy(kvp => kvp.Key)) + { + if (stoppingToken.IsCancellationRequested) + { + return result; + } + + result.Add(new ResponseMessage(kvp.Value.Key, kvp.Value.Value)); + } + + // Progress messages first then the final response message. + if (progressResponse != null) + { + result.Add(progressResponse); + } + } + + return result; + } + + private Dictionary ReadGuestKvps() + { + return MessageHelper.MergeMessageParts(ReadRawGuestKvps()); + } + + private Dictionary ReadRawGuestKvps() + { + Dictionary guestKvps = new Dictionary(); + + using var collection = _vmWmi.GetRelated("Msvm_KvpExchangeComponent"); + foreach (ManagementObject kvpExchangeComponent in collection) + { + foreach (var exchangeDataItem in (string[])kvpExchangeComponent["GuestExchangeItems"]) + { + XPathDocument xpathDoc = new XPathDocument(XmlReader.Create(new StringReader(exchangeDataItem))); + XPathNavigator navigator = xpathDoc.CreateNavigator(); + XPathNavigator? navigatorName = navigator.SelectSingleNode("/INSTANCE/PROPERTY[@NAME='Name']/VALUE/child::text()"); + + if (navigatorName != null) + { + XPathNavigator? navigatorData = navigator.SelectSingleNode("/INSTANCE/PROPERTY[@NAME='Data']/VALUE/child::text()"); + if (navigatorData != null) + { + var name = navigatorName.Value.ToString(); + var value = navigatorData.Value.ToString(); + guestKvps[name] = value; + } + } + } + } + + return guestKvps; + } + + public void Dispose() + { + Dispose(true); + GC.SuppressFinalize(this); + } + + private void Dispose(bool disposing) + { + if (!_disposed) + { + if (disposing) + { + _vmWmi?.Dispose(); + _virtualSystemService?.Dispose(); + } + + _disposed = true; + } + } +} diff --git a/HyperVExtension/src/HyperVExtension/CommunicationWithGuest/GuestKvpSession.cs b/HyperVExtension/src/HyperVExtension/CommunicationWithGuest/GuestKvpSession.cs new file mode 100644 index 0000000000..d8c3f50d9b --- /dev/null +++ b/HyperVExtension/src/HyperVExtension/CommunicationWithGuest/GuestKvpSession.cs @@ -0,0 +1,82 @@ +// Copyright (c) Microsoft Corporation. +// Licensed under the MIT License. + +using System.Globalization; +using System.Linq; +using System.Management; +using System.Runtime.InteropServices; +using System.Threading; +using System.Xml; +using System.Xml.Linq; +using System.Xml.XPath; +using HyperVExtension.Providers; +using Microsoft.Win32; + +namespace HyperVExtension.CommunicationWithGuest; + +/// +/// Class to translate request/response objects to/from messages and pass them to/from channel to guest VM.. +/// +internal sealed class GuestKvpSession : IDisposable +{ + private readonly Guid _vmId; + private readonly GuestKvpChannel _channel; + private readonly ResponseFactory _responseFactory = new(); + private Dictionary _processedMessages = new(); + private bool _disposed; + + public GuestKvpSession(Guid vmId) + { + _vmId = vmId; + _channel = new GuestKvpChannel(vmId); + } + + public void SendRequest(IHostRequest request, CancellationToken stoppingToken) + { + _channel.SendMessage(request.GetRequestMessage(), stoppingToken); + } + + public List WaitForResponse(string responseId, TimeSpan timeout, bool expectProgressResponse, CancellationToken stoppingToken) + { + var result = new List(); + var responseMessages = _channel.WaitForResponseMessages(responseId, timeout, expectProgressResponse, stoppingToken); + + // There is no way for host to remove messages from guest kvp. So, we need to keep track of processed messages. + // If we find that we received the same message as in previous call of this method, we will ignore it. + // Host will send "AckRequest" to let guest know that it can remove the message from kvp. + var newProcessedMessages = new Dictionary(); + + foreach (var responseMessage in responseMessages) + { + if (!_processedMessages.ContainsKey(responseMessage.ResponseId)) + { + result.Add(_responseFactory.CreateResponse(responseMessage)); + _channel.SendMessage(new AckRequest(responseMessage.ResponseId).GetRequestMessage(), stoppingToken); + } + + newProcessedMessages[responseMessage.ResponseId] = responseMessage; + } + + _processedMessages = newProcessedMessages; + return result; + } + + public void Dispose() + { + Dispose(true); + GC.SuppressFinalize(this); + } + + private void Dispose(bool disposing) + { + if (!_disposed) + { + if (disposing) + { + _channel?.Dispose(); + } + + _disposed = true; + } + } +} diff --git a/HyperVExtension/src/HyperVExtension/CommunicationWithGuest/HResultException.cs b/HyperVExtension/src/HyperVExtension/CommunicationWithGuest/HResultException.cs new file mode 100644 index 0000000000..f812115e95 --- /dev/null +++ b/HyperVExtension/src/HyperVExtension/CommunicationWithGuest/HResultException.cs @@ -0,0 +1,13 @@ +// Copyright (c) Microsoft Corporation. +// Licensed under the MIT License. + +namespace HyperVExtension.CommunicationWithGuest; + +internal sealed class HResultException : Exception +{ + public HResultException(int resultCode, string? description = null) + : base(description) + { + HResult = resultCode; + } +} diff --git a/HyperVExtension/src/HyperVExtension/CommunicationWithGuest/Requests/AckRequest.cs b/HyperVExtension/src/HyperVExtension/CommunicationWithGuest/Requests/AckRequest.cs new file mode 100644 index 0000000000..3365bf58fc --- /dev/null +++ b/HyperVExtension/src/HyperVExtension/CommunicationWithGuest/Requests/AckRequest.cs @@ -0,0 +1,25 @@ +// Copyright (c) Microsoft Corporation. +// Licensed under the MIT License. + +namespace HyperVExtension.CommunicationWithGuest; + +/// +/// Class to generate response to GetVersion request. +/// +internal sealed class AckRequest : RequestBase +{ + public AckRequest(string ackRequestId) + : base("Ack") + { + AckRequestId = ackRequestId; + GenerateJsonData(); + } + + public string AckRequestId { get; set; } + + protected override void GenerateJsonData() + { + base.GenerateJsonData(); + JsonData![nameof(AckRequestId)] = AckRequestId; + } +} diff --git a/HyperVExtension/src/HyperVExtension/CommunicationWithGuest/Requests/ConfigureRequest.cs b/HyperVExtension/src/HyperVExtension/CommunicationWithGuest/Requests/ConfigureRequest.cs new file mode 100644 index 0000000000..5979aa9d36 --- /dev/null +++ b/HyperVExtension/src/HyperVExtension/CommunicationWithGuest/Requests/ConfigureRequest.cs @@ -0,0 +1,27 @@ +// Copyright (c) Microsoft Corporation. +// Licensed under the MIT License. + +namespace HyperVExtension.CommunicationWithGuest; + +/// +/// Class to generate response to Configure request. +/// +internal sealed class ConfigureRequest : RequestBase +{ + private readonly string _configureYaml; + + public ConfigureRequest(string configureYaml) + : base("Configure") + { + _configureYaml = configureYaml; + GenerateJsonData(); + } + + protected override void GenerateJsonData() + { + base.GenerateJsonData(); + + var noNewLinesYaml = _configureYaml.Replace(System.Environment.NewLine, "\\n"); + JsonData!["Configure"] = noNewLinesYaml; + } +} diff --git a/HyperVExtension/src/HyperVExtension/CommunicationWithGuest/Requests/GetVersionRequest.cs b/HyperVExtension/src/HyperVExtension/CommunicationWithGuest/Requests/GetVersionRequest.cs new file mode 100644 index 0000000000..361a6aa0b1 --- /dev/null +++ b/HyperVExtension/src/HyperVExtension/CommunicationWithGuest/Requests/GetVersionRequest.cs @@ -0,0 +1,16 @@ +// Copyright (c) Microsoft Corporation. +// Licensed under the MIT License. + +namespace HyperVExtension.CommunicationWithGuest; + +/// +/// Class to generate response to GetVersion request. +/// +internal sealed class GetVersionRequest : RequestBase +{ + public GetVersionRequest() + : base("GetVersion") + { + GenerateJsonData(); + } +} diff --git a/HyperVExtension/src/HyperVExtension/CommunicationWithGuest/Requests/IHostRequest.cs b/HyperVExtension/src/HyperVExtension/CommunicationWithGuest/Requests/IHostRequest.cs new file mode 100644 index 0000000000..0732e2091a --- /dev/null +++ b/HyperVExtension/src/HyperVExtension/CommunicationWithGuest/Requests/IHostRequest.cs @@ -0,0 +1,20 @@ +// Copyright (c) Microsoft Corporation. +// Licensed under the MIT License. + +namespace HyperVExtension.CommunicationWithGuest; + +/// +/// Interface for creating response to host request. +/// +public interface IHostRequest +{ + string RequestId { get; set; } + + string RequestType { get; set; } + + uint Version { get; set; } + + DateTime Timestamp { get; set; } + + IRequestMessage GetRequestMessage(); +} diff --git a/HyperVExtension/src/HyperVExtension/CommunicationWithGuest/Requests/IRequestMessage.cs b/HyperVExtension/src/HyperVExtension/CommunicationWithGuest/Requests/IRequestMessage.cs new file mode 100644 index 0000000000..4086dfa0f2 --- /dev/null +++ b/HyperVExtension/src/HyperVExtension/CommunicationWithGuest/Requests/IRequestMessage.cs @@ -0,0 +1,14 @@ +// Copyright (c) Microsoft Corporation. +// Licensed under the MIT License. + +namespace HyperVExtension.CommunicationWithGuest; + +/// +/// Interface for providing request message data. +/// +public interface IRequestMessage +{ + string RequestId { get; set; } + + string RequestData { get; set; } +} diff --git a/HyperVExtension/src/HyperVExtension/CommunicationWithGuest/Requests/IsUserLoggedInRequest.cs b/HyperVExtension/src/HyperVExtension/CommunicationWithGuest/Requests/IsUserLoggedInRequest.cs new file mode 100644 index 0000000000..488c37d2d1 --- /dev/null +++ b/HyperVExtension/src/HyperVExtension/CommunicationWithGuest/Requests/IsUserLoggedInRequest.cs @@ -0,0 +1,16 @@ +// Copyright (c) Microsoft Corporation. +// Licensed under the MIT License. + +namespace HyperVExtension.CommunicationWithGuest; + +/// +/// Ask Hyper-V VM if user is logged in. +/// +internal sealed class IsUserLoggedInRequest : RequestBase +{ + public IsUserLoggedInRequest() + : base("IsUserLoggedIn") + { + GenerateJsonData(); + } +} diff --git a/HyperVExtension/src/HyperVExtension/CommunicationWithGuest/Requests/RequestBase.cs b/HyperVExtension/src/HyperVExtension/CommunicationWithGuest/Requests/RequestBase.cs new file mode 100644 index 0000000000..6624d44087 --- /dev/null +++ b/HyperVExtension/src/HyperVExtension/CommunicationWithGuest/Requests/RequestBase.cs @@ -0,0 +1,60 @@ +// Copyright (c) Microsoft Corporation. +// Licensed under the MIT License. + +using System.Text.Json.Nodes; + +namespace HyperVExtension.CommunicationWithGuest; + +/// +/// Base class for responses to the client. +/// JSON payload is generated in GenerateJsonData virtual method. +/// { +/// "RequestId": "DevSetup{10000000-1000-1000-1000-100000000000}", +/// "RequestType": "GetVersion", +/// "Timestamp":"2023-11-21T08:08:58.6287789Z", +/// "Version": 1, +/// +/// } +/// +internal class RequestBase : IHostRequest +{ + public RequestBase(string requestType) + { + Version = 1; // Update version when the response format changes and needs special handling based on version. + RequestId = $"DevSetup{{{Guid.NewGuid()}}}"; + RequestType = requestType; + Timestamp = DateTime.UtcNow; + } + + public virtual string RequestId { get; set; } + + public virtual string RequestType { get; set; } + + public virtual uint Version { get; set; } + + public virtual DateTime Timestamp { get; set; } + + public virtual IRequestMessage GetRequestMessage() + { + if (JsonData == null) + { + GenerateJsonData(); + } + + return new RequestMessage(RequestId, JsonData!.ToJsonString()); + } + + protected JsonNode? JsonData { get; private set; } + + protected virtual void GenerateJsonData() + { + var jsonData = new JsonObject + { + [nameof(Version)] = Version, + [nameof(RequestId)] = RequestId, + [nameof(RequestType)] = RequestType, + [nameof(Timestamp)] = Timestamp, + }; + JsonData = jsonData; + } +} diff --git a/HyperVExtension/src/HyperVExtension/CommunicationWithGuest/Requests/RequestMessage.cs b/HyperVExtension/src/HyperVExtension/CommunicationWithGuest/Requests/RequestMessage.cs new file mode 100644 index 0000000000..ae674b0bc6 --- /dev/null +++ b/HyperVExtension/src/HyperVExtension/CommunicationWithGuest/Requests/RequestMessage.cs @@ -0,0 +1,20 @@ +// Copyright (c) Microsoft Corporation. +// Licensed under the MIT License. + +namespace HyperVExtension.CommunicationWithGuest; + +/// +/// Response message data. +/// +internal struct RequestMessage : IRequestMessage +{ + public RequestMessage(string requestId, string requestData) + { + RequestId = requestId; + RequestData = requestData; + } + + public string RequestId { get; set; } + + public string RequestData { get; set; } +} diff --git a/HyperVExtension/src/HyperVExtension/CommunicationWithGuest/Responses/ConfigureProgressResponse.cs b/HyperVExtension/src/HyperVExtension/CommunicationWithGuest/Responses/ConfigureProgressResponse.cs new file mode 100644 index 0000000000..5da3ea8a24 --- /dev/null +++ b/HyperVExtension/src/HyperVExtension/CommunicationWithGuest/Responses/ConfigureProgressResponse.cs @@ -0,0 +1,42 @@ +// Copyright (c) Microsoft Corporation. +// Licensed under the MIT License. + +using System.Text.Json; +using System.Text.Json.Nodes; +using HyperVExtension.HostGuestCommunication; + +namespace HyperVExtension.CommunicationWithGuest; + +/// +/// Class used to handle progress response for Configure request (RequestType = Configure). +/// +internal sealed class ConfigureProgressResponse : ResponseBase +{ + public ConfigureProgressResponse(IResponseMessage responseMessage, JsonNode jsonData) + : base(responseMessage, jsonData) + { + ProgressCounter = GetRequiredUintValue(nameof(ProgressCounter)); + + // Calling JsonSerializer.Deserialize directly on JasonNode fails (why?), but deserializing + // from the original string works. + var configurationSetChangeDataNode = (string?)jsonData[nameof(ConfigurationSetChangeData)]; + if (configurationSetChangeDataNode == null) + { + // TODO: we may want to proceed without data and handle it later. That way calling code will know that + // Configure operation is completed. + throw new JsonException($"Missing {nameof(ConfigurationSetChangeData)} in JSON data."); + } + + var configurationSetChangeData = JsonSerializer.Deserialize(configurationSetChangeDataNode); + if (configurationSetChangeData == null) + { + throw new JsonException($"Failed to deserialize {nameof(ConfigurationSetChangeData)} from JSON data."); + } + + ProgressData = configurationSetChangeData; + } + + public ConfigurationSetChangeData ProgressData { get; } + + public uint ProgressCounter { get; } +} diff --git a/HyperVExtension/src/HyperVExtension/CommunicationWithGuest/Responses/ConfigureResponse.cs b/HyperVExtension/src/HyperVExtension/CommunicationWithGuest/Responses/ConfigureResponse.cs new file mode 100644 index 0000000000..6f8531f108 --- /dev/null +++ b/HyperVExtension/src/HyperVExtension/CommunicationWithGuest/Responses/ConfigureResponse.cs @@ -0,0 +1,38 @@ +// Copyright (c) Microsoft Corporation. +// Licensed under the MIT License. + +using System.Text.Json; +using System.Text.Json.Nodes; +using HyperVExtension.HostGuestCommunication; + +namespace HyperVExtension.CommunicationWithGuest; + +/// +/// Class used to handle response for Configure request (RequestType = Configure). +/// +internal sealed class ConfigureResponse : ResponseBase +{ + public ConfigureResponse(IResponseMessage responseMessage, JsonNode jsonData) + : base(responseMessage, jsonData) + { + // Calling JsonSerializer.Deserialize directly on JasonNode fails (why?), but deserializing + // from the original string works. + var applyConfigurationResultNode = (string?)jsonData[nameof(ApplyConfigurationResult)]; + if (applyConfigurationResultNode == null) + { + // TODO: we may want to proceed without data and handle it later. That way calling code will know that + // Configure operation is completed. + throw new JsonException($"Missing {nameof(ApplyConfigurationResult)} in JSON data."); + } + + var applyConfigurationResult = JsonSerializer.Deserialize(applyConfigurationResultNode); + if (applyConfigurationResult == null) + { + throw new JsonException($"Failed to deserialize {nameof(ApplyConfigurationResult)} from JSON data."); + } + + ApplyConfigurationResult = applyConfigurationResult; + } + + public ApplyConfigurationResult ApplyConfigurationResult { get; } +} diff --git a/HyperVExtension/src/HyperVExtension/CommunicationWithGuest/Responses/ErrorNoTypeResponse.cs b/HyperVExtension/src/HyperVExtension/CommunicationWithGuest/Responses/ErrorNoTypeResponse.cs new file mode 100644 index 0000000000..1a8ad48c81 --- /dev/null +++ b/HyperVExtension/src/HyperVExtension/CommunicationWithGuest/Responses/ErrorNoTypeResponse.cs @@ -0,0 +1,34 @@ +// Copyright (c) Microsoft Corporation. +// Licensed under the MIT License. + +namespace HyperVExtension.CommunicationWithGuest; + +/// +/// Class used to handle requests that have no request type. +/// It creates an error response JSON to send back to the client. +/// +internal sealed class ErrorNoTypeResponse : IGuestResponse +{ + public ErrorNoTypeResponse(IResponseMessage message) + { + Timestamp = DateTime.UtcNow; + ResponseId = message.ResponseId!; + RequestId = message.ResponseId!; + } + + public string RequestId { get; set; } + + public string RequestType { get; set; } = ""; + + public string ResponseId { get; set; } + + public string ResponseType { get; set; } = ""; + + public uint Status { get; set; } = 0xFFFFFFFF; + + public string ErrorDescription { get; set; } = "Missing Response or Request type."; + + public uint Version { get; set; } = 1; + + public DateTime Timestamp { get; set; } +} diff --git a/HyperVExtension/src/HyperVExtension/CommunicationWithGuest/Responses/ErrorResponse.cs b/HyperVExtension/src/HyperVExtension/CommunicationWithGuest/Responses/ErrorResponse.cs new file mode 100644 index 0000000000..11ee5efa28 --- /dev/null +++ b/HyperVExtension/src/HyperVExtension/CommunicationWithGuest/Responses/ErrorResponse.cs @@ -0,0 +1,36 @@ +// Copyright (c) Microsoft Corporation. +// Licensed under the MIT License. + +using System.Text.Json.Nodes; +using HyperVExtension.CommunicationWithGuest; + +namespace HyperVExtension.CommunicationWithGuest; + +/// +/// Class used to handle invalid requests (for example an exception while parsing request JSON). +/// It creates an error response to send back to the client. +/// +internal sealed class ErrorResponse : IGuestResponse +{ + public ErrorResponse(IResponseMessage responseMessage) + { + ResponseId = responseMessage.ResponseId!; + Timestamp = DateTime.UtcNow; + } + + public string RequestId { get; set; } = ""; + + public string RequestType { get; set; } = ""; + + public string ResponseId { get; set; } + + public string ResponseType { get; set; } = "ErrorNoData"; + + public uint Status { get; set; } = 0x80004005; // E_FAIL + + public string ErrorDescription { get; set; } = "Missing Request data."; + + public uint Version { get; set; } = 1; + + public DateTime Timestamp { get; set; } +} diff --git a/HyperVExtension/src/HyperVExtension/CommunicationWithGuest/Responses/ErrorUnsupportedResponse.cs b/HyperVExtension/src/HyperVExtension/CommunicationWithGuest/Responses/ErrorUnsupportedResponse.cs new file mode 100644 index 0000000000..35f1b8b357 --- /dev/null +++ b/HyperVExtension/src/HyperVExtension/CommunicationWithGuest/Responses/ErrorUnsupportedResponse.cs @@ -0,0 +1,17 @@ +// Copyright (c) Microsoft Corporation. +// Licensed under the MIT License. + +using System.Text.Json.Nodes; + +namespace HyperVExtension.CommunicationWithGuest; + +/// +/// Class used to handle unsupported requests. +/// +internal sealed class ErrorUnsupportedResponse : ResponseBase +{ + public ErrorUnsupportedResponse(IResponseMessage responseMessage, JsonNode jsonData) + : base(responseMessage, jsonData) + { + } +} diff --git a/HyperVExtension/src/HyperVExtension/CommunicationWithGuest/Responses/GetVersionResponse.cs b/HyperVExtension/src/HyperVExtension/CommunicationWithGuest/Responses/GetVersionResponse.cs new file mode 100644 index 0000000000..0ccc06be85 --- /dev/null +++ b/HyperVExtension/src/HyperVExtension/CommunicationWithGuest/Responses/GetVersionResponse.cs @@ -0,0 +1,17 @@ +// Copyright (c) Microsoft Corporation. +// Licensed under the MIT License. + +using System.Text.Json.Nodes; + +namespace HyperVExtension.CommunicationWithGuest; + +/// +/// Class used to handle response for service version (RequestType = GetVersion). +/// +internal sealed class GetVersionResponse : ResponseBase +{ + public GetVersionResponse(IResponseMessage responseMessage, JsonNode jsonData) + : base(responseMessage, jsonData) + { + } +} diff --git a/HyperVExtension/src/HyperVExtension/CommunicationWithGuest/Responses/IGuestResponse.cs b/HyperVExtension/src/HyperVExtension/CommunicationWithGuest/Responses/IGuestResponse.cs new file mode 100644 index 0000000000..d6f8c35cc3 --- /dev/null +++ b/HyperVExtension/src/HyperVExtension/CommunicationWithGuest/Responses/IGuestResponse.cs @@ -0,0 +1,26 @@ +// Copyright (c) Microsoft Corporation. +// Licensed under the MIT License. + +namespace HyperVExtension.CommunicationWithGuest; + +/// +/// Interface for creating response to host request. +/// +public interface IGuestResponse +{ + uint Version { get; set; } + + string RequestId { get; set; } + + string RequestType { get; set; } + + string ResponseId { get; set; } + + string ResponseType { get; set; } + + uint Status { get; set; } + + string ErrorDescription { get; set; } + + DateTime Timestamp { get; set; } +} diff --git a/HyperVExtension/src/HyperVExtension/CommunicationWithGuest/Responses/IResponseFactory.cs b/HyperVExtension/src/HyperVExtension/CommunicationWithGuest/Responses/IResponseFactory.cs new file mode 100644 index 0000000000..72ef912bdf --- /dev/null +++ b/HyperVExtension/src/HyperVExtension/CommunicationWithGuest/Responses/IResponseFactory.cs @@ -0,0 +1,12 @@ +// Copyright (c) Microsoft Corporation. +// Licensed under the MIT License. + +namespace HyperVExtension.CommunicationWithGuest; + +/// +/// Interface for creating response handler based on response message. +/// +public interface IResponseFactory +{ + IGuestResponse CreateResponse(IResponseMessage message); +} diff --git a/HyperVExtension/src/HyperVExtension/CommunicationWithGuest/Responses/IResponseMessage.cs b/HyperVExtension/src/HyperVExtension/CommunicationWithGuest/Responses/IResponseMessage.cs new file mode 100644 index 0000000000..1ab2f53cc2 --- /dev/null +++ b/HyperVExtension/src/HyperVExtension/CommunicationWithGuest/Responses/IResponseMessage.cs @@ -0,0 +1,14 @@ +// Copyright (c) Microsoft Corporation. +// Licensed under the MIT License. + +namespace HyperVExtension.CommunicationWithGuest; + +/// +/// Interface for providing response message data. +/// +public interface IResponseMessage +{ + string ResponseId { get; set; } + + string ResponseData { get; set; } +} diff --git a/HyperVExtension/src/HyperVExtension/CommunicationWithGuest/Responses/IsUserLoggedInResponse.cs b/HyperVExtension/src/HyperVExtension/CommunicationWithGuest/Responses/IsUserLoggedInResponse.cs new file mode 100644 index 0000000000..930abab619 --- /dev/null +++ b/HyperVExtension/src/HyperVExtension/CommunicationWithGuest/Responses/IsUserLoggedInResponse.cs @@ -0,0 +1,22 @@ +// Copyright (c) Microsoft Corporation. +// Licensed under the MIT License. + +using System.Text.Json.Nodes; + +namespace HyperVExtension.CommunicationWithGuest; + +/// +/// Class used to handle response for IsUserLoggedInRequest. +/// +internal sealed class IsUserLoggedInResponse : ResponseBase +{ + public IsUserLoggedInResponse(IResponseMessage responseMessage, JsonNode jsonData) + : base(responseMessage, jsonData) + { + IsUserLoggedIn = GetRequiredBoolValue(nameof(IsUserLoggedIn)); + } + + public bool IsUserLoggedIn { get; internal set; } + + public List LoggedInUsers { get; internal set; } = new List(); +} diff --git a/HyperVExtension/src/HyperVExtension/CommunicationWithGuest/Responses/ResponseBase.cs b/HyperVExtension/src/HyperVExtension/CommunicationWithGuest/Responses/ResponseBase.cs new file mode 100644 index 0000000000..240f0dd5ee --- /dev/null +++ b/HyperVExtension/src/HyperVExtension/CommunicationWithGuest/Responses/ResponseBase.cs @@ -0,0 +1,115 @@ +// Copyright (c) Microsoft Corporation. +// Licensed under the MIT License. + +using System.Text.Json.Nodes; + +namespace HyperVExtension.CommunicationWithGuest; + +/// +/// Base class for responses to the client. +/// JSON payload is generated in GenerateJsonData virtual method. +/// { +/// "ResponseId": "DevSetup{10000000-1000-1000-1000-100000000000}", +/// "ResponseType": "GetVersion", +/// "Timestamp":"2023-11-21T08:08:58.6287789Z", +/// "Version": "1", +/// +/// } +/// +internal class ResponseBase : IGuestResponse +{ + public ResponseBase(IResponseMessage message, JsonNode jsonData) + { + ResponseMessage = message; + JsonData = jsonData; + RequestId = GetRequiredStringValue(nameof(RequestId)); + RequestType = GetRequiredStringValue(nameof(RequestType)); + ResponseId = GetRequiredStringValue(nameof(ResponseId)); + ResponseType = GetRequiredStringValue(nameof(ResponseType)); + Version = GetRequiredUintValue(nameof(Version)); + Timestamp = GetRequiredDateTimeValue(nameof(Timestamp)); + Status = GetRequiredUintValue(nameof(Status)); + ErrorDescription = GetRequiredStringValue(nameof(ErrorDescription), true); + } + + public IResponseMessage ResponseMessage + { + get; + } + + public JsonNode JsonData + { + get; + } + + public virtual string RequestId { get; set; } + + public virtual string RequestType { get; set; } + + public virtual string ResponseId { get; set; } + + public virtual string ResponseType { get; set; } + + public virtual uint Status { get; set; } + + public virtual string ErrorDescription { get; set; } + + public virtual uint Version { get; set; } + + public virtual DateTime Timestamp { get; set; } + + protected string GetRequiredStringValue(string valueName, bool emptyIsOk = false) + { + try + { + var value = (string?)JsonData[valueName]; + var isValid = emptyIsOk ? value != null : !string.IsNullOrEmpty(value); + if (!isValid) + { + throw new ArgumentException($"{valueName} cannot be empty."); + } + + return value!; + } + catch (Exception ex) + { + throw new ArgumentException($"{valueName} cannot be empty.", ex); + } + } + + protected DateTime GetRequiredDateTimeValue(string valueName) + { + try + { + return (DateTime?)JsonData[valueName] ?? throw new ArgumentException($"{valueName} cannot be empty."); + } + catch (Exception ex) + { + throw new ArgumentException($"{valueName} cannot be empty.", ex); + } + } + + protected uint GetRequiredUintValue(string valueName) + { + try + { + return (uint?)JsonData[valueName] ?? throw new ArgumentException($"{valueName} cannot be empty."); + } + catch (Exception ex) + { + throw new ArgumentException($"{valueName} cannot be empty.", ex); + } + } + + protected bool GetRequiredBoolValue(string valueName) + { + try + { + return (bool?)JsonData[valueName] ?? throw new ArgumentException($"{valueName} cannot be empty."); + } + catch (Exception ex) + { + throw new ArgumentException($"{valueName} cannot be empty.", ex); + } + } +} diff --git a/HyperVExtension/src/HyperVExtension/CommunicationWithGuest/Responses/ResponseFactory.cs b/HyperVExtension/src/HyperVExtension/CommunicationWithGuest/Responses/ResponseFactory.cs new file mode 100644 index 0000000000..af738f080b --- /dev/null +++ b/HyperVExtension/src/HyperVExtension/CommunicationWithGuest/Responses/ResponseFactory.cs @@ -0,0 +1,72 @@ +// Copyright (c) Microsoft Corporation. +// Licensed under the MIT License. + +using System.Text.Json; +using System.Text.Json.Nodes; +using HyperVExtension.Providers; + +namespace HyperVExtension.CommunicationWithGuest; + +/// +/// Factory class for creating request handler based on request message. +/// +public class ResponseFactory : IResponseFactory +{ + public delegate IGuestResponse CreateRequestDelegate(IResponseMessage message, JsonNode json); + + private static readonly Dictionary<(string, string), CreateRequestDelegate> _responseFactories = new() + { + // TODO: Define request type constants in one place + { ("Completed", "GetVersion"), (message, json) => new GetVersionResponse(message, json) }, + { ("Completed", "Configure"), (message, json) => new ConfigureResponse(message, json) }, + { ("Progress", "Configure"), (message, json) => new ConfigureProgressResponse(message, json) }, + { ("Completed", "IsUserLoggedIn"), (message, json) => new IsUserLoggedInResponse(message, json) }, + }; + + public ResponseFactory() + { + } + + public IGuestResponse CreateResponse(IResponseMessage message) + { + // Parse message.RequestData and create appropriate request object + try + { + if (!string.IsNullOrEmpty(message.ResponseData)) + { + Logging.Logger()?.ReportInfo($"Received message: ID: '{message.ResponseId}' Data: '{message.ResponseData}'"); + var responseJson = JsonNode.Parse(message.ResponseData); + var responseType = (string?)responseJson?["ResponseType"]; + var requestType = (string?)responseJson?["RequestType"]; + if ((responseType != null) && (requestType != null)) + { + if (_responseFactories.TryGetValue((responseType!, requestType!), out var createResponse)) + { + // TODO: Try/catch error. + return createResponse(message, responseJson!); + } + else + { + return new ErrorUnsupportedResponse(message, responseJson!); + } + } + + Logging.Logger()?.ReportInfo($"Received message with empty Response or Request type: ID: '{message.ResponseId}', Message: '{message.ResponseData}'"); + return new ErrorNoTypeResponse(message); + } + else + { + // We have message id but no data, log error. Send error response. + Logging.Logger()?.ReportInfo($"Received message with empty data: ID: {message.ResponseId}"); + return new ErrorResponse(message); + } + } + catch (Exception ex) + { + var messageId = message?.ResponseId ?? ""; + var responseData = message?.ResponseData ?? ""; + Logging.Logger()?.ReportError($"Error processing message. Message ID: {messageId}. Request data: {responseData}", ex); + return new ErrorResponse(message!); + } + } +} diff --git a/HyperVExtension/src/HyperVExtension/CommunicationWithGuest/Responses/ResponseMessage.cs b/HyperVExtension/src/HyperVExtension/CommunicationWithGuest/Responses/ResponseMessage.cs new file mode 100644 index 0000000000..1fc019e3f7 --- /dev/null +++ b/HyperVExtension/src/HyperVExtension/CommunicationWithGuest/Responses/ResponseMessage.cs @@ -0,0 +1,20 @@ +// Copyright (c) Microsoft Corporation. +// Licensed under the MIT License. + +namespace HyperVExtension.CommunicationWithGuest; + +/// +/// Response message data. +/// +internal struct ResponseMessage : IResponseMessage +{ + public ResponseMessage(string requestId, string responseData) + { + ResponseId = requestId; + ResponseData = responseData; + } + + public string ResponseId { get; set; } + + public string ResponseData { get; set; } +} diff --git a/HyperVExtension/src/HyperVExtension/CommunicationWithGuest/WmiUtility.cs b/HyperVExtension/src/HyperVExtension/CommunicationWithGuest/WmiUtility.cs new file mode 100644 index 0000000000..c540f43930 --- /dev/null +++ b/HyperVExtension/src/HyperVExtension/CommunicationWithGuest/WmiUtility.cs @@ -0,0 +1,129 @@ +// Copyright (c) Microsoft Corporation. +// Licensed under the MIT License. + +using System.Globalization; +using System.Management; +using System.Management.Automation; +using System.Web.Services.Description; +using HyperVExtension.Providers; +using Windows.Win32.Foundation; + +namespace HyperVExtension.CommunicationWithGuest; + +internal sealed class WmiUtility +{ + public enum ReturnCode : uint + { + Completed = 0, + Started = 4096, + Failed = 32768, + AccessDenied = 32769, + NotSupported = 32770, + Unknown = 32771, + Timeout = 32772, + InvalidParameter = 32773, + SystemInUse = 32774, + InvalidState = 32775, + IncorrectDataType = 32776, + SystemNotAvailable = 32777, + OutofMemory = 32778, + } + + public enum JobState : ushort + { + New = 2, + Starting = 3, + Running = 4, + Suspended = 5, + ShuttingDown = 6, + Completed = 7, + Terminated = 8, + Killed = 9, + Exception = 10, + Service = 11, + } + + /// + /// Common utility function to get a service object + /// + public static ManagementObject GetServiceObject(ManagementScope scope, string serviceName) + { + scope.Connect(); + ManagementPath wmiPath = new ManagementPath(serviceName); + ManagementClass serviceClass = new ManagementClass(scope, wmiPath, null); + ManagementObjectCollection services = serviceClass.GetInstances(); + + if (services.Count == 0) + { + throw new System.ComponentModel.Win32Exception((int)WIN32_ERROR.ERROR_NOT_FOUND, $"Cannot instantiate '{serviceName}'."); + } + + ManagementObject? serviceObject = null; + + foreach (ManagementObject service in services) + { + serviceObject = service; + } + + return serviceObject!; + } + + public static ManagementObject GetTargetComputer(Guid vmId, ManagementScope scope) + { + var query = $"select * from Msvm_ComputerSystem Where Name = '{vmId.ToString("D")}'"; + + ManagementObjectSearcher searcher = new ManagementObjectSearcher(scope, new ObjectQuery(query)); + + ManagementObjectCollection computers = searcher.Get(); + + if (computers.Count == 0) + { + throw new System.ComponentModel.Win32Exception((int)WIN32_ERROR.ERROR_NOT_FOUND, $"Cannot find target computer '{vmId.ToString("D")}'."); + } + + ManagementObject? computer = null; + + foreach (ManagementObject instance in computers) + { + computer = instance; + break; + } + + return computer!; + } + + public static bool JobCompleted(ManagementBaseObject outParams, ManagementScope scope, out uint errorCode, out string errorDescription) + { + errorCode = 0; + errorDescription = string.Empty; + + // Retrieve msvc_StorageJob path. This is a full wmi path + var jobPath = (string)outParams["Job"]; + ManagementObject job = new ManagementObject(scope, new ManagementPath(jobPath), null); + + // Try to get storage job information + job.Get(); + while ((ushort)job["JobState"] == (ushort)JobState.Starting + || (ushort)job["JobState"] == (ushort)JobState.Running) + { + Logging.Logger()?.ReportInfo($"WMI job in progress... {job["PercentComplete"]}% completed."); + Thread.Sleep(300); + job.Get(); + } + + // Figure out if job failed + var jobCompleted = true; + var jobState = (ushort)job["JobState"]; + if (jobState != (ushort)JobState.Completed) + { + errorCode = (ushort)job["ErrorCode"]; + errorDescription = (string)job["ErrorDescription"]; + Logging.Logger()?.ReportError($"WMI job state: {jobState}."); + Logging.Logger()?.ReportError($"WMI job error: {errorCode}."); + Logging.Logger()?.ReportError($"WMI job error description: {errorDescription}."); + jobCompleted = false; + } + + return jobCompleted; + } +} diff --git a/HyperVExtension/src/HyperVExtension/Constants.cs b/HyperVExtension/src/HyperVExtension/Constants.cs new file mode 100644 index 0000000000..8a9971b66a --- /dev/null +++ b/HyperVExtension/src/HyperVExtension/Constants.cs @@ -0,0 +1,25 @@ +// Copyright (c) Microsoft Corporation. +// Licensed under the MIT License. + +namespace HyperVExtension; + +internal sealed class Constants +{ + public const string WindowsThumbnail = "ms-appx:///HyperVExtension/Assets/hyper-v-windows-default-image.jpg"; + +// We use different icon locations for different builds. Note these are ms-resource URIs, but are used by Dev Home to load the providers icon. +// from the extension package. Extensions that implement the IComputeSystemProvider interface must provide a provider icon in this format. +// Dev Home will use SHLoadIndirectString (https://learn.microsoft.com/windows/win32/api/shlwapi/nf-shlwapi-shloadindirectstring) to load the +// location of the icon from the extension package.Once it gets this location, it will load the icon from the path and display it in the UI. +// Icons should be located in an extension resource.pri file which is generated at build time. +// See the MakePri.exe documentation for how you can view what is in the resource.pri file, so you can find the location of your icon. +// https://learn.microsoft.com/en-us/windows/uwp/app-resources/makepri-exe-command-options. (use MakePri.exe in a VS Developer Command Prompt or +// Powershell window) +#if CANARY_BUILD + public const string ExtensionIcon = "ms-resource://Microsoft.Windows.DevHome.Canary/Files/HyperVExtension/Assets/hyper-v-provider-icon.png"; +#elif STABLE_BUILD + public const string ExtensionIcon = "ms-resource://Microsoft.Windows.DevHome/Files/HyperVExtension/Assets/hyper-v-provider-icon.png"; +#else + public const string ExtensionIcon = "ms-resource://Microsoft.Windows.DevHome.Dev/Files/HyperVExtension/Assets/hyper-v-provider-icon.png"; +#endif +} diff --git a/HyperVExtension/src/HyperVExtension/Exceptions/ComputeSystemOperationException.cs b/HyperVExtension/src/HyperVExtension/Exceptions/ComputeSystemOperationException.cs new file mode 100644 index 0000000000..c188a50076 --- /dev/null +++ b/HyperVExtension/src/HyperVExtension/Exceptions/ComputeSystemOperationException.cs @@ -0,0 +1,30 @@ +// Copyright (c) Microsoft Corporation. +// Licensed under the MIT License. + +using System.Globalization; +using Microsoft.Windows.DevHome.SDK; + +namespace HyperVExtension.Exceptions; + +public class ComputeSystemOperationException : Exception +{ + // This is used in times when the Hyper-V manager failed to perform an operation and did not receive an error from PowerShell. + // This shouldn't happen, but in case it does, check the state of the virtual machine when the operation was requested + // for debugging clues. + private const string ErrorMessage = "operation failed but no PowerShell error was received. Check the state of the virtual machine."; + + public ComputeSystemOperationException(ComputeSystemOperations operation) + : base($"{operation} {ErrorMessage}") + { + } + + public ComputeSystemOperationException(string message) + : base(message) + { + } + + public ComputeSystemOperationException(string? message, Exception? innerException) + : base(message, innerException) + { + } +} diff --git a/HyperVExtension/src/HyperVExtension/Exceptions/HyperVAdminGroupException.cs b/HyperVExtension/src/HyperVExtension/Exceptions/HyperVAdminGroupException.cs new file mode 100644 index 0000000000..8ee5ed62f5 --- /dev/null +++ b/HyperVExtension/src/HyperVExtension/Exceptions/HyperVAdminGroupException.cs @@ -0,0 +1,18 @@ +// Copyright (c) Microsoft Corporation. +// Licensed under the MIT License. + +using System; +using System.Collections.Generic; +using System.Linq; +using System.Text; +using System.Threading.Tasks; + +namespace HyperVExtension.Exceptions; + +public class HyperVAdminGroupException : HyperVManagerException +{ + public HyperVAdminGroupException(string message) + : base(message) + { + } +} diff --git a/HyperVExtension/src/HyperVExtension/Exceptions/HyperVManagerException.cs b/HyperVExtension/src/HyperVExtension/Exceptions/HyperVManagerException.cs new file mode 100644 index 0000000000..59656c8c37 --- /dev/null +++ b/HyperVExtension/src/HyperVExtension/Exceptions/HyperVManagerException.cs @@ -0,0 +1,23 @@ +// Copyright (c) Microsoft Corporation. +// Licensed under the MIT License. + +using System; +using System.Collections.Generic; +using System.Linq; +using System.Text; +using System.Threading.Tasks; + +namespace HyperVExtension.Exceptions; + +public class HyperVManagerException : Exception +{ + public HyperVManagerException(string? message) + : base(message) + { + } + + public HyperVManagerException(string? message, Exception? innerException) + : base(message, innerException) + { + } +} diff --git a/HyperVExtension/src/HyperVExtension/Exceptions/HyperVModuleNotLoadedException.cs b/HyperVExtension/src/HyperVExtension/Exceptions/HyperVModuleNotLoadedException.cs new file mode 100644 index 0000000000..138e8ec9b7 --- /dev/null +++ b/HyperVExtension/src/HyperVExtension/Exceptions/HyperVModuleNotLoadedException.cs @@ -0,0 +1,18 @@ +// Copyright (c) Microsoft Corporation. +// Licensed under the MIT License. + +using System; +using System.Collections.Generic; +using System.Linq; +using System.Text; +using System.Threading.Tasks; + +namespace HyperVExtension.Exceptions; + +public class HyperVModuleNotLoadedException : HyperVManagerException +{ + public HyperVModuleNotLoadedException(string message) + : base(message) + { + } +} diff --git a/HyperVExtension/src/HyperVExtension/Exceptions/HyperVVirtualMachineManagementException.cs b/HyperVExtension/src/HyperVExtension/Exceptions/HyperVVirtualMachineManagementException.cs new file mode 100644 index 0000000000..78c46f8c63 --- /dev/null +++ b/HyperVExtension/src/HyperVExtension/Exceptions/HyperVVirtualMachineManagementException.cs @@ -0,0 +1,12 @@ +// Copyright (c) Microsoft Corporation. +// Licensed under the MIT License. + +namespace HyperVExtension.Exceptions; + +public class HyperVVirtualMachineManagementException : HyperVManagerException +{ + public HyperVVirtualMachineManagementException(string message) + : base(message) + { + } +} diff --git a/HyperVExtension/src/HyperVExtension/Extensions/DevSetupAgentDeploymentException.cs b/HyperVExtension/src/HyperVExtension/Extensions/DevSetupAgentDeploymentException.cs new file mode 100644 index 0000000000..6df3e4972c --- /dev/null +++ b/HyperVExtension/src/HyperVExtension/Extensions/DevSetupAgentDeploymentException.cs @@ -0,0 +1,17 @@ +// Copyright (c) Microsoft Corporation. +// Licensed under the MIT License. + +namespace HyperVExtension.Exceptions; + +public class DevSetupAgentDeploymentException : Exception +{ + public DevSetupAgentDeploymentException(string? message) + : base(message) + { + } + + public DevSetupAgentDeploymentException(string? message, Exception? innerException) + : base(message, innerException) + { + } +} diff --git a/HyperVExtension/src/HyperVExtension/Extensions/DevSetupAgentDeploymentSessionException.cs b/HyperVExtension/src/HyperVExtension/Extensions/DevSetupAgentDeploymentSessionException.cs new file mode 100644 index 0000000000..55fc3d3dab --- /dev/null +++ b/HyperVExtension/src/HyperVExtension/Extensions/DevSetupAgentDeploymentSessionException.cs @@ -0,0 +1,17 @@ +// Copyright (c) Microsoft Corporation. +// Licensed under the MIT License. + +namespace HyperVExtension.Exceptions; + +public class DevSetupAgentDeploymentSessionException : Exception +{ + public DevSetupAgentDeploymentSessionException(string? message) + : base(message) + { + } + + public DevSetupAgentDeploymentSessionException(string? message, Exception? innerException) + : base(message, innerException) + { + } +} diff --git a/HyperVExtension/src/HyperVExtension/Extensions/ServiceExtensions.cs b/HyperVExtension/src/HyperVExtension/Extensions/ServiceExtensions.cs new file mode 100644 index 0000000000..215ddf5c49 --- /dev/null +++ b/HyperVExtension/src/HyperVExtension/Extensions/ServiceExtensions.cs @@ -0,0 +1,33 @@ +// Copyright (c) Microsoft Corporation. +// Licensed under the MIT License. + +using HyperVExtension.Models; +using HyperVExtension.Providers; +using HyperVExtension.Services; +using Microsoft.Extensions.DependencyInjection; +using Microsoft.Extensions.Hosting; +using Microsoft.Windows.DevHome.SDK; + +namespace HyperVExtension.Extensions; + +public static class ServiceExtensions +{ + public static IServiceCollection AddHyperVExtensionServices(this IServiceCollection services, HostBuilderContext context) + { + // Instances + services.AddTransient(); + + // Services + services.AddSingleton(); + services.AddSingleton(); + services.AddSingleton(); + services.AddSingleton(); + + // Pattern to allow multiple non-service registered interfaces to be used with registered interfaces during construction. + services.AddSingleton(psService => + ActivatorUtilities.CreateInstance(psService, new PowerShellSession())); + services.AddSingleton(sp => psObject => ActivatorUtilities.CreateInstance(sp, psObject)); + + return services; + } +} diff --git a/HyperVExtension/src/HyperVExtension/Helpers/AdaptiveCardActionPayload.cs b/HyperVExtension/src/HyperVExtension/Helpers/AdaptiveCardActionPayload.cs new file mode 100644 index 0000000000..c98caa4f56 --- /dev/null +++ b/HyperVExtension/src/HyperVExtension/Helpers/AdaptiveCardActionPayload.cs @@ -0,0 +1,59 @@ +// Copyright (c) Microsoft Corporation. +// Licensed under the MIT License. + +using System.Globalization; + +namespace HyperVExtension.Helpers; + +internal sealed class AdaptiveCardActionPayload +{ + public string? Id + { + get; set; + } + + public string? Style + { + get; set; + } + + public string? ToolTip + { + get; set; + } + + public string? Title + { + get; set; + } + + public string? Type + { + get; set; + } + + public bool IsCancelAction() + { + return Id == "cancelAction"; + } + + public bool IsOkAction() + { + return Id == "okAction"; + } + + public bool IsUrlAction() + { + return Type == "Action.OpenUrl"; + } + + public bool IsSubmitAction() + { + return Type == "Action.Submit"; + } + + public bool IsExecuteAction() + { + return Type == "Action.Execute"; + } +} diff --git a/HyperVExtension/src/HyperVExtension/Helpers/BytesHelper.cs b/HyperVExtension/src/HyperVExtension/Helpers/BytesHelper.cs new file mode 100644 index 0000000000..ae650f2007 --- /dev/null +++ b/HyperVExtension/src/HyperVExtension/Helpers/BytesHelper.cs @@ -0,0 +1,36 @@ +// Copyright (c) Microsoft Corporation. +// Licensed under the MIT License. + +using System.Globalization; + +namespace HyperVExtension.Helpers; + +public static class BytesHelper +{ + public const decimal OneKbInBytes = 1ul << 10; + + public const decimal OneMbInBytes = 1ul << 20; + + public const decimal OneGbInBytes = 1ul << 30; + + public const decimal OneTbInBytes = 1ul << 40; + + /// + /// Converts bytes represented by a long value to its human readable string + /// equivalent in either megabytes, gigabytes or terabytes. + /// Note: this is only for internal use and is not localized. + /// + public static string ConvertFromBytes(ulong size) + { + if (size >= OneTbInBytes) + { + return $"{(size / OneTbInBytes).ToString("F", CultureInfo.InvariantCulture)} TB"; + } + else if (size >= OneGbInBytes) + { + return $"{(size / OneGbInBytes).ToString("F", CultureInfo.InvariantCulture)} GB"; + } + + return $"{(size / OneMbInBytes).ToString("F", CultureInfo.InvariantCulture)} MB"; + } +} diff --git a/HyperVExtension/src/HyperVExtension/Helpers/DevSetupAgentDeploymentHelper.cs b/HyperVExtension/src/HyperVExtension/Helpers/DevSetupAgentDeploymentHelper.cs new file mode 100644 index 0000000000..cb6ccaeaf6 --- /dev/null +++ b/HyperVExtension/src/HyperVExtension/Helpers/DevSetupAgentDeploymentHelper.cs @@ -0,0 +1,326 @@ +// Copyright (c) Microsoft Corporation. +// Licensed under the MIT License. + +using System.Management.Automation; +using System.Security; +using HyperVExtension.Exceptions; +using HyperVExtension.Services; + +namespace HyperVExtension.Helpers; + +/// +/// A helper class to deploy DevSetupAgent service to a VM using PowerShell Direct service. +/// +public class DevSetupAgentDeploymentHelper +{ + // Architectures returned by Win32_Processor.Architecture (Win32_Processor WMI class) + private enum ProcessorArchitecture : ushort + { + X86 = 0, + MIPS = 1, + Alpha = 2, + PowerPC = 3, + ARM = 5, + IA64 = 6, + X64 = 9, + ARM64 = 12, + } + + private readonly IPowerShellService _powerShellService; + private readonly string _vmId; + + public DevSetupAgentDeploymentHelper(IPowerShellService powerShellService, string vmId) + { + _powerShellService = powerShellService; + _vmId = vmId; + } + + public void DeployDevSetupAgent(string userName, SecureString password) + { + var credential = new PSCredential(userName, password); + var session = GetSessionObject(credential); + var architecture = GetVMArchitechture(session); + var sourcePath = GetSourcePath(architecture); + + var deployDevSetupAgentStatement = new StatementBuilder() + .AddScript(_script, false) + .AddCommand("Install-DevSetupAgent") + .AddParameter("VMId", _vmId) + .AddParameter("Session", session) + .AddParameter("Path", sourcePath) + .Build(); + + // TODO: Subscribe for PowerShell events to get the progress of the deployment like: + //// ps.Streams.Information.DataAdded += (sender, e) => + //// ps.Streams.Error.DataAdded += (sender, e) => + //// ps.Streams.Verbose.DataAdded += (sender, e) => + //// ps.Streams.Warning.DataAdded += (sender, e) => + //// ps.Streams.Progress.DataAdded += (sender, e) => + var result = _powerShellService.Execute(deployDevSetupAgentStatement, PipeType.DontClearBetweenStatements); + if (!string.IsNullOrEmpty(result.CommandOutputErrorMessage)) + { + throw new DevSetupAgentDeploymentException( + $"Unable to deploy DevSetupAgent service to VM with Id: {_vmId} due to PowerShell error: {result.CommandOutputErrorMessage}"); + } + } + + public virtual string GetSourcePath(ushort architecture) + { + if ((architecture == (ushort)ProcessorArchitecture.X64) || (architecture == (ushort)ProcessorArchitecture.X86)) + { + return Path.Combine(AppDomain.CurrentDomain.BaseDirectory, "DevSetupAgent_x86.zip"); + } + else if (architecture == (ushort)ProcessorArchitecture.ARM64) + { + return Path.Combine(AppDomain.CurrentDomain.BaseDirectory, "DevSetupAgent_arm64.zip"); + } + else + { + throw new DevSetupAgentDeploymentException( + $"Unable to deploy DevSetupAgent service to VM with Id: {_vmId} due to unsupported architecture: {architecture}"); + } + } + + public PSObject GetSessionObject(PSCredential credential) + { + var newSessionCommand = new StatementBuilder() + .AddCommand("New-PSSession") + .AddParameter("VMId", _vmId) + .AddParameter("Credential", credential) + .Build(); + + var result = _powerShellService.Execute(newSessionCommand, PipeType.None); + if (!string.IsNullOrEmpty(result.CommandOutputErrorMessage)) + { + throw new DevSetupAgentDeploymentSessionException( + $"Unable to create remote session for VM with Id: {_vmId} due to PowerShell error: {result.CommandOutputErrorMessage}"); + } + + return result!.PsObjects.FirstOrDefault()!; + } + + private ushort GetVMArchitechture(PSObject session) + { + var getVMArchitechtureCommand = new StatementBuilder() + .AddCommand("Invoke-Command") + .AddParameter("Session", session) + .AddParameter("ScriptBlock", ScriptBlock.Create("(Get-CIMInstance -Class win32_processor).Architecture")) + .Build(); + + var result = _powerShellService!.Execute(getVMArchitechtureCommand, PipeType.None); + if (!string.IsNullOrEmpty(result.CommandOutputErrorMessage)) + { + throw new DevSetupAgentDeploymentException( + $"Unable to get VM architecture for VM with Id: {_vmId} due to PowerShell error: {result.CommandOutputErrorMessage}"); + } + + var psObject = result.PsObjects.FirstOrDefault(); + if (psObject == null) + { + throw new DevSetupAgentDeploymentException( + $"Unable to get VM architecture for VM with Id: {_vmId} due to PowerShell error: No result returned"); + } + + return (ushort)psObject.BaseObject; + } + + private readonly string _script = @" +function Install-DevSetupAgent +{ + Param( + [Parameter(Mandatory = $true)] + [Guid] $VMId, + + [Parameter(Mandatory = $true)] + [System.Management.Automation.Runspaces.PSSession] $Session, + + [Parameter(Mandatory = $true)] + [string] $Path + ) + + $ErrorActionPreference = ""Stop"" + $activity = ""Installing DevSetupAgent to VM $VMId"" + + # Validate input. Only .cab and .zip files are supported + # If $Path is a directory, it will be copied to the VM and installed as is + $isDirectory = $false + $isCab = $false + $inputFileName = $null + if (Test-Path -Path $Path -PathType 'Container') + { + $isDirectory = $true + } + elseif (Test-Path -Path $Path -PathType 'Leaf') + { + if ($Path -match '\.(cab)$') + { + $isCab = $true + } + elseif (-not $Path -match '\.(zip)$') + { + throw ""Only .cab and .zip files are supported"" + } + $inputFileName = Split-Path -Path $Path -Leaf + } + else + { + throw ""$Path does not exist"" + } + + + $DevSetupAgentConst = ""DevSetupAgent"" + $DevSetupEngineConst = ""DevSetupEngine"" + $session = $Session + + $guestTempDirectory = Invoke-Command -Session $session -ScriptBlock { $env:temp } + + [string] $guid = [System.Guid]::NewGuid() + $guestUnpackDirectory = Join-Path -Path $guestTempDirectory -ChildPath $guid + $guestDevSetupAgentTempDirectory = Join-Path -Path $guestUnpackDirectory -ChildPath $DevSetupAgentConst + + Write-Host ""Creating VM temporary folder $guestUnpackDirectory"" + Write-Progress -Activity $activity -Status ""Creating VM temporary folder $guestUnpackDirectory"" -PercentComplete 10 + Invoke-Command -Session $session -ScriptBlock { New-Item -Path ""$using:guestUnpackDirectory"" -ItemType ""directory"" } + + if ($isDirectory) + { + $destinationPath = $guestDevSetupAgentTempDirectory + } + else + { + $destinationPath = $guestUnpackDirectory + } + + Write-Host ""Copying $Path to VM $destinationPath"" + Write-Progress -Activity $activity -Status ""Copying DevSetupAgent to VM $destinationPath"" -PercentComplete 15 + Copy-Item -ToSession $session -Recurse -Path $Path -Destination $destinationPath + + + Invoke-Command -Session $session -ScriptBlock { + $ErrorActionPreference = ""Stop"" + + try + { + $guestDevSetupAgentPath = Join-Path -Path $Env:Programfiles -ChildPath $using:DevSetupAgentConst + + # Stop and remove previous version of DevSetupAgent service if it exists + $service = Get-Service -Name $using:DevSetupAgentConst -ErrorAction SilentlyContinue + if ($service) + { + $serviceWMI = Get-WmiObject -Class Win32_Service -Filter ""Name='$using:DevSetupAgentConst'"" + $existingServicePath = $serviceWMI.Properties[""PathName""].Value + if ($existingServicePath) + { + $guestDevSetupAgentPath = Split-Path $existingServicePath -Parent + } + + try + { + Write-Host ""Stopping DevSetupAgent service"" + Write-Progress -Activity $using:activity -Status ""Stopping DevSetupAgent service $destinationPath"" -PercentComplete 30 + $service.Stop() + } + catch + { + Write-Host ""Ignoring error: $PSItem"" + } + + Remove-Variable -Name service -ErrorAction SilentlyContinue + + # Remove-Service is only available in PowerShell 6.0 and later. Windows doesn't come with it preinstalled. + Write-Host ""Removing DevSetupAgent service"" + Write-Progress -Activity $using:activity -Status ""Removing DevSetupAgent service"" -PercentComplete 35 + $serviceWMI = Get-WmiObject -Class Win32_Service -Filter ""Name='$using:DevSetupAgentConst'"" + $serviceWMI.Delete() + Remove-Variable -Name serviceWMI -ErrorAction SilentlyContinue + } + + # Stop previous version of DevSetupEngine COM server if it exists + $devSetupEngineProcess = Get-Process -Name ""$using:DevSetupEngineConst"" -ErrorAction SilentlyContinue + if ($devSetupEngineProcess -ne $null) + { + Write-Host ""Stopping $using:DevSetupEngineConst process"" + Write-Progress -Activity $using:activity -Status ""Stopping $using:DevSetupEngineConst process"" -PercentComplete 40 + Stop-Process -Force -Name ""$using:DevSetupEngineConst"" + } + + # Unregister DevSetupEngine + $enginePath = Join-Path -Path $guestDevSetupAgentPath -ChildPath ""$using:DevSetupEngineConst.exe"" + if (Test-Path -Path $enginePath) + { + Write-Host ""Unregistering DevSetupEngine ($enginePath)"" + Write-Progress -Activity $using:activity -Status ""Registering DevSetupEngine ($enginePath)"" -PercentComplete 88 + &$enginePath ""-UnregisterComServer"" + } + + # Remove previous version of DevSetupAgent service files + if (Test-Path -Path $guestDevSetupAgentPath) + { + # Sleep a few seconds to make sure all handles released after shutting down previous DevSetupEngine + Start-Sleep -Seconds 7 + Write-Host ""Deleting old DevSetupAgent service files"" + Write-Progress -Activity $using:activity -Status ""Deleting old DevSetupAgent service files"" -PercentComplete 45 + Remove-Item -Recurse -Force -Path $guestDevSetupAgentPath + } + + if ($using:isDirectory) + { + Write-Host ""Copying DevSetupAgent to $guestDevSetupAgentPath"" + Write-Progress -Activity $using:activity -Status ""Deleting old DevSetupAgent service files"" -PercentComplete 50 + Copy-Item -Recurse -Path $using:guestDevSetupAgentTempDirectory -Destination $guestDevSetupAgentPath + } + elseif ($using:isCab) + { + $cabPath = Join-Path -Path $using:guestUnpackDirectory -ChildPath $using:inputFileName + Write-Host ""Unpacking $cabPath to $guestDevSetupAgentPath"" + Write-Progress -Activity $using:activity -Status ""Unpacking $cabPath to $guestDevSetupAgentPath"" -PercentComplete 60 + $expandOutput=&""$Env:SystemRoot\System32\expand.exe"" $cabPath /F:* $Env:Programfiles + if ($LastExitCode -ne 0) + { + throw ""Error unpacking $cabPath`:`n$LastExitCode`n$($expandOutput|Out-String)"" + } + } + else + { + $zipPath = Join-Path -Path $using:guestUnpackDirectory -ChildPath $using:inputFileName + Write-Host ""Unpacking $using:inputFileName to $guestDevSetupAgentPath"" + Write-Progress -Activity $using:activity -Status ""Unpacking $using:inputFileName to $guestDevSetupAgentPath"" -PercentComplete 60 + Expand-Archive -Path $zipPath -Destination $guestDevSetupAgentPath + } + + # Register DevSetupAgent service + $servicePath = Join-Path -Path $guestDevSetupAgentPath -ChildPath ""$using:DevSetupAgentConst.exe"" + Write-Host ""Registering DevSetupAgent service ($servicePath)"" + Write-Progress -Activity $using:activity -Status ""Registering DevSetupAgent service ($servicePath)"" -PercentComplete 85 + New-Service -Name $using:DevSetupAgentConst -BinaryPathName $servicePath -StartupType Automatic + + # Register DevSetupEngine + Write-Host ""Registering DevSetupEngine ($enginePath)"" + Write-Progress -Activity $using:activity -Status ""Registering DevSetupEngine ($enginePath)"" -PercentComplete 88 + + # Executing non-console apps using '&' does not set $LastExitCode. Using Start-Process here to get the returned error code. + $process = Start-Process -NoNewWindow -Wait $enginePath -ArgumentList ""-RegisterComServer"" -PassThru + if ($process.ExitCode -ne 0) + { + throw ""Error registering $enginePath`: $process.ExitCode"" + } + + Write-Host ""Starting DevSetupAgent service"" + Write-Progress -Activity $using:activity -Status ""Starting DevSetupAgent service"" -PercentComplete 92 + Start-Service $using:DevSetupAgentConst + } + catch + { + Write-Host ""Error on guest OS: $PSItem"" + } + finally + { + Write-Host ""Removing temporary directory $using:guestUnpackDirectory"" + Remove-Item -Recurse -Force -Path $using:guestUnpackDirectory -ErrorAction SilentlyContinue + } + } + + Remove-PSSession $session +} +"; +} diff --git a/HyperVExtension/src/HyperVExtension/Helpers/HyperVStrings.cs b/HyperVExtension/src/HyperVExtension/Helpers/HyperVStrings.cs new file mode 100644 index 0000000000..abd76b2524 --- /dev/null +++ b/HyperVExtension/src/HyperVExtension/Helpers/HyperVStrings.cs @@ -0,0 +1,81 @@ +// Copyright (c) Microsoft Corporation. +// Licensed under the MIT License. + +namespace HyperVExtension.Helpers; + +public static class HyperVStrings +{ + // Common strings used for Hyper-V extension + public const string HyperVModuleName = "Hyper-V"; + public const string HyperVProviderDisplayName = "Microsoft Hyper-V"; + public const string HyperVProviderId = "Microsoft.HyperV"; + public const string Name = "Name"; + public const string VMManagementService = "vmms"; // virtual machine management service. + + // see: https://learn.microsoft.com/windows-server/identity/ad-ds/manage/understand-security-identifiers + public const string HyperVAdminGroupWellKnownSid = "S-1-5-32-578"; + + // Hyper-V VM member strings + public const string ComputerName = "ComputerName"; + public const string CPUUsage = "CPUUsage"; + public const string CreationTime = "CreationTime"; + public const string DynamicMemoryEnabled = "DynamicMemoryEnabled"; + public const string HardDrives = "HardDrives"; + public const string Id = "Id"; + public const string IsDeleted = "IsDeleted"; + public const string MemoryAssigned = "MemoryAssigned"; + public const string MemoryDemand = "MemoryDemand"; + public const string MemoryMaximum = "MemoryMaximum"; + public const string MemoryMinimum = "MemoryMinimum"; + public const string MemoryStartup = "MemoryStartup"; + public const string MemoryStatus = "MemoryStatus"; + public const string ParentCheckpointId = "ParentCheckpointId"; + public const string ParentCheckpointName = "ParentCheckpointName"; + public const string Path = "Path"; + public const string ProcessorCount = "ProcessorCount"; + public const string State = "State"; + public const string Status = "Status"; + public const string Uptime = "Uptime"; + public const string VmId = "VMId"; + public const string VMName = "VMName"; + public const string VMSnapshotId = "VMSnapshotId"; + public const string VMSnapshotName = "VMSnapshotName"; + public const string Size = "Size"; + + // Hyper-V PowerShell commands strings + public const string GetModule = "Get-Module"; + public const string SelectObject = "Select-Object"; + public const string StartService = "Start-Service"; + public const string GetService = "Get-Service"; + public const string GetVM = "Get-VM"; + public const string GetVHD = "Get-VHD"; + public const string StartVM = "Start-VM"; + public const string StopVM = "Stop-VM"; + public const string SuspendVM = "Suspend-VM"; + public const string ResumeVM = "Resume-VM"; + public const string RemoveVM = "Remove-VM"; + public const string GetVMSnapshot = "Get-VMSnapshot"; + public const string RestoreVMSnapshot = "Restore-VMSnapshot"; + public const string RemoveVMSnapshot = "Remove-VMSnapshot"; + public const string CreateVMCheckpoint = "Checkpoint-VM"; + public const string RestartVM = "Restart-VM"; + + // Hyper-V PowerShell command parameter strings + public const string ListAvailable = "ListAvailable"; + public const string Property = "Property"; + public const string Force = "Force"; + public const string Confirm = "Confirm"; + public const string Save = "Save"; + public const string TurnOff = "TurnOff"; + public const string PassThru = "PassThru"; + + // Hyper-V psObject property values + public const string CanStopService = "CanStop"; + public const string VMOffState = "Off"; + public const string RunningState = "Running"; + public const string PausedState = "Paused"; + public const string SavedState = "Saved"; + + // Hyper-V scripts + public const string VmConnectScript = "vmconnect.exe localhost -G"; +} diff --git a/HyperVExtension/src/HyperVExtension/Helpers/Json.cs b/HyperVExtension/src/HyperVExtension/Helpers/Json.cs new file mode 100644 index 0000000000..1f974435e7 --- /dev/null +++ b/HyperVExtension/src/HyperVExtension/Helpers/Json.cs @@ -0,0 +1,63 @@ +// Copyright (c) Microsoft Corporation. +// Licensed under the MIT License. + +using System.IO; +using System.Text; +using System.Text.Json; +using System.Text.Json.Serialization; +using System.Threading.Tasks; + +namespace HyperVExtension.Helpers; + +public static class Json +{ + public static async Task ToObjectAsync(string value) + { + if (typeof(T) == typeof(bool)) + { + return (T)(object)bool.Parse(value); + } + + await using var stream = new MemoryStream(Encoding.UTF8.GetBytes(value)); + return (await JsonSerializer.DeserializeAsync(stream))!; + } + + public static async Task StringifyAsync(T value) + { + if (typeof(T) == typeof(bool)) + { + return value!.ToString()!.ToLowerInvariant(); + } + + await using var stream = new MemoryStream(); + await JsonSerializer.SerializeAsync(stream, value); + return Encoding.UTF8.GetString(stream.ToArray()); + } + + private static readonly JsonSerializerOptions _options = new() + { + PropertyNameCaseInsensitive = true, + DefaultIgnoreCondition = JsonIgnoreCondition.Never, + IncludeFields = true, + }; + + public static T? ToObject(string value) + { + if (typeof(T) == typeof(bool)) + { + return (T)(object)bool.Parse(value); + } + + return JsonSerializer.Deserialize(value, _options); + } + + public static string Stringify(T value) + { + if (typeof(T) == typeof(bool)) + { + return value!.ToString()!.ToLowerInvariant(); + } + + return JsonSerializer.Serialize(value, _options); + } +} diff --git a/HyperVExtension/src/HyperVExtension/Helpers/PsObjectHelper.cs b/HyperVExtension/src/HyperVExtension/Helpers/PsObjectHelper.cs new file mode 100644 index 0000000000..89a424481d --- /dev/null +++ b/HyperVExtension/src/HyperVExtension/Helpers/PsObjectHelper.cs @@ -0,0 +1,79 @@ +// Copyright (c) Microsoft Corporation. +// Licensed under the MIT License. + +using System.Management.Automation; +using HyperVExtension.Providers; + +namespace HyperVExtension.Helpers; + +/// +/// Helper class for interacting with s +/// at runtime. This is used for cases where we don't know the the underlying object types +/// inside the at compile time. +/// +public class PsObjectHelper +{ + private readonly PSObject _psObject; + + public PsObjectHelper(in PSObject pSObject) + { + _psObject = pSObject; + } + + /// + /// Method to extract an object from the member collection located in the psObject. + /// This is used for cases where we don't know the PSObject's base object type at compile time. + /// PSObject documentation here: https://learn.microsoft.com/dotnet/api/system.management.automation.psobject?view=powershellsdk-7.3.0 + /// + /// + /// PSObjects contain a member array that holds properties and methods for the base object it wraps. Each item in the array + /// is a key value pair, where the key is the name of the property or method and the value is the value of the property + /// or output of the method call. When we invoke a PowerShell command using the System.Management.Automation assembly's + /// PowerShell class the returned result is a list of PsObject's. Each PsObject wraps an underlying base object of type + /// 'Object'. If we know the underlying base object type at compile time e.g if we know PowerShell will return a string as the + /// base object, we can cast it to the string type directly without needing to use this method. However, The Hyper-V + /// PowerShell module is loaded into the PowerShell runspace/session at run time and we do not have access to the custom + /// types it holds until then. So this prevents us from statically knowing the types, hence why we have this generic + /// method to make getting information from these custom types simpler. Note: These custom types and their values can be + /// found by opening a PowerShell window and piping the object to the Get-Member PowerShell cmdlet. + /// E.g Get-VM -Name | Get-Member. + /// + public T? MemberNameToValue(string memberName) + { + var potentialValue = _psObject.Members?[memberName]?.Value; + + if (potentialValue is T memberValue) + { + return memberValue; + } + + return default(T); + } + + /// + /// Method to extract the value of an object's property using reflection. + /// This is used for cases where we don't know the inputted objects type at compile time. + /// + public T? PropertyNameToValue(in object obj, string propertyName) + { + var type = obj.GetType(); + + try + { + var property = type.GetProperty(propertyName); + var potentialValue = property?.GetValue(obj, null); + + if (potentialValue is T propertyValue) + { + return propertyValue; + } + } + catch (Exception ex) + { + Logging.Logger()? + .ReportError($"Failed to get property value with name {propertyName} from object with type {type}.", ex); + } + + return default(T); + } +} diff --git a/HyperVExtension/src/HyperVExtension/Helpers/Resources.cs b/HyperVExtension/src/HyperVExtension/Helpers/Resources.cs new file mode 100644 index 0000000000..86657c2335 --- /dev/null +++ b/HyperVExtension/src/HyperVExtension/Helpers/Resources.cs @@ -0,0 +1,69 @@ +// Copyright (c) Microsoft Corporation. +// Licensed under the MIT License. + +using DevHome.Logging; +using Microsoft.Windows.ApplicationModel.Resources; + +namespace HyperVExtension.Helpers; + +public static class Resources +{ + private static ResourceLoader? _resourceLoader; + + public static string GetResource(string identifier, Logger? log = null) + { + try + { + if (_resourceLoader == null) + { + _resourceLoader = new ResourceLoader(ResourceLoader.GetDefaultResourceFilePath(), "HyperVExtension/Resources"); + } + + return _resourceLoader.GetString(identifier); + } + catch (Exception ex) + { + log?.ReportError($"Failed loading resource: {identifier}", ex); + + // If we fail, load the original identifier so it is obvious which resource is missing. + return identifier; + } + } + + // Replaces all identifiers in the provided list in the target string. Assumes all identifiers + // are wrapped with '%' to prevent sub-string replacement errors. This is intended for strings + // such as a JSON string with resource identifiers embedded. + public static string ReplaceIdentifers(string str, string[] resourceIdentifiers, Logger? log = null) + { + var start = DateTime.Now; + foreach (var identifier in resourceIdentifiers) + { + // What is faster, String.Replace, RegEx, or StringBuilder.Replace? It is String.Replace(). + // https://learn.microsoft.com/archive/blogs/debuggingtoolbox/comparing-regex-replace-string-replace-and-stringbuilder-replace-which-has-better-performance + var resourceString = GetResource(identifier, log); + str = str.Replace($"%{identifier}%", resourceString); + } + + var elapsed = DateTime.Now - start; + log?.ReportDebug($"Replaced identifiers in {elapsed.TotalMilliseconds}ms"); + return str; + } + + public static string[] GetHyperVResourceIdentifiers() + { + return + [ + "VmCredentialRequest/Title", + "VmCredentialRequest/Description1", + "VmCredentialRequest/Description2", + "VmCredentialRequest/UsernameErrorMsg", + "VmCredentialRequest/PasswordErrorMsg", + "VmCredentialRequest/UsernameLabel", + "VmCredentialRequest/PasswordLabel", + "VmCredentialRequest/OkText", + "VmCredentialRequest/CancelText", + "WaitForLoginRequest/Title", + "WaitForLoginRequest/Description", + ]; + } +} diff --git a/HyperVExtension/src/HyperVExtension/Helpers/StatementBuilder.cs b/HyperVExtension/src/HyperVExtension/Helpers/StatementBuilder.cs new file mode 100644 index 0000000000..081d292392 --- /dev/null +++ b/HyperVExtension/src/HyperVExtension/Helpers/StatementBuilder.cs @@ -0,0 +1,70 @@ +// Copyright (c) Microsoft Corporation. +// Licensed under the MIT License. + +using HyperVExtension.Models; + +namespace HyperVExtension.Helpers; + +/// +/// Helper class to allow PowerShell Command line statements to be built easier. +/// +public class StatementBuilder +{ + private readonly List _powerShellCommandLineStatements = new(); + + /// Adds a new PowerShell commandline statement with only a single command inside. + /// The StatementBuilder object that is being used to contain the commandline statements + public StatementBuilder AddCommand(string command) + { + var statement = new PowerShellCommandlineStatement() { Command = command, }; + _powerShellCommandLineStatements.Add(statement); + return this; + } + + /// Adds a new parameter to the last PowerShell commandline statement that was created. + /// The StatementBuilder object that is being used to contain the commandline statements + public StatementBuilder AddParameter(string propertyName, object propertyValue) + { + var statement = _powerShellCommandLineStatements.LastOrDefault(); + + if (statement == null) + { + throw new ArgumentException($"Cannot add parameter to the last {nameof(PowerShellCommandlineStatement)} because it was null"); + } + + statement.Parameters.Add(propertyName, propertyValue); + return this; + } + + /// Adds a new PowerShell commandline statement with only a single script inside. + /// The StatementBuilder object that is being used to contain the commandline statements + public StatementBuilder AddScript(string script, bool useLocalScope) + { + var statement = new PowerShellCommandlineStatement() { Script = script, UseLocalScope = useLocalScope }; + _powerShellCommandLineStatements.Add(statement); + return this; + } + + /// + /// Signals to the StatementBuilder to provide a copy of its internal list of PowerShell commandline statements. + /// Note: Doing this this will also clear StatementBuilder's internal list after it returns the copy. + /// + /// A copy of the StatementBuilder's internal list which contains the commandline statements + public List Build() + { + var statementListClone = new List(); + foreach (var statement in _powerShellCommandLineStatements) + { + statementListClone.Add(statement.Clone()); + } + + Reset(); + return statementListClone; + } + + /// Clears the StatementBuilder's internal list of PowerShell statements. + public void Reset() + { + _powerShellCommandLineStatements.Clear(); + } +} diff --git a/HyperVExtension/src/HyperVExtension/Helpers/StringExtensions.cs b/HyperVExtension/src/HyperVExtension/Helpers/StringExtensions.cs new file mode 100644 index 0000000000..40234c7982 --- /dev/null +++ b/HyperVExtension/src/HyperVExtension/Helpers/StringExtensions.cs @@ -0,0 +1,16 @@ +// Copyright (c) Microsoft Corporation. +// Licensed under the MIT License. + +using System.Globalization; + +namespace HyperVExtension.Helpers; + +public static class StringExtensions +{ + public static string ToStringInvariant(this T value) => Convert.ToString(value, CultureInfo.InvariantCulture)!; + + public static string FormatInvariant(this string value, params object[] arguments) + { + return string.Format(CultureInfo.InvariantCulture, value, arguments); + } +} diff --git a/HyperVExtension/src/HyperVExtension/HyperVExtension.cs b/HyperVExtension/src/HyperVExtension/HyperVExtension.cs new file mode 100644 index 0000000000..b3a44b3264 --- /dev/null +++ b/HyperVExtension/src/HyperVExtension/HyperVExtension.cs @@ -0,0 +1,82 @@ +// Copyright (c) Microsoft Corporation. +// Licensed under the MIT License. + +using System.Runtime.InteropServices; +using HyperVExtension.Common.Extensions; +using Microsoft.Extensions.Hosting; +using Microsoft.Windows.DevHome.SDK; + +namespace HyperVExtension; + +[ComVisible(true)] +[Guid("F8B26528-976A-488C-9B40-7198FB425C9E")] +[ComDefaultInterface(typeof(IExtension))] +public sealed class HyperVExtension : IExtension, IDisposable +{ + private readonly IHost _host; + private bool _disposed; + + public HyperVExtension(IHost host) + { + _host = host; + } + + /// + /// Gets the synchronization object that is used to prevent the main program from exiting + /// until the extension is disposed. + /// + public ManualResetEvent ExtensionDisposedEvent { get; } = new(false); + + /// + /// Gets provider object for the specified provider type. + /// + /// + /// The provider type that the Hyper-V extension may support. This is used to query the Hyper-V + /// extension for whether it supports the provider type. + /// + /// + /// When the extension supports the ProviderType the object returned will not be null. However, + /// when the extension does not support the ProviderType the returned object will be null. + /// + public object? GetProvider(ProviderType providerType) + { + object? provider = null; + try + { + switch (providerType) + { + case ProviderType.ComputeSystem: + provider = _host.GetService(); + break; + default: + Providers.Logging.Logger()?.ReportInfo($"Unsupported provider: {providerType}"); + break; + } + } + catch (Exception ex) + { + Providers.Logging.Logger()?.ReportError($"Failed to get provider for provider type {providerType}", ex); + } + + return provider; + } + + public void Dispose() + { + Dispose(true); + GC.SuppressFinalize(this); + } + + private void Dispose(bool disposing) + { + if (!_disposed) + { + if (disposing) + { + ExtensionDisposedEvent.Set(); + } + + _disposed = true; + } + } +} diff --git a/HyperVExtension/src/HyperVExtension/HyperVExtension.csproj b/HyperVExtension/src/HyperVExtension/HyperVExtension.csproj new file mode 100644 index 0000000000..675b8c3d49 --- /dev/null +++ b/HyperVExtension/src/HyperVExtension/HyperVExtension.csproj @@ -0,0 +1,87 @@ + + + + Library + enable + enable + Dev + win10-x86;win10-x64;win10-arm64 + + + + + + + + + all + runtime; build; native; contentfiles; analyzers; buildtransitive + + + + + + + + + + + + + + + + + + + all + runtime; build; native; contentfiles; analyzers; buildtransitive + + + + + + + Always + + + Always + + + + + $(DefineConstants);CANARY_BUILD + $(DefineConstants);STABLE_BUILD + + + + portable + + + + portable + + + + portable + + + + portable + + + + portable + + + + portable + + \ No newline at end of file diff --git a/HyperVExtension/src/HyperVExtension/Models/Checkpoint.cs b/HyperVExtension/src/HyperVExtension/Models/Checkpoint.cs new file mode 100644 index 0000000000..61366dca10 --- /dev/null +++ b/HyperVExtension/src/HyperVExtension/Models/Checkpoint.cs @@ -0,0 +1,32 @@ +// Copyright (c) Microsoft Corporation. +// Licensed under the MIT License. + +namespace HyperVExtension.Models; + +/// +/// Class that represents a checkpoint. +/// +public class Checkpoint +{ + public Guid ParentCheckpointId { get; set; } + + public string ParentCheckpointName { get; set; } = string.Empty; + + public Guid Id { get; set; } + + public string Name { get; set; } = string.Empty; + + public List ChildCheckpoints { get; set; } = new List(); + + public Checkpoint(Guid parentCheckpointId, string parentCheckpointName, Guid checkpointId, string checkpointName) + { + ParentCheckpointId = parentCheckpointId; + ParentCheckpointName = parentCheckpointName; + Id = checkpointId; + Name = checkpointName; + } + + public Checkpoint() + { + } +} diff --git a/HyperVExtension/src/HyperVExtension/Models/HyperVVirtualMachine.cs b/HyperVExtension/src/HyperVExtension/Models/HyperVVirtualMachine.cs new file mode 100644 index 0000000000..4d1cc989da --- /dev/null +++ b/HyperVExtension/src/HyperVExtension/Models/HyperVVirtualMachine.cs @@ -0,0 +1,807 @@ +// Copyright (c) Microsoft Corporation. +// Licensed under the MIT License. + +using System; +using System.Data; +using System.Globalization; +using System.Management.Automation; +using System.Runtime.InteropServices.WindowsRuntime; +using System.Text; +using System.Threading; +using HyperVExtension.Common; +using HyperVExtension.Common.Extensions; +using HyperVExtension.CommunicationWithGuest; +using HyperVExtension.Exceptions; +using HyperVExtension.Helpers; +using HyperVExtension.Providers; +using HyperVExtension.Services; +using Microsoft.Extensions.Hosting; +using Microsoft.Windows.DevHome.SDK; +using Windows.Foundation; +using Windows.Storage; +using Windows.Storage.Streams; +using Windows.Win32.Foundation; +using SDK = Microsoft.Windows.DevHome.SDK; + +namespace HyperVExtension.Models; + +public delegate HyperVVirtualMachine HyperVVirtualMachineFactory(PSObject pSObject); + +/// Class that represents a Hyper-V virtual machine object. +public class HyperVVirtualMachine : IComputeSystem +{ + private readonly string _errorResourceKey = "ErrorPerformingOperation"; + + private readonly string _currentCheckpointKey = "CurrentCheckpoint"; + + private readonly IStringResource _stringResource; + + private readonly IHost _host; + private readonly IHyperVManager _hyperVManager; + + private readonly PsObjectHelper _psObjectHelper; + + public event TypedEventHandler StateChanged = (s, e) => { }; + + public Guid VmId => _psObjectHelper.MemberNameToValue(HyperVStrings.Id); + + // IComputeSystem expects a string for the Id of the compute system. + public string Id => VmId.ToString(); + + public string? DisplayName => _psObjectHelper.MemberNameToValue(HyperVStrings.Name); + + public int CPUUsage => _psObjectHelper.MemberNameToValue(HyperVStrings.CPUUsage); + + public long MemoryAssigned => _psObjectHelper.MemberNameToValue(HyperVStrings.MemoryAssigned); + + public long MemoryDemand => _psObjectHelper.MemberNameToValue(HyperVStrings.MemoryDemand); + + public string? MemoryStatus => _psObjectHelper.MemberNameToValue(HyperVStrings.MemoryStatus); + + public TimeSpan Uptime => _psObjectHelper.MemberNameToValue(HyperVStrings.Uptime); + + public string? Status => _psObjectHelper.MemberNameToValue(HyperVStrings.Status); + + public string? State => _psObjectHelper.MemberNameToValue(HyperVStrings.State)?.ToString(); + + public bool DynamicMemoryEnabled => _psObjectHelper.MemberNameToValue(HyperVStrings.DynamicMemoryEnabled); + + public long MemoryMaximum => _psObjectHelper.MemberNameToValue(HyperVStrings.MemoryMaximum); + + public long MemoryMinimum => _psObjectHelper.MemberNameToValue(HyperVStrings.MemoryMinimum); + + public long MemoryStartup => _psObjectHelper.MemberNameToValue(HyperVStrings.MemoryStartup); + + public long ProcessorCount => _psObjectHelper.MemberNameToValue(HyperVStrings.ProcessorCount); + + public Guid ParentCheckpointId => _psObjectHelper.MemberNameToValue(HyperVStrings.ParentCheckpointId); + + public string? ParentCheckpointName => _psObjectHelper.MemberNameToValue(HyperVStrings.ParentCheckpointName); + + public string? Path => _psObjectHelper.MemberNameToValue(HyperVStrings.Path); + + public DateTime CreationTime => _psObjectHelper.MemberNameToValue(HyperVStrings.CreationTime); + + public string? ComputerName => _psObjectHelper.MemberNameToValue(HyperVStrings.ComputerName); + + public bool IsDeleted => _psObjectHelper.MemberNameToValue(HyperVStrings.IsDeleted); + + // Temporary will need to add more error strings for different operations. + public string OperationErrorUnknownString => _stringResource.GetLocalized(_errorResourceKey); + + // TODO: make getting this list dynamic so we can remove operations based on OS version. + public ComputeSystemOperations SupportedOperations => ComputeSystemOperations.Start | + ComputeSystemOperations.ShutDown | + ComputeSystemOperations.Terminate | + ComputeSystemOperations.Delete | + ComputeSystemOperations.Save | + ComputeSystemOperations.Pause | + ComputeSystemOperations.Resume | + ComputeSystemOperations.CreateSnapshot | + ComputeSystemOperations.DeleteSnapshot | + ComputeSystemOperations.Restart | + ComputeSystemOperations.ApplyConfiguration; + + public string SupplementalDisplayName { get; set; } = string.Empty; + + public IDeveloperId? AssociatedDeveloperId { get; set; } + + public string AssociatedProviderId { get; set; } = HyperVStrings.HyperVProviderId; + + public HyperVVirtualMachine(IHost host, IHyperVManager hyperVManager, IStringResource stringResource, PSObject psObject) + { + _host = host; + _hyperVManager = hyperVManager; + _psObjectHelper = new(psObject); + _stringResource = stringResource; + } + + public IEnumerable GetHardDrives() + { + var returnList = new List(); + var hardDriveList = _psObjectHelper.MemberNameToValue>(HyperVStrings.HardDrives) ?? new List(); + foreach (var hardDrive in hardDriveList) + { + returnList.Add( + new HyperVVirtualMachineHardDisk + { + ComputerName = _psObjectHelper.PropertyNameToValue(hardDrive, HyperVStrings.ComputerName), + Name = _psObjectHelper.PropertyNameToValue(hardDrive, HyperVStrings.Name), + Path = _psObjectHelper.PropertyNameToValue(hardDrive, HyperVStrings.Path), + VmId = _psObjectHelper.PropertyNameToValue(hardDrive, HyperVStrings.VmId), + VMName = _psObjectHelper.PropertyNameToValue(hardDrive, HyperVStrings.VMName), + VMSnapshotId = _psObjectHelper.PropertyNameToValue(hardDrive, HyperVStrings.VMSnapshotId), + VMSnapshotName = _psObjectHelper.PropertyNameToValue(hardDrive, HyperVStrings.VMSnapshotName), + }); + + var disk = returnList.Last(); + if (disk.Path != null) + { + disk.DiskSizeInBytes = _hyperVManager.GetVhdSize(disk.Path); + } + } + + return returnList; + } + + public IAsyncOperation GetStateAsync() + { + return Task.Run(() => + { + var currentState = State switch + { + HyperVStrings.RunningState => ComputeSystemState.Running, + HyperVStrings.VMOffState => ComputeSystemState.Stopped, + HyperVStrings.PausedState => ComputeSystemState.Paused, + HyperVStrings.SavedState => ComputeSystemState.Saved, + _ => ComputeSystemState.Unknown, + }; + + return new ComputeSystemStateResult(currentState); + }).AsAsyncOperation(); + } + + public IAsyncOperation StartAsync(string options) + { + return Task.Run(() => + { + return Start(options); + }).AsAsyncOperation(); + } + + private ComputeSystemOperationResult Start(string options) + { + try + { + if (State == HyperVStrings.RunningState) + { + // VM is already running. + return new ComputeSystemOperationResult(); + } + + StateChanged(this, ComputeSystemState.Starting); + if (_hyperVManager.StartVirtualMachine(VmId)) + { + StateChanged(this, ComputeSystemState.Running); + Logging.Logger()?.ReportInfo(OperationSuccessString(ComputeSystemOperations.Start)); + return new ComputeSystemOperationResult(); + } + + throw new ComputeSystemOperationException(ComputeSystemOperations.Start); + } + catch (Exception ex) + { + StateChanged(this, ComputeSystemState.Unknown); + Logging.Logger()?.ReportError(OperationErrorString(ComputeSystemOperations.Start), ex); + return new ComputeSystemOperationResult(ex, OperationErrorUnknownString, ex.Message); + } + } + + public IAsyncOperation ShutDownAsync(string options) + { + return Task.Run(() => + { + try + { + if (State == HyperVStrings.VMOffState) + { + // VM is already off. + return new ComputeSystemOperationResult(); + } + + StateChanged(this, ComputeSystemState.Stopping); + if (_hyperVManager.StopVirtualMachine(VmId, StopVMKind.Default)) + { + StateChanged(this, ComputeSystemState.Stopped); + Logging.Logger()?.ReportInfo(OperationSuccessString(ComputeSystemOperations.ShutDown)); + return new ComputeSystemOperationResult(); + } + + throw new ComputeSystemOperationException(ComputeSystemOperations.ShutDown); + } + catch (Exception ex) + { + StateChanged(this, ComputeSystemState.Unknown); + Logging.Logger()?.ReportError(OperationErrorString(ComputeSystemOperations.ShutDown)); + return new ComputeSystemOperationResult(ex, OperationErrorUnknownString, ex.Message); + } + }).AsAsyncOperation(); + } + + public IAsyncOperation TerminateAsync(string options) + { + return Task.Run(() => + { + try + { + if (State == HyperVStrings.VMOffState) + { + // VM is already off. + return new ComputeSystemOperationResult(); + } + + StateChanged(this, ComputeSystemState.Stopping); + if (_hyperVManager.StopVirtualMachine(VmId, StopVMKind.TurnOff)) + { + StateChanged(this, ComputeSystemState.Stopped); + Logging.Logger()?.ReportInfo(OperationSuccessString(ComputeSystemOperations.Terminate)); + return new ComputeSystemOperationResult(); + } + + throw new ComputeSystemOperationException(ComputeSystemOperations.Terminate); + } + catch (Exception ex) + { + StateChanged(this, ComputeSystemState.Unknown); + Logging.Logger()?.ReportError(OperationErrorString(ComputeSystemOperations.Terminate), ex); + return new ComputeSystemOperationResult(ex, OperationErrorUnknownString, ex.Message); + } + }).AsAsyncOperation(); + } + + public IAsyncOperation DeleteAsync(string options) + { + return Task.Run(() => + { + try + { + StateChanged(this, ComputeSystemState.Deleting); + if (_hyperVManager.RemoveVirtualMachine(VmId)) + { + StateChanged(this, ComputeSystemState.Deleted); + Logging.Logger()?.ReportInfo(OperationSuccessString(ComputeSystemOperations.Delete)); + return new ComputeSystemOperationResult(); + } + + throw new ComputeSystemOperationException(ComputeSystemOperations.Delete); + } + catch (Exception ex) + { + StateChanged(this, ComputeSystemState.Unknown); + Logging.Logger()?.ReportError(OperationErrorString(ComputeSystemOperations.Delete), ex); + return new ComputeSystemOperationResult(ex, OperationErrorUnknownString, ex.Message); + } + }).AsAsyncOperation(); + } + + public IAsyncOperation SaveAsync(string options) + { + return Task.Run(() => + { + try + { + if (State == HyperVStrings.SavedState) + { + // VM is already saved. + return new ComputeSystemOperationResult(); + } + + StateChanged(this, ComputeSystemState.Saving); + if (_hyperVManager.StopVirtualMachine(VmId, StopVMKind.Save)) + { + StateChanged(this, ComputeSystemState.Saved); + Logging.Logger()?.ReportInfo(OperationSuccessString(ComputeSystemOperations.Save)); + return new ComputeSystemOperationResult(); + } + + throw new ComputeSystemOperationException(ComputeSystemOperations.Save); + } + catch (Exception ex) + { + StateChanged(this, ComputeSystemState.Unknown); + Logging.Logger()?.ReportError(OperationErrorString(ComputeSystemOperations.Save), ex); + return new ComputeSystemOperationResult(ex, OperationErrorUnknownString, ex.Message); + } + }).AsAsyncOperation(); + } + + public IAsyncOperation PauseAsync(string options) + { + return Task.Run(() => + { + try + { + if (State == HyperVStrings.PausedState) + { + // VM is already paused. + return new ComputeSystemOperationResult(); + } + + StateChanged(this, ComputeSystemState.Pausing); + if (_hyperVManager.PauseVirtualMachine(VmId)) + { + StateChanged(this, ComputeSystemState.Paused); + Logging.Logger()?.ReportInfo(OperationSuccessString(ComputeSystemOperations.Pause)); + return new ComputeSystemOperationResult(); + } + + throw new ComputeSystemOperationException(ComputeSystemOperations.Pause); + } + catch (Exception ex) + { + StateChanged(this, ComputeSystemState.Unknown); + Logging.Logger()?.ReportError(OperationErrorString(ComputeSystemOperations.Pause), ex); + return new ComputeSystemOperationResult(ex, OperationErrorUnknownString, ex.Message); + } + }).AsAsyncOperation(); + } + + public IAsyncOperation ResumeAsync(string options) + { + return Task.Run(() => + { + try + { + if (State == HyperVStrings.RunningState) + { + // VM is already running. + return new ComputeSystemOperationResult(); + } + + StateChanged(this, ComputeSystemState.Starting); + if (_hyperVManager.ResumeVirtualMachine(VmId)) + { + StateChanged(this, ComputeSystemState.Running); + Logging.Logger()?.ReportInfo(OperationSuccessString(ComputeSystemOperations.Resume)); + return new ComputeSystemOperationResult(); + } + + throw new ComputeSystemOperationException(ComputeSystemOperations.Resume); + } + catch (Exception ex) + { + StateChanged(this, ComputeSystemState.Unknown); + Logging.Logger()?.ReportError(OperationErrorString(ComputeSystemOperations.Resume), ex); + return new ComputeSystemOperationResult(ex, OperationErrorUnknownString, ex.Message); + } + }).AsAsyncOperation(); + } + + public IAsyncOperation CreateSnapshotAsync(string options) + { + return Task.Run(() => + { + try + { + if (_hyperVManager.CreateCheckpoint(VmId)) + { + Logging.Logger()?.ReportInfo(OperationSuccessString(ComputeSystemOperations.CreateSnapshot)); + return new ComputeSystemOperationResult(); + } + + throw new ComputeSystemOperationException(ComputeSystemOperations.CreateSnapshot); + } + catch (Exception ex) + { + Logging.Logger()?.ReportError(OperationErrorString(ComputeSystemOperations.CreateSnapshot), ex); + return new ComputeSystemOperationResult(ex, OperationErrorUnknownString, ex.Message); + } + }).AsAsyncOperation(); + } + + public IAsyncOperation RevertSnapshotAsync(string options) + { + return Task.Run(() => + { + try + { + // Reverting checkpoints means applying the previous checkpoint onto the VM. + if (_hyperVManager.ApplyCheckpoint(VmId, ParentCheckpointId)) + { + Logging.Logger()?.ReportInfo(OperationSuccessString(ComputeSystemOperations.RevertSnapshot)); + return new ComputeSystemOperationResult(); + } + + throw new ComputeSystemOperationException(ComputeSystemOperations.RevertSnapshot); + } + catch (Exception ex) + { + Logging.Logger()?.ReportError(OperationErrorString(ComputeSystemOperations.RevertSnapshot), ex); + return new ComputeSystemOperationResult(ex, OperationErrorUnknownString, ex.Message); + } + }).AsAsyncOperation(); + } + + public IAsyncOperation DeleteSnapshotAsync(string options) + { + return Task.Run(() => + { + try + { + // For v1 we only support deleting the previous checkpoint. + if (_hyperVManager.RemoveCheckpoint(VmId, ParentCheckpointId)) + { + Logging.Logger()?.ReportInfo(OperationSuccessString(ComputeSystemOperations.DeleteSnapshot)); + return new ComputeSystemOperationResult(); + } + + throw new ComputeSystemOperationException(ComputeSystemOperations.DeleteSnapshot); + } + catch (Exception ex) + { + Logging.Logger()?.ReportError(OperationErrorString(ComputeSystemOperations.DeleteSnapshot), ex); + return new ComputeSystemOperationResult(ex, OperationErrorUnknownString, ex.Message); + } + }).AsAsyncOperation(); + } + + public IAsyncOperation ConnectAsync(string options) + { + return Task.Run(() => + { + try + { + _hyperVManager.ConnectToVirtualMachine(VmId); + Logging.Logger()?.ReportInfo($"Successful vmconnect launch attempt on {DateTime.Now}: VM details: {this}"); + return new ComputeSystemOperationResult(); + } + catch (Exception ex) + { + Logging.Logger()?.ReportError($"Failed to launch vmconnect on {DateTime.Now}: VM details: {this}", ex); + return new ComputeSystemOperationResult(ex, OperationErrorUnknownString, ex.Message); + } + }).AsAsyncOperation(); + } + + public IAsyncOperation RestartAsync(string options) + { + return Task.Run(() => + { + try + { + if (State != HyperVStrings.RunningState) + { + throw new ComputeSystemOperationException(ComputeSystemOperations.Restart); + } + + StateChanged(this, ComputeSystemState.Restarting); + if (_hyperVManager.RestartVirtualMachine(VmId)) + { + StateChanged(this, ComputeSystemState.Running); + Logging.Logger()?.ReportInfo(OperationSuccessString(ComputeSystemOperations.Restart)); + return new ComputeSystemOperationResult(); + } + + throw new ComputeSystemOperationException(ComputeSystemOperations.Restart); + } + catch (Exception ex) + { + StateChanged(this, ComputeSystemState.Unknown); + Logging.Logger()?.ReportError(OperationErrorString(ComputeSystemOperations.Restart), ex); + return new ComputeSystemOperationResult(ex, OperationErrorUnknownString, ex.Message); + } + }).AsAsyncOperation(); + } + + public IAsyncOperation GetComputeSystemThumbnailAsync(string options) + { + return Task.Run(async () => + { + var uri = new Uri(Constants.WindowsThumbnail); + var storageFile = await StorageFile.GetFileFromApplicationUriAsync(uri); + var randomAccessStream = await storageFile.OpenReadAsync(); + + // Convert the stream to a byte array + var bytes = new byte[randomAccessStream.Size]; + await randomAccessStream.ReadAsync(bytes.AsBuffer(), (uint)randomAccessStream.Size, InputStreamOptions.None); + return new ComputeSystemThumbnailResult(bytes); + }).AsAsyncOperation(); + } + + public IAsyncOperation> GetComputeSystemPropertiesAsync(string options) + { + return Task.Run(() => + { + try + { + // For hyper-v we'll provide storage as the total allocated size of all virtual hard disks + // assigned to the virtual machine. + var totalDiskSize = 0ul; + foreach (var disk in GetHardDrives()) + { + totalDiskSize += disk.DiskSizeInBytes; + } + + // Only specific properties are supported for now. + var properties = new List + { + ComputeSystemProperty.Create(ComputeSystemPropertyKind.CpuCount, ProcessorCount), + ComputeSystemProperty.Create(ComputeSystemPropertyKind.AssignedMemorySizeInBytes, MemoryAssigned), + ComputeSystemProperty.Create(ComputeSystemPropertyKind.AssignedMemorySizeInBytes, totalDiskSize), + ComputeSystemProperty.Create(ComputeSystemPropertyKind.UptimeIn100ns, Uptime), + ComputeSystemProperty.CreateCustom(ParentCheckpointName, _stringResource.GetLocalized(_currentCheckpointKey), null), + }; + + return properties.AsEnumerable(); + } + catch (Exception ex) + { + Logging.Logger()?.ReportError($"Failed to GetComputeSystemPropertiesAsync on {DateTime.Now}: VM details: {this}", ex); + return new List(); + } + }).AsAsyncOperation(); + } + + public IAsyncOperation ModifyPropertiesAsync(string inputJson) + { + // This is temporary until we have a proper implementation for this. + var notImplementedException = new NotImplementedException($"Method not implemented by Hyper-V Compute Systems: VM details: {this}"); + return Task.FromResult(new ComputeSystemOperationResult(notImplementedException, OperationErrorUnknownString, notImplementedException.Message)).AsAsyncOperation(); + } + + public SDK.ApplyConfigurationResult ApplyConfiguration(ApplyConfigurationOperation operation) + { + const int MaxRetryAttempts = 3; + + try + { + // TODO: Check if VM is already running. Set progress to Starting VM if needed. + // Hyper-V KVP service can set succeed even if VM is not running and VM will receive + // registry key changes next time it starts. + var startResult = Start(string.Empty); + if (startResult.Result.Status == ProviderOperationStatus.Failure) + { + return operation.CompleteOperation(new HostGuestCommunication.ApplyConfigurationResult(startResult.Result.ExtendedError.HResult, startResult.Result.DisplayMessage)); + } + + var guestSession = new GuestKvpSession(Guid.Parse(Id)); + + // Query VM by sending a request to DevSetupAgent. + var getVersionRequest = new GetVersionRequest(); + guestSession.SendRequest(getVersionRequest, CancellationToken.None); + var getVersionResponses = guestSession.WaitForResponse(getVersionRequest.RequestId, TimeSpan.FromSeconds(15), true, CancellationToken.None); + if (getVersionResponses.Count > 0) + { + var response = getVersionResponses[0]; + if (response is GetVersionResponse getVersionResponse) + { + // TODO: Check if VM can accept new Configure requests. Or if we need to update DevSetupAgent to a new version. + } + else + { + // TODO: Check if we can get any diagnostic from this unexpected response. + Logging.Logger()?.ReportError( + $"Unexpected response while applying configuration on {DateTime.Now}: " + + $"responseId: {response.RequestId}, responseType: {response.ResponseType}, " + + $"VM details: {this}"); + return operation.CompleteOperation(new HostGuestCommunication.ApplyConfigurationResult(HRESULT.E_FAIL, $"Received unexpected response from the VM")); + } + } + else + { + // No response from VM. Deploy DevSetupAgent to the VM. + for (var i = 0; i < MaxRetryAttempts; i++) + { + try + { + if (!DeployDevSetupAgent(operation)) + { + // User canceled the operation. + return operation.CompleteOperation(new HostGuestCommunication.ApplyConfigurationResult(HRESULT.E_ABORT, "User canceled the operation")); + } + + break; + } + catch (DevSetupAgentDeploymentSessionException ex) + { + // We couldn't create PS remote session to the VM. Retry to ask for credentials + if (i == (MaxRetryAttempts - 1)) + { + return operation.CompleteOperation(new HostGuestCommunication.ApplyConfigurationResult(ex.HResult, ex.Message)); + } + } + } + } + + // Ask VM if there is a logged in user. If not, we need to wait for user to log in. + for (var i = 0; i < MaxRetryAttempts; i++) + { + var userLoggedInRequest = new IsUserLoggedInRequest(); + guestSession.SendRequest(userLoggedInRequest, CancellationToken.None); + var userLoggedInResponses = guestSession.WaitForResponse(userLoggedInRequest.RequestId, TimeSpan.FromSeconds(15), true, CancellationToken.None); + if (userLoggedInResponses.Count > 0) + { + var response = userLoggedInResponses[0]; + if (response is IsUserLoggedInResponse userLoggedInResponse) + { + if (!userLoggedInResponse.IsUserLoggedIn) + { + if (!WaitForUserToLogin(operation)) + { + // User canceled the operation. + return operation.CompleteOperation(new HostGuestCommunication.ApplyConfigurationResult(HRESULT.E_ABORT, "User canceled the operation")); + } + + // Send request again to check if user is logged in if we didn't exceed maximum attempt number. + } + else + { + // User is logged in. We can continue with configuration. + break; + } + } + else + { + // TODO: Check if we can get any diagnostic from this unexpected response. + Logging.Logger()?.ReportError( + $"Unexpected response while applying configuration on {DateTime.Now}: " + + $"responseId: {response.RequestId}, responseType: {response.ResponseType}, " + + $"VM details: {this}"); + return operation.CompleteOperation(new HostGuestCommunication.ApplyConfigurationResult(HRESULT.E_FAIL, $"Received unexpected response from the VM")); + } + } + + // User is not logged in. We need to wait for user to log in. + if (i == (MaxRetryAttempts - 1)) + { + return operation.CompleteOperation(new HostGuestCommunication.ApplyConfigurationResult(HRESULT.E_ABORT, "No interactive user on the VM")); + } + } + + var configureRequest = new ConfigureRequest(operation.Configuration); + guestSession.SendRequest(configureRequest, CancellationToken.None); + + // Wait for response. 5 hours is an arbitrary period of time that should be big enough + // for most scenarios. + // Configuration task can run for a long time that we can't control. What's more, VM can be saved or paused, + // then started and configuration task will continue to run. + // TODO: To improve this we can: + // make the timeout configurable. + // query VM if it has completed the configuration task. + // monitor if VM was paused or saved while we are waiting for responses. + var waitTime = TimeSpan.FromHours(5); + var startTime = DateTime.Now; + while ((DateTime.Now - startTime) < waitTime) + { + var responses = guestSession.WaitForResponse(configureRequest.RequestId, TimeSpan.FromSeconds(30), true, CancellationToken.None); + + foreach (var response in responses) + { + if (response is ConfigureResponse configureResponse) + { + LogApplyConfigurationResult(configureResponse.ApplyConfigurationResult); + + // Create SDK's result. Set Completed status and event. + // Receiving ConfigureResponse means operation has completed. We don't expect anymore responses. + return operation.CompleteOperation(configureResponse.ApplyConfigurationResult); + } + else if (response is ConfigureProgressResponse configureProgressResponse) + { + LogApplyConfigurationProgress(configureProgressResponse.ProgressData); + + // Create SDK's result. Set Completed status and event. + operation.SetProgress(ConfigurationSetState.InProgress, configureProgressResponse.ProgressData, null); + } + else + { + // Unexpected (error) response. Log it and return error. Not much we can do here. + Logging.Logger()?.ReportError( + $"Unexpected response while applying configuration on {DateTime.Now}: " + + $"responseId: {response.RequestId}, responseType: {response.ResponseType}, " + + $"VM details: {this}"); + } + } + } + + throw new TimeoutException("Timeout while waiting for the configuration task to complete"); + } + catch (Exception ex) + { + Logging.Logger()?.ReportError($"Failed to apply configuration on {DateTime.Now}: VM details: {this}", ex); + return operation.CompleteOperation(new HostGuestCommunication.ApplyConfigurationResult(ex.HResult, ex.Message)); + } + } + + public IApplyConfigurationOperation? CreateApplyConfigurationOperation(string configuration) + { + try + { + return new ApplyConfigurationOperation(this, configuration); + } + catch (Exception ex) + { + Logging.Logger()?.ReportError($"Failed to apply configuration on {DateTime.Now}: VM details: {this}", ex); + return new ApplyConfigurationOperation(this, ex); + } + } + + private bool DeployDevSetupAgent(ApplyConfigurationOperation operation) + { + var powerShell = _host.GetService(); + var credentialsAdaptiveCardSession = new VmCredentialAdaptiveCardSession(operation); + + operation.SetProgress(ConfigurationSetState.WaitingForAdminUserLogon, null, credentialsAdaptiveCardSession); + + (var userName, var password) = credentialsAdaptiveCardSession.WaitForCredentials(); + + if ((userName != null) && (password != null)) + { + var deploymentHelper = new DevSetupAgentDeploymentHelper(powerShell, Id); + deploymentHelper.DeployDevSetupAgent(userName, password); + return true; + } + else + { + return false; + } + } + + private bool WaitForUserToLogin(ApplyConfigurationOperation operation) + { + // Ask user to login to the VM and wait for confirmation. + var waitForLoginAdaptiveCardSession = new WaitForLoginAdaptiveCardSession(operation); + + operation.SetProgress(ConfigurationSetState.WaitingForUserLogon, null, waitForLoginAdaptiveCardSession); + + return waitForLoginAdaptiveCardSession.WaitForUserResponse(); + } + + // TODO: This can be to noisy. We need "verbose" logging level for this. + private void LogApplyConfigurationProgress(HostGuestCommunication.ConfigurationSetChangeData progressData) + { + } + + private void LogApplyConfigurationResult(HostGuestCommunication.ApplyConfigurationResult applyConfigurationResult) + { + } + + public override string ToString() + { + StringBuilder builder = new(); + builder.AppendLine(CultureInfo.InvariantCulture, $"VM Id: {Id} "); + builder.AppendLine(CultureInfo.InvariantCulture, $"VM Name: {DisplayName} "); + builder.AppendLine(CultureInfo.InvariantCulture, $"VM CreationTime: {CreationTime} "); + builder.AppendLine(CultureInfo.InvariantCulture, $"VM State: {State} "); + builder.AppendLine(CultureInfo.InvariantCulture, $"VM Status: {Status} "); + builder.AppendLine(CultureInfo.InvariantCulture, $"VM CPUUsage: {CPUUsage}% "); + builder.AppendLine(CultureInfo.InvariantCulture, $"VM Uptime: {Uptime} "); + builder.AppendLine(CultureInfo.InvariantCulture, $"VM MemoryStatus: {MemoryStatus} "); + builder.AppendLine(CultureInfo.InvariantCulture, $"VM MemoryAssigned: {BytesHelper.ConvertFromBytes((ulong)MemoryAssigned)} "); + builder.AppendLine(CultureInfo.InvariantCulture, $"VM MemoryDemand: {BytesHelper.ConvertFromBytes((ulong)MemoryDemand)} "); + builder.AppendLine(CultureInfo.InvariantCulture, $"VM StartupMemory: {BytesHelper.ConvertFromBytes((ulong)MemoryStartup)} "); + builder.AppendLine(CultureInfo.InvariantCulture, $"VM MinimumMemory: {BytesHelper.ConvertFromBytes((ulong)MemoryMinimum)} "); + builder.AppendLine(CultureInfo.InvariantCulture, $"VM MaximumMemory: {BytesHelper.ConvertFromBytes((ulong)MemoryMaximum)} "); + builder.AppendLine(CultureInfo.InvariantCulture, $"VM DynamicMemoryEnabled: {DynamicMemoryEnabled} "); + builder.AppendLine(CultureInfo.InvariantCulture, $"VM ParentCheckpointId: {ParentCheckpointId} "); + + var hardDisks = GetHardDrives(); + builder.AppendLine(CultureInfo.InvariantCulture, $"Number of Harddisks: {hardDisks.Count()} "); + + foreach (var hardDisk in hardDisks) + { + builder.AppendLine(hardDisk.ToString()); + } + + return builder.ToString(); + } + + private string OperationErrorString(ComputeSystemOperations operation) + { + return $"Failed to complete {operation} operation on {DateTime.Now}: VM details: {this}"; + } + + private string OperationSuccessString(ComputeSystemOperations operation) + { + return $"Successfully completed {operation} operation on {DateTime.Now}: VM details: {this}"; + } +} diff --git a/HyperVExtension/src/HyperVExtension/Models/HyperVVirtualMachineHardDisk.cs b/HyperVExtension/src/HyperVExtension/Models/HyperVVirtualMachineHardDisk.cs new file mode 100644 index 0000000000..3de4fb9278 --- /dev/null +++ b/HyperVExtension/src/HyperVExtension/Models/HyperVVirtualMachineHardDisk.cs @@ -0,0 +1,37 @@ +// Copyright (c) Microsoft Corporation. +// Licensed under the MIT License. + +using System.Globalization; +using System.Text; +using HyperVExtension.Helpers; + +namespace HyperVExtension.Models; + +public class HyperVVirtualMachineHardDisk +{ + public string? ComputerName { get; set; } + + public string? Name { get; set; } + + public string? Path { get; set; } + + public Guid VmId { get; set; } + + public string? VMName { get; set; } + + public Guid VMSnapshotId { get; set; } + + public string? VMSnapshotName { get; set; } + + public ulong DiskSizeInBytes { get; set; } + + public override string ToString() + { + StringBuilder builder = new(); + builder.AppendLine(CultureInfo.InvariantCulture, $"VM HardDisk Name: {Name} "); + builder.AppendLine(CultureInfo.InvariantCulture, $"VM HardDisk VmId: {VmId} "); + builder.AppendLine(CultureInfo.InvariantCulture, $"VM HardDisk Size : {BytesHelper.ConvertFromBytes(DiskSizeInBytes)} "); + builder.AppendLine(CultureInfo.InvariantCulture, $"VM HardDisk VMSnapshotId : {VMSnapshotId} "); + return builder.ToString(); + } +} diff --git a/HyperVExtension/src/HyperVExtension/Models/IPowerShellSession.cs b/HyperVExtension/src/HyperVExtension/Models/IPowerShellSession.cs new file mode 100644 index 0000000000..33d41d1c8d --- /dev/null +++ b/HyperVExtension/src/HyperVExtension/Models/IPowerShellSession.cs @@ -0,0 +1,40 @@ +// Copyright (c) Microsoft Corporation. +// Licensed under the MIT License. + +using System.Collections; +using System.Collections.ObjectModel; +using System.Management.Automation; + +namespace HyperVExtension.Models; + +/// +/// Wrapper interface for interacting with the PowerShell class in the System.Management.Automation +/// assembly. +/// +public interface IPowerShellSession +{ + /// Adds a command to the PowerShell session. + /// A string representing the name of a cmdlet that can be run by PowerShell + public void AddCommand(string command); + + /// Adds parameters to the PowerShell session. + /// + /// Key value pairs where the key is the parameter name and the value is the value + /// associated with the parameter. + /// + public void AddParameters(IDictionary parameters); + + /// Adds a script to the PowerShell session. + /// A string representing a script that can be run by PowerShell. + public void AddScript(string script, bool useLocalScope); + + /// Invokes the PowerShell statements. + /// A collection of PowerShell Objects returned by PowerShell. + public Collection Invoke(); + + /// Clears the PowerShell session by removing all commands and error streams. + public void ClearSession(); + + /// Gets the error messages associated with this instance of the PowerShell session. + public string GetErrorMessages(); +} diff --git a/HyperVExtension/src/HyperVExtension/Models/IWindowsIdentityService.cs b/HyperVExtension/src/HyperVExtension/Models/IWindowsIdentityService.cs new file mode 100644 index 0000000000..f14e705aed --- /dev/null +++ b/HyperVExtension/src/HyperVExtension/Models/IWindowsIdentityService.cs @@ -0,0 +1,12 @@ +// Copyright (c) Microsoft Corporation. +// Licensed under the MIT License. + +namespace HyperVExtension.Models; + +/// +/// Wrapper interface that can be used to get the WindowsIdentityWrapper. +/// +public interface IWindowsIdentityService +{ + public WindowsIdentityWrapper GetCurrentWindowsIdentity(); +} diff --git a/HyperVExtension/src/HyperVExtension/Models/IWindowsServiceController.cs b/HyperVExtension/src/HyperVExtension/Models/IWindowsServiceController.cs new file mode 100644 index 0000000000..1fd2b205cf --- /dev/null +++ b/HyperVExtension/src/HyperVExtension/Models/IWindowsServiceController.cs @@ -0,0 +1,22 @@ +// Copyright (c) Microsoft Corporation. +// Licensed under the MIT License. + +using System.ServiceProcess; + +namespace HyperVExtension.Models; + +/// +/// Wrapper interface for the Service Controller class. +/// +public interface IWindowsServiceController +{ + public ServiceControllerStatus Status { get; } + + public string ServiceName { get; set; } + + public void ContinueService(); + + public void StartService(); + + public void WaitForStatusChange(ServiceControllerStatus desiredStatus, TimeSpan timeout); +} diff --git a/HyperVExtension/src/HyperVExtension/Models/PowerShellCommandlineStatement.cs b/HyperVExtension/src/HyperVExtension/Models/PowerShellCommandlineStatement.cs new file mode 100644 index 0000000000..9210fc7e68 --- /dev/null +++ b/HyperVExtension/src/HyperVExtension/Models/PowerShellCommandlineStatement.cs @@ -0,0 +1,73 @@ +// Copyright (c) Microsoft Corporation. +// Licensed under the MIT License. + +using System.Globalization; +using System.Text; + +namespace HyperVExtension.Models; + +/// +/// Class that can contain a PowerShell command and its parameters or a PowerShell script. +/// +public class PowerShellCommandlineStatement +{ + /// Gets or sets the command to use in the PowerShell command line statement. + /// + /// Should not be used in the same object + /// when is non-empty. This could + /// lead to undesired results. + /// + public string Command { get; set; } = string.Empty; + + /// Gets or sets the parameters for the PowerShell command. + public Dictionary Parameters { get; set; } = new(); + + /// + /// Gets or sets the script for the PowerShell command line statement. + /// + /// + /// Should not be used in the same object + /// when is non-empty. This could + /// lead to undesired results. + /// + public string Script { get; set; } = string.Empty; + + /// + /// Gets or sets a value indicating whether to use local scope for the PowerShell script. + /// + public bool UseLocalScope { get; set; } = true; + + /// Returns a clone of the PowerShellCommandlineStatement object. + public PowerShellCommandlineStatement Clone() + { + return new PowerShellCommandlineStatement + { + Command = Command, + Parameters = Parameters, + Script = Script, + UseLocalScope = UseLocalScope, + }; + } + + public override string ToString() + { + StringBuilder builder = new(); + builder.AppendLine(CultureInfo.InvariantCulture, $"Command: {Command}"); + builder.AppendLine(CultureInfo.InvariantCulture, $"Parameters: {CreateParameterString()}"); + builder.AppendLine(CultureInfo.InvariantCulture, $"Script: {Script}"); + builder.AppendLine(CultureInfo.InvariantCulture, $"UseLocalScope: {UseLocalScope}"); + + return builder.ToString(); + } + + private string CreateParameterString() + { + StringBuilder builder = new(); + foreach (var parameter in Parameters) + { + builder.AppendLine(CultureInfo.InvariantCulture, $"{parameter.Key} = {parameter.Value}"); + } + + return builder.ToString(); + } +} diff --git a/HyperVExtension/src/HyperVExtension/Models/PowerShellResult.cs b/HyperVExtension/src/HyperVExtension/Models/PowerShellResult.cs new file mode 100644 index 0000000000..8e440ecd91 --- /dev/null +++ b/HyperVExtension/src/HyperVExtension/Models/PowerShellResult.cs @@ -0,0 +1,17 @@ +// Copyright (c) Microsoft Corporation. +// Licensed under the MIT License. + +using System.Collections.ObjectModel; +using System.Management.Automation; + +namespace HyperVExtension.Models; + +/// A class that represents the result of a PowerShell command +public class PowerShellResult : PowerShellResultBase +{ + /// + public override Collection PsObjects { get; set; } = new(); + + /// + public override string CommandOutputErrorMessage { get; set; } = string.Empty; +} diff --git a/HyperVExtension/src/HyperVExtension/Models/PowerShellResultBase.cs b/HyperVExtension/src/HyperVExtension/Models/PowerShellResultBase.cs new file mode 100644 index 0000000000..f0537b7fde --- /dev/null +++ b/HyperVExtension/src/HyperVExtension/Models/PowerShellResultBase.cs @@ -0,0 +1,25 @@ +// Copyright (c) Microsoft Corporation. +// Licensed under the MIT License. + +using System.Collections.ObjectModel; +using System.Management.Automation; + +namespace HyperVExtension.Models; + +/// The base class for all PowerShell results +public abstract class PowerShellResultBase +{ + /// Gets or sets the base object of the PowerShell result. + /// + /// This is the PowerShell object that is returned from the invoke method + /// inside the PowerShell session. + /// + public abstract Collection PsObjects { get; set; } + + /// Gets or sets the error message return by PowerShell. + /// + /// This is the error message that is returned when the command returns an error + /// in the PowerShell runspace. This is used for logging and debugging purposes. + /// + public abstract string CommandOutputErrorMessage { get; set; } +} diff --git a/HyperVExtension/src/HyperVExtension/Models/PowerShellSession.cs b/HyperVExtension/src/HyperVExtension/Models/PowerShellSession.cs new file mode 100644 index 0000000000..7a7a201dc5 --- /dev/null +++ b/HyperVExtension/src/HyperVExtension/Models/PowerShellSession.cs @@ -0,0 +1,82 @@ +// Copyright (c) Microsoft Corporation. +// Licensed under the MIT License. + +using System; +using System.Collections; +using System.Collections.ObjectModel; +using System.Management.Automation; +using System.Text; +using System.Threading.Tasks; + +namespace HyperVExtension.Models; + +/// +/// Wrapper class for interacting with the PowerShell class in the System.Management.Automation +/// assembly. +/// +public class PowerShellSession : IPowerShellSession, IDisposable +{ + private readonly PowerShell _powerShellSession; + private bool _disposedValue; + + public PowerShellSession() + { + _powerShellSession = PowerShell.Create(); + } + + /// + public void AddCommand(string command) + { + _powerShellSession.AddCommand(command); + } + + /// + public void AddParameters(IDictionary parameters) + { + _powerShellSession.AddParameters(parameters); + } + + /// + public void AddScript(string script, bool useLocalScope) + { + _powerShellSession.AddScript(script, useLocalScope); + } + + /// + public Collection Invoke() + { + return _powerShellSession.Invoke(); + } + + /// + public void ClearSession() + { + _powerShellSession.Commands.Clear(); + _powerShellSession.Streams.ClearStreams(); + } + + /// + public string GetErrorMessages() + { + return string.Join(Environment.NewLine, _powerShellSession.Streams.Error.Select(err => err.Exception.Message)); + } + + protected virtual void Dispose(bool disposing) + { + if (!_disposedValue) + { + if (disposing) + { + _powerShellSession.Dispose(); + } + + _disposedValue = true; + } + } + + public void Dispose() + { + Dispose(disposing: true); + GC.SuppressFinalize(this); + } +} diff --git a/HyperVExtension/src/HyperVExtension/Models/VmCredentialAdaptiveCardSession.cs b/HyperVExtension/src/HyperVExtension/Models/VmCredentialAdaptiveCardSession.cs new file mode 100644 index 0000000000..d699d3a6ff --- /dev/null +++ b/HyperVExtension/src/HyperVExtension/Models/VmCredentialAdaptiveCardSession.cs @@ -0,0 +1,191 @@ +// Copyright (c) Microsoft Corporation. +// Licensed under the MIT License. + +using System.Net; +using System.Security; +using HyperVExtension.CommunicationWithGuest; +using HyperVExtension.Helpers; +using HyperVExtension.Providers; +using Microsoft.Windows.DevHome.SDK; +using Windows.Foundation; + +namespace HyperVExtension.Models; + +public sealed class VmCredentialAdaptiveCardSession : IExtensionAdaptiveCardSession2, IDisposable +{ + private sealed class InputPayload + { + public string? Id + { + get; set; + } + + public string? UserVal + { + get; set; + } + + public string? PassVal + { + get; set; + } + } + + private readonly ApplyConfigurationOperation _operation; + private readonly ManualResetEvent _sessionStatusChangedEvent = new(false); + private IExtensionAdaptiveCard? _extensionAdaptiveCard; + private string? _usernameString; + private SecureString? _passwordString; + private bool _disposed; + + public event TypedEventHandler? Stopped; + + public VmCredentialAdaptiveCardSession(ApplyConfigurationOperation operation) + { + _operation = operation; + } + + void IExtensionAdaptiveCardSession.Dispose() + { + ((IDisposable)this).Dispose(); + } + + public ProviderOperationResult Initialize(IExtensionAdaptiveCard extensionUI) + { + _extensionAdaptiveCard = extensionUI; + var operationResult = _extensionAdaptiveCard.Update(GetTemplate(), null, "VmCredential"); + return operationResult; + } + + public IAsyncOperation OnAction(string action, string inputs) + { + return Task.Run(() => + { + ProviderOperationResult operationResult; + try + { + Logging.Logger()?.ReportInfo($"OnAction() called with state:{_extensionAdaptiveCard?.State}"); + Logging.Logger()?.ReportDebug($"action: {action}"); + + switch (_extensionAdaptiveCard?.State) + { + case "VmCredential": + { + Logging.Logger()?.ReportDebug($"inputs: {inputs}"); + var actionPayload = Helpers.Json.ToObject(action) ?? throw new InvalidOperationException("Invalid action"); + if (actionPayload.IsOkAction()) + { + var inputPayload = Helpers.Json.ToObject(inputs) ?? throw new InvalidOperationException("Invalid inputs"); + _usernameString = inputPayload.UserVal; + _passwordString = new NetworkCredential(string.Empty, inputPayload.PassVal).SecurePassword; + } + + operationResult = new ProviderOperationResult(ProviderOperationStatus.Success, null, null, null); + _sessionStatusChangedEvent.Set(); + break; + } + + default: + { + Logging.Logger()?.ReportError($"Unexpected state:{_extensionAdaptiveCard?.State}"); + operationResult = new ProviderOperationResult(ProviderOperationStatus.Failure, null, "Something went wrong", $"Unexpected state:{_extensionAdaptiveCard?.State}"); + break; + } + } + } + catch (Exception ex) + { + Logging.Logger()?.ReportError($"Exception in OnAction: {ex}"); + operationResult = new ProviderOperationResult(ProviderOperationStatus.Failure, ex, "Something went wrong", ex.Message); + } + + Stopped?.Invoke(this, new(operationResult, string.Empty)); + return operationResult; + }).AsAsyncOperation(); + } + + public (string? userName, SecureString? password) WaitForCredentials() + { + WaitHandle.WaitAny([_sessionStatusChangedEvent, _operation.CancellationToken.WaitHandle]); + return (_usernameString, _passwordString); + } + + void IDisposable.Dispose() + { + Dispose(true); + GC.SuppressFinalize(this); + } + + private void Dispose(bool disposing) + { + if (!_disposed) + { + if (disposing) + { + _sessionStatusChangedEvent?.Dispose(); + } + + _disposed = true; + } + } + + private string GetTemplate() + { + return Resources.ReplaceIdentifers(_credentialUITemplate, Resources.GetHyperVResourceIdentifiers(), Logging.Logger()); + } + + private static readonly string _credentialUITemplate = @" +{ + ""$schema"": ""http://adaptivecards.io/schemas/adaptive-card.json"", + ""type"": ""AdaptiveCard"", + ""version"": ""1.5"", + ""body"": [ + { + ""type"": ""TextBlock"", + ""text"": ""%VmCredentialRequest/Title%"", + ""horizontalAlignment"": ""Center"", + ""wrap"": true, + ""style"": ""heading"" + }, + { + ""type"": ""TextBlock"", + ""text"": ""%VmCredentialRequest/Description1%"", + ""wrap"": true + }, + { + ""type"": ""TextBlock"", + ""text"": ""%VmCredentialRequest/Description2%"", + ""wrap"": true + }, + { + ""type"": ""Input.Text"", + ""id"": ""UserVal"", + ""label"": ""%VmCredentialRequest/UsernameLabel%"", + ""isRequired"": true, + ""errorMessage"": ""%VmCredentialRequest/UsernameErrorMsg%"" + }, + { + ""type"": ""Input.Text"", + ""id"": ""PassVal"", + ""style"": ""Password"", + ""label"": ""%VmCredentialRequest/PasswordLabel%"" + } + ], + ""actions"": [ + { + ""type"": ""Action.Execute"", + ""title"": ""%VmCredentialRequest/OkText%"", + ""id"": ""okAction"", + ""data"": { + ""id"": ""okAction"" + } + }, + { + ""type"": ""Action.Execute"", + ""title"": ""%VmCredentialRequest/CancelText%"", + ""id"": ""cancelAction"" + } + ] +} +"; +} diff --git a/HyperVExtension/src/HyperVExtension/Models/WaitForLoginAdaptiveCardSession.cs b/HyperVExtension/src/HyperVExtension/Models/WaitForLoginAdaptiveCardSession.cs new file mode 100644 index 0000000000..6f2b5c3032 --- /dev/null +++ b/HyperVExtension/src/HyperVExtension/Models/WaitForLoginAdaptiveCardSession.cs @@ -0,0 +1,162 @@ +// Copyright (c) Microsoft Corporation. +// Licensed under the MIT License. + +using System.Net; +using System.Security; +using HyperVExtension.CommunicationWithGuest; +using HyperVExtension.Helpers; +using HyperVExtension.Providers; +using Microsoft.Windows.DevHome.SDK; +using Windows.Foundation; + +namespace HyperVExtension.Models; + +public sealed class WaitForLoginAdaptiveCardSession : IExtensionAdaptiveCardSession2, IDisposable +{ + private sealed class InputPayload + { + public string? Id + { + get; set; + } + } + + private readonly ApplyConfigurationOperation _operation; + private readonly ManualResetEvent _sessionStatusChangedEvent = new(false); + private IExtensionAdaptiveCard? _extensionAdaptiveCard; + private bool _isUserLoggedIn; + private bool _disposed; + + public event TypedEventHandler? Stopped; + + public WaitForLoginAdaptiveCardSession(ApplyConfigurationOperation operation) + { + _operation = operation; + } + + void IExtensionAdaptiveCardSession.Dispose() + { + ((IDisposable)this).Dispose(); + } + + public ProviderOperationResult Initialize(IExtensionAdaptiveCard extensionUI) + { + _extensionAdaptiveCard = extensionUI; + var operationResult = _extensionAdaptiveCard.Update(GetTemplate(), null, "WaitForVmUserLogin"); + return operationResult; + } + + public IAsyncOperation OnAction(string action, string inputs) + { + return Task.Run(() => + { + ProviderOperationResult operationResult; + try + { + Logging.Logger()?.ReportInfo($"OnAction() called with state:{_extensionAdaptiveCard?.State}"); + Logging.Logger()?.ReportDebug($"action: {action}"); + + switch (_extensionAdaptiveCard?.State) + { + case "WaitForVmUserLogin": + { + Logging.Logger()?.ReportDebug($"inputs: {inputs}"); + var actionPayload = Helpers.Json.ToObject(action) ?? throw new InvalidOperationException("Invalid action"); + if (actionPayload.IsOkAction()) + { + _isUserLoggedIn = true; + } + + operationResult = new ProviderOperationResult(ProviderOperationStatus.Success, null, null, null); + _sessionStatusChangedEvent.Set(); + break; + } + + default: + { + Logging.Logger()?.ReportError($"Unexpected state:{_extensionAdaptiveCard?.State}"); + operationResult = new ProviderOperationResult(ProviderOperationStatus.Failure, null, "Something went wrong", $"Unexpected state:{_extensionAdaptiveCard?.State}"); + break; + } + } + } + catch (Exception ex) + { + Logging.Logger()?.ReportError($"Exception in OnAction: {ex}"); + operationResult = new ProviderOperationResult(ProviderOperationStatus.Failure, ex, "Something went wrong", ex.Message); + } + + Stopped?.Invoke(this, new(operationResult, string.Empty)); + return operationResult; + }).AsAsyncOperation(); + } + + public bool WaitForUserResponse() + { + WaitHandle.WaitAny(new[] { _sessionStatusChangedEvent, _operation.CancellationToken.WaitHandle }); + return _isUserLoggedIn; + } + + void IDisposable.Dispose() + { + Dispose(true); + GC.SuppressFinalize(this); + } + + private void Dispose(bool disposing) + { + if (!_disposed) + { + if (disposing) + { + _sessionStatusChangedEvent?.Dispose(); + } + + _disposed = true; + } + } + + private string GetTemplate() + { + return Resources.ReplaceIdentifers(_credentialUITemplate, Resources.GetHyperVResourceIdentifiers(), Logging.Logger()); + } + + private static readonly string _credentialUITemplate = @" +{ + ""type"": ""AdaptiveCard"", + ""$schema"": ""http://adaptivecards.io/schemas/adaptive-card.json"", + ""version"": ""1.5"", + ""body"": [ + { + ""type"": ""TextBlock"", + ""text"": ""%WaitForLoginRequest/Title%"", + ""wrap"": true, + ""style"": ""heading"" + }, + { + ""type"": ""TextBlock"", + ""text"": ""%WaitForLoginRequest/Description%"", + ""wrap"": true + } + ], + ""actions"": [ + { + ""type"": ""Action.Execute"", + ""title"": ""%VmCredentialRequest/OkText%"", + ""data"": { + ""id"": ""okAction"" + }, + ""id"": ""okAction"" + }, + { + ""type"": ""Action.Execute"", + ""title"": ""%VmCredentialRequest/CancelText%"", + ""data"": { + ""id"": ""cancelAction"" + }, + ""id"": ""cancelAction"" + } + ] +} +"; +} diff --git a/HyperVExtension/src/HyperVExtension/Models/WindowsIdentityService.cs b/HyperVExtension/src/HyperVExtension/Models/WindowsIdentityService.cs new file mode 100644 index 0000000000..d1a7415244 --- /dev/null +++ b/HyperVExtension/src/HyperVExtension/Models/WindowsIdentityService.cs @@ -0,0 +1,18 @@ +// Copyright (c) Microsoft Corporation. +// Licensed under the MIT License. + +using System; +using System.Collections.Generic; +using System.Linq; +using System.Text; +using System.Threading.Tasks; + +namespace HyperVExtension.Models; + +public class WindowsIdentityService : IWindowsIdentityService +{ + public WindowsIdentityWrapper GetCurrentWindowsIdentity() + { + return new WindowsIdentityWrapper(); + } +} diff --git a/HyperVExtension/src/HyperVExtension/Models/WindowsIdentityWrapper.cs b/HyperVExtension/src/HyperVExtension/Models/WindowsIdentityWrapper.cs new file mode 100644 index 0000000000..c677941ace --- /dev/null +++ b/HyperVExtension/src/HyperVExtension/Models/WindowsIdentityWrapper.cs @@ -0,0 +1,17 @@ +// Copyright (c) Microsoft Corporation. +// Licensed under the MIT License. + +using System.Security.Principal; + +namespace HyperVExtension.Models; + +/// +/// Wrapper class for the WindowsIdentity class. +/// +public class WindowsIdentityWrapper +{ + private readonly WindowsIdentity _windowsIdentity = WindowsIdentity.GetCurrent(); + + // Get the sid's of the current user. + public virtual IdentityReferenceCollection Groups => _windowsIdentity.Groups!; +} diff --git a/HyperVExtension/src/HyperVExtension/Models/WindowsServiceController.cs b/HyperVExtension/src/HyperVExtension/Models/WindowsServiceController.cs new file mode 100644 index 0000000000..7fbbe37aca --- /dev/null +++ b/HyperVExtension/src/HyperVExtension/Models/WindowsServiceController.cs @@ -0,0 +1,77 @@ +// Copyright (c) Microsoft Corporation. +// Licensed under the MIT License. + +using System.ServiceProcess; +using DevHome.Logging; +using HyperVExtension.Services; + +namespace HyperVExtension.Models; + +/// +/// Class used as a wrapper for the ServiceController class which can be mocked. +/// +public class WindowsServiceController : IWindowsServiceController, IDisposable +{ + private readonly ServiceController _serviceController; + + public ServiceControllerStatus Status => _serviceController.Status; + + public string ServiceName + { + get => _serviceController.ServiceName; + set => _serviceController.ServiceName = value; + } + + public WindowsServiceController() + { + _serviceController = new(); + } + + public WindowsServiceController(string serviceName) + : this() + { + _serviceController.ServiceName = serviceName; + } + + private bool _disposed; + + public void ContinueService() + { + _serviceController.Continue(); + } + + public void StartService() + { + _serviceController.Start(); + } + + public void WaitForStatusChange(ServiceControllerStatus desiredStatus, TimeSpan timeout) + { + _serviceController.WaitForStatus(desiredStatus, timeout); + } + + private void Dispose(bool disposing) + { + if (!_disposed) + { + LogEvent.Create( + nameof(WindowsServiceController), + string.Empty, + SeverityLevel.Debug, + "Disposing WindowsServiceController"); + + if (disposing) + { + _serviceController.Dispose(); + } + } + + _disposed = true; + } + + public void Dispose() + { + Dispose(true); + GC.SuppressFinalize(this); + } +} diff --git a/HyperVExtension/src/HyperVExtension/NativeMethods.txt b/HyperVExtension/src/HyperVExtension/NativeMethods.txt new file mode 100644 index 0000000000..fc7b5a2b8d --- /dev/null +++ b/HyperVExtension/src/HyperVExtension/NativeMethods.txt @@ -0,0 +1,5 @@ +GetCurrentPackageFullName +WIN32_ERROR +E_FAIL +E_ABORT +S_OK diff --git a/HyperVExtension/src/HyperVExtension/Providers/HyperVProvider.cs b/HyperVExtension/src/HyperVExtension/Providers/HyperVProvider.cs new file mode 100644 index 0000000000..89ce9251fc --- /dev/null +++ b/HyperVExtension/src/HyperVExtension/Providers/HyperVProvider.cs @@ -0,0 +1,98 @@ +// Copyright (c) Microsoft Corporation. +// Licensed under the MIT License. + +using HyperVExtension.Common; +using HyperVExtension.Exceptions; +using HyperVExtension.Helpers; +using HyperVExtension.Services; +using Microsoft.Windows.DevHome.SDK; +using Windows.Foundation; + +namespace HyperVExtension.Providers; + +/// Class that provides compute system information for Hyper-V Virtual machines. +public class HyperVProvider : IComputeSystemProvider +{ + private readonly string errorResourceKey = "ErrorPerformingOperation"; + + private readonly IStringResource _stringResource; + + private readonly IHyperVManager _hyperVManager; + + // Temporary will need to add more error strings for different operations. + public string OperationErrorString => _stringResource.GetLocalized(errorResourceKey); + + public HyperVProvider(IHyperVManager hyperVManager, IStringResource stringResource) + { + _hyperVManager = hyperVManager; + _stringResource = stringResource; + } + + /// Gets or sets the default compute system properties. + public string DefaultComputeSystemProperties { get; set; } = string.Empty; + + /// Gets the display name of the provider. This shouldn't be localized. + public string DisplayName { get; } = HyperVStrings.HyperVProviderDisplayName; + + /// Gets the ID of the Hyper-V provider. + public string Id { get; } = HyperVStrings.HyperVProviderId; + + /// Gets the properties of the provider. + public string Properties { get; private set; } = string.Empty; + + /// Gets the supported operations of the Hyper-V provider. + /// TODO: currently only CreateComputeSystem is supported in the SDK. For Hyper-V v1 creation + /// won't be supported. + public ComputeSystemProviderOperations SupportedOperations => ComputeSystemProviderOperations.CreateComputeSystem; + + public Uri? Icon + { + get => new(Constants.ExtensionIcon); + set => throw new NotSupportedException("Setting the icon is not supported"); + } + + /// Creates a new Hyper-V compute system. + /// Optional string with parameters that the Hyper-V provider can recognize + public ICreateComputeSystemOperation? CreateComputeSystem(IDeveloperId developerId, string options) + { + // This is temporary until we have a proper implementation for this. + Logging.Logger()?.ReportError($"creation not supported yet for hyper-v"); + return null; + } + + /// Gets a list of all Hyper-V compute systems. The developerId is not used by the Hyper-V provider + public IAsyncOperation GetComputeSystemsAsync(IDeveloperId developerId) + { + return Task.Run(() => + { + try + { + var computeSystems = _hyperVManager.GetAllVirtualMachines(); + Logging.Logger()?.ReportInfo($"Successfully retrieved all virtual machines on: {DateTime.Now}"); + return new ComputeSystemsResult(computeSystems); + } + catch (Exception ex) + { + Logging.Logger()?.ReportError($"Failed to retrieved all virtual machines on: {DateTime.Now}", ex); + return new ComputeSystemsResult(ex, OperationErrorString, ex.Message); + } + }).AsAsyncOperation(); + } + + public ComputeSystemAdaptiveCardResult CreateAdaptiveCardSessionForDeveloperId(IDeveloperId developerId, ComputeSystemAdaptiveCardKind sessionKind) + { + // This won't be supported until creation is supported. + var notImplementedException = new NotImplementedException($"Method not implemented by Hyper-V Compute System Provider"); + return new ComputeSystemAdaptiveCardResult(notImplementedException, OperationErrorString, notImplementedException.Message); + } + + public ComputeSystemAdaptiveCardResult CreateAdaptiveCardSessionForComputeSystem(IComputeSystem computeSystem, ComputeSystemAdaptiveCardKind sessionKind) + { + // This won't be supported until property modification is supported. + var notImplementedException = new NotImplementedException($"Method not implemented by Hyper-V Compute System Provider"); + return new ComputeSystemAdaptiveCardResult(notImplementedException, OperationErrorString, notImplementedException.Message); + } + + // This will be implemented in a future release, but will be available for Dev Environments 1.0. + public ICreateComputeSystemOperation CreateCreateComputeSystemOperation(IDeveloperId developerId, string inputJson) => throw new NotImplementedException(); +} diff --git a/HyperVExtension/src/HyperVExtension/Providers/Logging.cs b/HyperVExtension/src/HyperVExtension/Providers/Logging.cs new file mode 100644 index 0000000000..848b48d9de --- /dev/null +++ b/HyperVExtension/src/HyperVExtension/Providers/Logging.cs @@ -0,0 +1,47 @@ +// Copyright (c) Microsoft Corporation. +// Licensed under the MIT License. + +using DevHome.Logging; +using Windows.Storage; + +namespace HyperVExtension.Providers; + +public class Logging +{ + private static Logger? _logger; + + public static Logger? Logger() + { + try + { + _logger ??= new Logger("HyperVExtension", GetLoggingOptions()); + } + catch + { + // Do nothing if logger fails. + } + + return _logger; + } + + public static Options GetLoggingOptions() + { + return new Options + { + LogFileFolderRoot = ApplicationData.Current.TemporaryFolder.Path, + LogFileName = "HyperVExtension_{now}.log", + LogFileFolderName = "HyperVExtension", + DebugListenerEnabled = true, +#if DEBUG + LogStdoutEnabled = true, + LogStdoutFilter = SeverityLevel.Debug, + LogFileFilter = SeverityLevel.Debug, +#else + LogStdoutEnabled = false, + LogStdoutFilter = SeverityLevel.Info, + LogFileFilter = SeverityLevel.Info, +#endif + FailFastSeverity = FailFastSeverityLevel.Critical, + }; + } +} diff --git a/HyperVExtension/src/HyperVExtension/Services/HyperVManager.cs b/HyperVExtension/src/HyperVExtension/Services/HyperVManager.cs new file mode 100644 index 0000000000..5655b88662 --- /dev/null +++ b/HyperVExtension/src/HyperVExtension/Services/HyperVManager.cs @@ -0,0 +1,827 @@ +// Copyright (c) Microsoft Corporation. +// Licensed under the MIT License. + +using System.Globalization; +using System.Management.Automation; +using System.Security.Principal; +using System.ServiceProcess; +using DevHome.Logging; +using HyperVExtension.Common.Extensions; +using HyperVExtension.Exceptions; +using HyperVExtension.Helpers; +using HyperVExtension.Models; +using HyperVExtension.Providers; +using Microsoft.Extensions.Hosting; + +namespace HyperVExtension.Services; + +/// +/// Class that interacts with the Hyper-V service and is used for all Hyper-V related +/// functionality. +/// +public class HyperVManager : IHyperVManager, IDisposable +{ + private readonly IPowerShellService _powerShellService; + + private readonly HyperVVirtualMachineFactory _hyperVVirtualMachineFactory; + + private readonly IHost _host; + + private readonly object _operationLock = new(); + + public bool IsFirstTimeLoadingModule { get; private set; } = true; + + /// + /// This dictionary is used so we can map a virtual machines id to the amount of operations + /// that were requested of us to perform for it. We should only perform one operation per virtual machine + /// at a time. The manager is still able perform multiple operations at time assuming each operation is + /// for a different virtual machine. + /// + private readonly Dictionary _virtualMachinesToOperateOn = new(); + + private readonly AutoResetEvent _operationEventForVirtualMachine = new(false); + + private const uint _numberOfOperationsToPeformPerVirtualMachine = 1; + + private readonly TimeSpan _serviceTimeoutInSeconds = TimeSpan.FromSeconds(3); + + private bool _disposed; + + public HyperVManager(IHost host, IPowerShellService powerShellService, HyperVVirtualMachineFactory hyperVVirtualMachineFactory) + { + _powerShellService = powerShellService; + _host = host; + _hyperVVirtualMachineFactory = hyperVVirtualMachineFactory; + } + + /// + public bool IsHyperVModuleLoaded() + { + if (IsFirstTimeLoadingModule) + { + IsFirstTimeLoadingModule = false; + LoadHyperVModule(); + } + + // Build command line statement to get all the available modules. + // Work around for .Net 8 and PowerShell.SDK 7.4.* issue where the PowerShell session + // Can't find the module, even though it appears in a regular PowerShell terminal window. + // this will be removed once the issue is resolved. + var commandLineStatements = new StatementBuilder() + .AddScript("Get-Module -ListAvailable", true) + .Build(); + + var result = _powerShellService.Execute(commandLineStatements, PipeType.None); + var moduleFound = result.PsObjects?.Any(psObject => + { + var helper = new PsObjectHelper(psObject); + return helper.MemberNameToValue(HyperVStrings.Name) == HyperVStrings.HyperVModuleName; + }) ?? false; + + if (!moduleFound) + { + Logging.Logger()?.ReportWarn($"PowerShell could not find the Hyper-V module in the list of modules loaded into the current session: {result.CommandOutputErrorMessage}"); + } + + return moduleFound; + } + + private void LoadHyperVModule() + { + // Makes sure the Hyper-V module is loaded in the current PowerShell session. + // After moving to .Net 8 and using PowerShell.SDK 7.4.*, simply attempting to + // import the Hyper-V module from Dev Home does not work. We need to force the + // module by attempting to load it twice. + // A work around is to use the Get-Module twice in the PowerShell session + // to find the Hyper-V module. I'll need to investigate this further. + var commandLineStatements = new StatementBuilder() + .AddCommand(HyperVStrings.GetModule) + .AddParameter(HyperVStrings.ListAvailable, true) + .AddParameter(HyperVStrings.Name, HyperVStrings.HyperVModuleName) + .Build(); + + var result = _powerShellService.Execute(commandLineStatements, PipeType.None); + + if (!string.IsNullOrEmpty(result.CommandOutputErrorMessage)) + { + Logging.Logger()?.ReportWarn($"PowerShell returned an error while attempting to get the Hyper-V module on the first try: {result.CommandOutputErrorMessage}"); + } + } + + /// + public void StartVirtualMachineManagementService() + { + if (!IsUserInHyperVAdminGroup()) + { + throw new HyperVAdminGroupException("The current logged on user is not in the Hyper-V administrator group"); + } + + if (!IsHyperVModuleLoaded()) + { + // we won't throw an exception here. If there is a cmdlet failure due to the module not being loaded, we'll let the + // PowerShell cmdlet throw the exception. + Logging.Logger()?.ReportError("The Hyper-V PowerShell Module is not Loaded"); + } + + var serviceController = _host.GetService(); + serviceController.ServiceName = HyperVStrings.VMManagementService; + + switch (serviceController.Status) + { + case ServiceControllerStatus.Running: + // The service is already running + return; + case ServiceControllerStatus.StartPending: + // The service is starting, so we'll wait to confirm it started. + break; + + // If the service is stopping, we'll wait till its fully stopped. + case ServiceControllerStatus.StopPending: + serviceController.WaitForStatusChange(ServiceControllerStatus.Stopped, _serviceTimeoutInSeconds); + goto case ServiceControllerStatus.Stopped; + + case ServiceControllerStatus.Stopped: + // Service is stopped try to start it. + serviceController.StartService(); + break; + + // If the service is pausing, we'll wait till its fully paused. + case ServiceControllerStatus.PausePending: + serviceController.WaitForStatusChange(ServiceControllerStatus.Paused, _serviceTimeoutInSeconds); + goto case ServiceControllerStatus.Paused; + + case ServiceControllerStatus.Paused: + // If the service is paused, try to resume it. + serviceController.ContinueService(); + break; + } + + // wait for service to start. + serviceController.WaitForStatusChange(ServiceControllerStatus.Running, _serviceTimeoutInSeconds); + } + + /// + public IEnumerable GetAllVirtualMachines() + { + StartVirtualMachineManagementService(); + + // Build command line statement to get all the the VMs on the machine. + var commandLineStatements = new StatementBuilder().AddCommand(HyperVStrings.GetVM).Build(); + var result = _powerShellService.Execute(commandLineStatements, PipeType.None); + + if (!string.IsNullOrEmpty(result.CommandOutputErrorMessage)) + { + // Note: errors here could be about retrieving 1 out of N virtual machines, so we log this and return the rest. + Logging.Logger()? + .ReportWarn($"Unable to get all VMs due to PowerShell error: {result.CommandOutputErrorMessage}"); + } + + var returnList = result.PsObjects? + .Where(psObject => psObject != null) + .Select(psObject => _hyperVVirtualMachineFactory(psObject)); + + return returnList ?? new List(); + } + + /// + public HyperVVirtualMachine GetVirtualMachine(Guid vmId) + { + StartVirtualMachineManagementService(); + AddVirtualMachineToOperationsMap(vmId); + + try + { + var result = _powerShellService.Execute(GetVMCommandLineStatement(vmId), PipeType.None); + + if (!string.IsNullOrEmpty(result.CommandOutputErrorMessage)) + { + throw new HyperVManagerException($"Unable to get VM with Id {vmId} due to PowerShell error: {result.CommandOutputErrorMessage}"); + } + + // If we found the VM there should only be one psObject in the list. + var psObject = result.PsObjects?.FirstOrDefault(); + if (psObject != null) + { + return _hyperVVirtualMachineFactory(psObject); + } + + throw new HyperVManagerException($"Unable to get VM with Id {vmId} due to PowerShell returning a null PsObject"); + } + finally + { + RemoveVirtualMachineFromOperationsMap(vmId); + } + } + + /// + public bool StopVirtualMachine(Guid vmId, StopVMKind stopVMKind) + { + StartVirtualMachineManagementService(); + + // Start building default command line statement to stop the VM. + var statementBuilder = new StatementBuilder() + .AddCommand(HyperVStrings.GetVM) + .AddParameter(HyperVStrings.Id, vmId) + .AddCommand(HyperVStrings.StopVM) + .AddParameter(HyperVStrings.PassThru, true); + + var endStateString = HyperVStrings.VMOffState; + + if (stopVMKind == StopVMKind.Save) + { + // Add parameter to change the Stop-VM cmdlets behavior to save the VM's state instead of shutting it down. + statementBuilder.AddParameter(HyperVStrings.Save, true); + endStateString = HyperVStrings.SavedState; + } + else if (stopVMKind == StopVMKind.TurnOff) + { + // Add parameter to change the Stop-VM cmdlets behavior to turn off the VM immediately instead of shutting it down. + statementBuilder.AddParameter(HyperVStrings.TurnOff, true); + } + + AddVirtualMachineToOperationsMap(vmId); + + try + { + var result = _powerShellService.Execute(statementBuilder.Build(), PipeType.PipeOutput); + + if (!string.IsNullOrEmpty(result.CommandOutputErrorMessage)) + { + throw new HyperVManagerException($"Unable to stop VM with Id {vmId} due to PowerShell error: {result.CommandOutputErrorMessage}"); + } + + // The VM will be the returned object since we used the "PassThru" parameter. + var vmObject = result.PsObjects.FirstOrDefault(); + if (vmObject == null) + { + return false; + } + + var virtualMachine = _hyperVVirtualMachineFactory(vmObject); + + // If the current state and endstate are the same we were able to stop the VM successfully. + return AreStringsTheSame(virtualMachine?.State, endStateString); + } + finally + { + RemoveVirtualMachineFromOperationsMap(vmId); + } + } + + /// + public bool StartVirtualMachine(Guid vmId) + { + StartVirtualMachineManagementService(); + + // Start building command line statement to start the VM. + var statementBuilder = new StatementBuilder() + .AddCommand(HyperVStrings.GetVM) + .AddParameter(HyperVStrings.Id, vmId) + .AddCommand(HyperVStrings.StartVM) + .AddParameter(HyperVStrings.PassThru, true); + + AddVirtualMachineToOperationsMap(vmId); + + try + { + var result = _powerShellService.Execute(statementBuilder.Build(), PipeType.PipeOutput); + + if (!string.IsNullOrEmpty(result.CommandOutputErrorMessage)) + { + throw new HyperVManagerException($"Unable to start VM with Id {vmId} due to PowerShell error: {result.CommandOutputErrorMessage}"); + } + + // The VM will be the returned object since we used the "PassThru" parameter. + var vmObject = result.PsObjects.FirstOrDefault(); + if (vmObject == null) + { + return false; + } + + var virtualMachine = _hyperVVirtualMachineFactory(vmObject); + + // Check if we were able to turn on the VM successfully. + return AreStringsTheSame(virtualMachine?.State, HyperVStrings.RunningState); + } + finally + { + RemoveVirtualMachineFromOperationsMap(vmId); + } + } + + /// + public bool PauseVirtualMachine(Guid vmId) + { + StartVirtualMachineManagementService(); + + // Start building command line statement to pause the VM. + var statementBuilder = new StatementBuilder() + .AddCommand(HyperVStrings.GetVM) + .AddParameter(HyperVStrings.Id, vmId) + .AddCommand(HyperVStrings.SuspendVM) + .AddParameter(HyperVStrings.PassThru, true); + + AddVirtualMachineToOperationsMap(vmId); + + try + { + var result = _powerShellService.Execute(statementBuilder.Build(), PipeType.PipeOutput); + + if (!string.IsNullOrEmpty(result.CommandOutputErrorMessage)) + { + throw new HyperVManagerException($"Unable to pause VM with Id {vmId} due to PowerShell error: {result.CommandOutputErrorMessage}"); + } + + // The VM will be the returned object since we used the "PassThru" parameter. + var vmObject = result.PsObjects.FirstOrDefault(); + if (vmObject == null) + { + return false; + } + + var virtualMachine = _hyperVVirtualMachineFactory(vmObject); + + // Check if we were able to pause the VM successfully. + return AreStringsTheSame(virtualMachine?.State, HyperVStrings.PausedState); + } + finally + { + RemoveVirtualMachineFromOperationsMap(vmId); + } + } + + /// + public bool ResumeVirtualMachine(Guid vmId) + { + StartVirtualMachineManagementService(); + var statementBuilder = new StatementBuilder(); + + // Build command line statement to resume the VM. + var commandLineStatements = statementBuilder + .AddCommand(HyperVStrings.GetVM) + .AddParameter(HyperVStrings.Id, vmId) + .AddCommand(HyperVStrings.ResumeVM) + .AddParameter(HyperVStrings.PassThru, true) + .Build(); + + AddVirtualMachineToOperationsMap(vmId); + + try + { + var result = _powerShellService.Execute(commandLineStatements, PipeType.PipeOutput); + + if (!string.IsNullOrEmpty(result.CommandOutputErrorMessage)) + { + throw new HyperVManagerException($"Unable to resume VM with Id {vmId} due to PowerShell error: {result.CommandOutputErrorMessage}"); + } + + // The VM will be the returned object since we used the "PassThru" parameter. + var vmObject = result.PsObjects.FirstOrDefault(); + if (vmObject == null) + { + return false; + } + + var virtualMachine = _hyperVVirtualMachineFactory(vmObject); + + // Check if we were able to resume the VM successfully. + return AreStringsTheSame(virtualMachine?.State, HyperVStrings.RunningState); + } + finally + { + RemoveVirtualMachineFromOperationsMap(vmId); + } + } + + /// + public bool RemoveVirtualMachine(Guid vmId) + { + StartVirtualMachineManagementService(); + var statementBuilder = new StatementBuilder(); + + // Build command line statement to remove the VM. + var commandLineStatements = statementBuilder + .AddCommand(HyperVStrings.GetVM) + .AddParameter(HyperVStrings.Id, vmId) + .AddCommand(HyperVStrings.RemoveVM) + .AddParameter(HyperVStrings.Force, true) + .AddParameter(HyperVStrings.PassThru, true) + .Build(); + + AddVirtualMachineToOperationsMap(vmId); + + try + { + var result = _powerShellService.Execute(commandLineStatements, PipeType.PipeOutput); + if (!string.IsNullOrEmpty(result.CommandOutputErrorMessage)) + { + throw new HyperVManagerException($"Unable to remove VM with Id {vmId} due to PowerShell error: {result.CommandOutputErrorMessage}"); + } + + // The VM will be the returned object since we used the "PassThru" parameter. + var vmObject = result.PsObjects.FirstOrDefault(); + if (vmObject == null) + { + return false; + } + + var virtualMachine = _hyperVVirtualMachineFactory(vmObject); + return virtualMachine.IsDeleted; + } + finally + { + RemoveVirtualMachineFromOperationsMap(vmId); + } + } + + /// + public void ConnectToVirtualMachine(Guid vmId) + { + StartVirtualMachineManagementService(); + AddVirtualMachineToOperationsMap(vmId); + + try + { + // Build command line statement to connect to the VM. + var commandLineStatements = new StatementBuilder() + .AddScript($"{HyperVStrings.VmConnectScript} {vmId}", true) + .Build(); + + var result = _powerShellService.Execute(commandLineStatements, PipeType.PipeOutput); + + // Note: We use the vmconnect application to connect to the VM. VM connect will display a message box with + // an error if one occurs. We will only throw this error if an error occurs within the PowerShell session. + if (!string.IsNullOrEmpty(result.CommandOutputErrorMessage)) + { + throw new HyperVManagerException($"Unable to launch VM with Id {vmId} due to PowerShell error: {result.CommandOutputErrorMessage}"); + } + } + finally + { + RemoveVirtualMachineFromOperationsMap(vmId); + } + } + + /// + public IEnumerable GetVirtualMachineCheckpoints(Guid vmId) + { + StartVirtualMachineManagementService(); + + // Build command line statement to get all the checkpoints. + var commandLineStatements = new StatementBuilder() + .AddCommand(HyperVStrings.GetVM) + .AddParameter(HyperVStrings.Id, vmId) + .AddCommand(HyperVStrings.GetVMSnapshot) + .Build(); + + AddVirtualMachineToOperationsMap(vmId); + + try + { + var result = _powerShellService.Execute(commandLineStatements, PipeType.PipeOutput); + if (!string.IsNullOrEmpty(result.CommandOutputErrorMessage)) + { + // Note: errors here could be about retrieving 1 out of N checkpoints, so we log this and return the rest. + Logging.Logger()? + .ReportWarn($"Unable to get all checkpoints for VM with Id {vmId} due to PowerShell error: {result.CommandOutputErrorMessage}"); + } + + var checkpointList = result.PsObjects?.Select(psObject => + { + var helper = new PsObjectHelper(psObject); + var checkpointId = helper.MemberNameToValue(HyperVStrings.Id); + var checkpointName = helper.MemberNameToValue(HyperVStrings.Name) ?? string.Empty; + var parentCheckpointId = helper.MemberNameToValue(HyperVStrings.ParentCheckpointId); + var parentCheckpointName = helper.MemberNameToValue(HyperVStrings.ParentCheckpointName) ?? string.Empty; + return new Checkpoint(parentCheckpointId, parentCheckpointName, checkpointId, checkpointName); + }); + + return checkpointList ?? new List(); + } + finally + { + RemoveVirtualMachineFromOperationsMap(vmId); + } + } + + /// + public bool ApplyCheckpoint(Guid vmId, Guid checkpointId) + { + StartVirtualMachineManagementService(); + + // Build command line statement to apply the checkpoint. + var commandLineStatements = new StatementBuilder() + .AddCommand(HyperVStrings.GetVMSnapshot) + .AddParameter(HyperVStrings.Id, checkpointId) + .AddCommand(HyperVStrings.RestoreVMSnapshot) + .AddParameter(HyperVStrings.Confirm, false) + .Build(); + + AddVirtualMachineToOperationsMap(vmId); + + try + { + var result = _powerShellService.Execute(commandLineStatements, PipeType.PipeOutput); + if (!string.IsNullOrEmpty(result.CommandOutputErrorMessage)) + { + throw new HyperVManagerException( + $"Unable to apply the checkpoint with Id: {checkpointId} for VM with Id: {vmId} due to PowerShell error: {result.CommandOutputErrorMessage}"); + } + + // Build command line statement to get the VM so we can confirm that the checkpoint was applied. + var virtualMachine = ExecuteAndReturnObject(GetVMCommandLineStatement(vmId), PipeType.None); + + if (virtualMachine == null) + { + return false; + } + + // If the parentCheckpointId of the current VM is equal to the one that was passed in, we applied it successfully. + return virtualMachine.ParentCheckpointId == checkpointId; + } + finally + { + RemoveVirtualMachineFromOperationsMap(vmId); + } + } + + /// + public bool RemoveCheckpoint(Guid vmId, Guid checkpointId) + { + StartVirtualMachineManagementService(); + var statementBuilder = new StatementBuilder(); + + // Build command line statement to remove the checkpoint. + var commandLineStatements = statementBuilder + .AddCommand(HyperVStrings.GetVMSnapshot) + .AddParameter(HyperVStrings.Id, checkpointId) + .AddCommand(HyperVStrings.RemoveVMSnapshot) + .AddParameter(HyperVStrings.Confirm, false) + .Build(); + + AddVirtualMachineToOperationsMap(vmId); + + try + { + var result = _powerShellService.Execute(commandLineStatements, PipeType.PipeOutput); + if (!string.IsNullOrEmpty(result.CommandOutputErrorMessage)) + { + throw new HyperVManagerException( + $"Unable to remove the checkpoint with Id: {checkpointId} for VM with Id: {vmId} due to PowerShell error: {result.CommandOutputErrorMessage}"); + } + + // Build command line statement to get the VM's checkpoints so we can confirm that the checkpoint was removed. + commandLineStatements = statementBuilder + .AddCommand(HyperVStrings.GetVM) + .AddParameter(HyperVStrings.Id, vmId) + .AddCommand(HyperVStrings.GetVMSnapshot) + .Build(); + + result = _powerShellService.Execute(commandLineStatements, PipeType.PipeOutput); + + if (result.PsObjects != null) + { + // Check if the checkpoint still exists. At this point it should not exist if we removed it successfully. + var wasCheckPointRemoved = !result.PsObjects.Any(psObject => + new PsObjectHelper(psObject).MemberNameToValue(HyperVStrings.Id) == checkpointId); + + return wasCheckPointRemoved; + } + + return false; + } + finally + { + RemoveVirtualMachineFromOperationsMap(vmId); + } + } + + /// + public bool CreateCheckpoint(Guid vmId) + { + StartVirtualMachineManagementService(); + + // Build command line statement to create the new checkpoint and then return it as a PowerShell object. + var commandLineStatements = new StatementBuilder() + .AddCommand(HyperVStrings.GetVM) + .AddParameter(HyperVStrings.Id, vmId) + .AddCommand(HyperVStrings.CreateVMCheckpoint) + .AddParameter(HyperVStrings.PassThru, true) + .Build(); + + AddVirtualMachineToOperationsMap(vmId); + + try + { + var result = _powerShellService.Execute(commandLineStatements, PipeType.PipeOutput); + if (!string.IsNullOrEmpty(result.CommandOutputErrorMessage)) + { + throw new HyperVManagerException( + $"Unable to create a new checkpoint for VM with Id: {vmId} due to PowerShell error: {result.CommandOutputErrorMessage}"); + } + + var newCheckpoint = result.PsObjects.FirstOrDefault(); + if (newCheckpoint != null) + { + // Get the checkpoint id of the returned checkpoint. + var helper = new PsObjectHelper(newCheckpoint); + var newCheckpointId = helper.MemberNameToValue(HyperVStrings.Id); + + // The id of the new checkpoint should be a non empty Guid. + return newCheckpointId != Guid.Empty; + } + + return false; + } + finally + { + RemoveVirtualMachineFromOperationsMap(vmId); + } + } + + /// + public bool RestartVirtualMachine(Guid vmId) + { + StartVirtualMachineManagementService(); + + // Start building command line statement to start the VM. + var statementBuilder = new StatementBuilder() + .AddCommand(HyperVStrings.GetVM) + .AddParameter(HyperVStrings.Id, vmId) + .AddCommand(HyperVStrings.RestartVM) + .AddParameter(HyperVStrings.Force, true) + .AddParameter(HyperVStrings.PassThru, true); + + AddVirtualMachineToOperationsMap(vmId); + + try + { + var result = _powerShellService.Execute(statementBuilder.Build(), PipeType.PipeOutput); + + if (!string.IsNullOrEmpty(result.CommandOutputErrorMessage)) + { + throw new HyperVManagerException($"Unable to start VM with Id {vmId} due to PowerShell error: {result.CommandOutputErrorMessage}"); + } + + // The VM will be the returned object since we used the "PassThru" parameter. + var vmObject = result.PsObjects.FirstOrDefault(); + if (vmObject == null) + { + return false; + } + + // confirm the VM was started successfully. + var virtualMachine = _hyperVVirtualMachineFactory(vmObject); + return virtualMachine.State == HyperVStrings.RunningState; + } + finally + { + RemoveVirtualMachineFromOperationsMap(vmId); + } + } + + /// + public ulong GetVhdSize(string diskPath) + { + var statementBuilder = new StatementBuilder() + .AddCommand(HyperVStrings.GetVHD) + .AddParameter(HyperVStrings.Path, diskPath); + + var result = _powerShellService.Execute(statementBuilder.Build(), PipeType.PipeOutput); + + if (!string.IsNullOrEmpty(result.CommandOutputErrorMessage)) + { + throw new HyperVManagerException($"Unable to get disk size due to PowerShell error: {result.CommandOutputErrorMessage}"); + } + + // object in the returned results should represent a virtual disk. + var vmObject = result.PsObjects.FirstOrDefault(); + if (vmObject == null) + { + // If the VM object is null we were unable to get the disk size. + return 0; + } + + var helper = new PsObjectHelper(vmObject); + return helper.MemberNameToValue(HyperVStrings.Size); + } + + /// + public bool IsUserInHyperVAdminGroup() + { + var currentUser = _host.GetService().GetCurrentWindowsIdentity(); + var wasHyperVSidFound = currentUser?.Groups?.Any(sid => sid.Value == HyperVStrings.HyperVAdminGroupWellKnownSid); + return wasHyperVSidFound ?? false; + } + + private bool AreStringsTheSame(string? stringA, string? stringB) + { + return stringA?.Equals(stringB, StringComparison.OrdinalIgnoreCase) ?? false; + } + + /// + /// Helper method that executes a list of PowerShell commandline statements + /// and returns the given T object or its default value. + /// + private T? ExecuteAndReturnObject(List statements, PipeType pipeType) + { + var result = _powerShellService.Execute(statements, pipeType); + var psObject = result.PsObjects.FirstOrDefault(); + if (psObject == null) + { + Logging.Logger()?.ReportError($"Unable to create {nameof(T)} due to PowerShell error: {result.CommandOutputErrorMessage}"); + return default(T); + } + + if (typeof(T) == typeof(HyperVVirtualMachine) && _hyperVVirtualMachineFactory(psObject) is T virtualMachine) + { + return virtualMachine; + } + + if (typeof(T) == typeof(PsObjectHelper) && new PsObjectHelper(psObject) is T psObjecthelper) + { + return psObjecthelper; + } + + return default(T); + } + + /// Helper method that is used to get the PowerShell commandline statement for retrieving a specific virtual machine object. + private List GetVMCommandLineStatement(Guid vmId) + { + // Build command line statement to get a specific VM. + return new StatementBuilder().AddCommand(HyperVStrings.GetVM).AddParameter(HyperVStrings.Id, vmId).Build(); + } + + /// + /// Adds the Id of the virtual machine to the _virtualMachinesToOperateOn dictionary. This makes sure + /// we perform only one operation per virtual machine. We do this by incrementing the value belonging + /// to the key (vm guid), when a request comes in to perform an operation on the VM. When the number + /// of operations queued up for the VM exceeds _numberOfOperationsToPeformPerVirtualMachine the thread + /// will wait until it is signalled to proceed. + /// + private void AddVirtualMachineToOperationsMap(Guid vmId) + { + var managerCurrentlyDoingOperationOnVM = false; + + lock (_operationLock) + { + // increment the number of operations being performed by the manager on the VM by 1 + // each time we enter the lock. + _virtualMachinesToOperateOn.TryGetValue(vmId, out var queuedOperationsForThisVm); + _virtualMachinesToOperateOn[vmId] = queuedOperationsForThisVm + 1; + if (_virtualMachinesToOperateOn[vmId] > _numberOfOperationsToPeformPerVirtualMachine) + { + managerCurrentlyDoingOperationOnVM = true; + } + } + + if (managerCurrentlyDoingOperationOnVM) + { + // Wait to be signalled when the previous operation on the VM has completed. + _operationEventForVirtualMachine.WaitOne(); + } + } + + /// + /// decrements the operation queue count and signals to a waiting thread that it can now proceed out + /// of the waiting state. + /// + private void RemoveVirtualMachineFromOperationsMap(Guid vmId) + { + lock (_operationLock) + { + // Decrement the number of queued operations by one now that the operation has completed. + _virtualMachinesToOperateOn.TryGetValue(vmId, out var queuedOperationsForThisVm); + _virtualMachinesToOperateOn[vmId] = queuedOperationsForThisVm - 1; + + // Set the signal to allow waiting threads to proceed + _operationEventForVirtualMachine.Set(); + } + } + + private void Dispose(bool disposing) + { + if (!_disposed) + { + LogEvent.Create( + nameof(HyperVManager), + string.Empty, + SeverityLevel.Debug, + "Disposing HyperVManager"); + + if (disposing) + { + _operationEventForVirtualMachine.Dispose(); + } + } + + _disposed = true; + } + + public void Dispose() + { + Dispose(true); + GC.SuppressFinalize(this); + } +} diff --git a/HyperVExtension/src/HyperVExtension/Services/IHyperVManager.cs b/HyperVExtension/src/HyperVExtension/Services/IHyperVManager.cs new file mode 100644 index 0000000000..8f95938808 --- /dev/null +++ b/HyperVExtension/src/HyperVExtension/Services/IHyperVManager.cs @@ -0,0 +1,101 @@ +// Copyright (c) Microsoft Corporation. +// Licensed under the MIT License. + +using HyperVExtension.Models; + +namespace HyperVExtension.Services; + +/// +/// The Stop-VM PowerShell cmdlet can either shutdown a VM by default via its OS, save +/// a VM's state or turn off a VM which is equivalent to disconnecting the power from +/// the virtual machine +/// +public enum StopVMKind +{ + Default, + Save, + TurnOff, +} + +/// Class that handles interacting directly with Hyper-V. +public interface IHyperVManager +{ + /// Gets a boolean indicating whether the user is in the Hyper-V Administrator group. + public bool IsUserInHyperVAdminGroup(); + + /// Gets a boolean indicating whether the Hyper-V PowerShell module is available. + public bool IsHyperVModuleLoaded(); + + /// Starts the virtual machine management service if it is not running. + public void StartVirtualMachineManagementService(); + + /// Gets a list of Hyper-V virtual machines. + /// A list of virtual machines. + public IEnumerable GetAllVirtualMachines(); + + /// Gets a Hyper-V virtual machine. + /// The id of the virtual machine. + public HyperVVirtualMachine GetVirtualMachine(Guid vmId); + + /// Stops a Hyper-V virtual machine. + /// This can either Shuts down, turn off, or save a virtual machine's state. + /// The id of the virtual machine. + /// True if the virtual machine was stopped successfully, false otherwise. + public bool StopVirtualMachine(Guid vmId, StopVMKind stopVMKind); + + /// Starts a Hyper-V virtual machine. + /// The id of the virtual machine. + /// True if the virtual machine was started successfully, false otherwise. + public bool StartVirtualMachine(Guid vmId); + + /// Pauses a Hyper-V virtual machine. + /// The id of the virtual machine. + /// True if the virtual machine was paused successfully, false otherwise. + public bool PauseVirtualMachine(Guid vmId); + + /// Resumes a Hyper-V virtual machine. + /// The id of the virtual machine. + /// True if the virtual machine was resumed successfully, false otherwise. + public bool ResumeVirtualMachine(Guid vmId); + + /// Removes a Hyper-V virtual machine. + /// The id of the virtual machine. + /// True if the virtual machine was removed successfully, false otherwise. + public bool RemoveVirtualMachine(Guid vmId); + + /// Starts a remote session a Hyper-V virtual machine. + /// The id of the virtual machine. + public void ConnectToVirtualMachine(Guid vmId); + + /// Gets a list of all checkpoints for a Hyper-V virtual machine. + /// The id of the virtual machine. + /// A list of checkpoints and their parent checkpoints + public IEnumerable GetVirtualMachineCheckpoints(Guid vmId); + + /// Apply a Hyper-V virtual machines checkpoint. + /// The id of the virtual machine. + /// The id of the checkpoint for the virtual machine. + /// True if the checkpoint was applied successfully, false otherwise. + public bool ApplyCheckpoint(Guid vmId, Guid checkpointId); + + /// Removes a Hyper-V virtual machines checkpoint. + /// The id of the virtual machine. + /// The id of the checkpoint for the virtual machine. + /// True if the checkpoint was removed successfully, false otherwise. + public bool RemoveCheckpoint(Guid vmId, Guid checkpointId); + + /// Create a new checkpoint for a Hyper-V virtual machine. + /// The id of the virtual machine. + /// True if the checkpoint was created successfully, false otherwise. + public bool CreateCheckpoint(Guid vmId); + + /// Restarts a Hyper-V virtual machine. + /// The id of the virtual machine. + /// True if the virtual machine was started successfully, false otherwise. + public bool RestartVirtualMachine(Guid vmId); + + /// Gets the disk size for a specific virtual disk + /// The path to the virtual disk. + /// The size in bytes of the virtual disk. + public ulong GetVhdSize(string diskPath); +} diff --git a/HyperVExtension/src/HyperVExtension/Services/IPowerShellService.cs b/HyperVExtension/src/HyperVExtension/Services/IPowerShellService.cs new file mode 100644 index 0000000000..c49975f9b4 --- /dev/null +++ b/HyperVExtension/src/HyperVExtension/Services/IPowerShellService.cs @@ -0,0 +1,29 @@ +// Copyright (c) Microsoft Corporation. +// Licensed under the MIT License. + +using HyperVExtension.Models; + +namespace HyperVExtension.Services; + +/// Enum that represents the type of piping to use when executing PowerShell statements. +public enum PipeType +{ + None, + PipeOutput, + DontClearBetweenStatements, +} + +/// Interface that handles PowerShell command line statements. +public interface IPowerShellService +{ + /// Executes a single PowerShell commandline statement. + /// A list that houses commandline statements that can be run by PowerShell. + /// An object that provides error information as well as the list of PSOjects returned by PowerShell. + public PowerShellResult Execute(PowerShellCommandlineStatement commandLineStatement); + + /// Executes multiple PowerShell commandline statements. + /// A list that houses commandline statements that can be run by PowerShell. + /// Type of piping to use when running the commandline statements. + /// An object that provides error information as well as the list of PSOjects returned by PowerShell. + public PowerShellResult Execute(IEnumerable commandLineStatements, PipeType pipeType); +} diff --git a/HyperVExtension/src/HyperVExtension/Services/PowerShellService.cs b/HyperVExtension/src/HyperVExtension/Services/PowerShellService.cs new file mode 100644 index 0000000000..f3ef11fbe0 --- /dev/null +++ b/HyperVExtension/src/HyperVExtension/Services/PowerShellService.cs @@ -0,0 +1,192 @@ +// Copyright (c) Microsoft Corporation. +// Licensed under the MIT License. + +using System.Collections.Generic; +using System.Collections.ObjectModel; +using System.Management.Automation; +using System.Threading; +using DevHome.Logging; +using HyperVExtension.Common; +using HyperVExtension.Models; +using Microsoft.Extensions.Hosting; + +namespace HyperVExtension.Services; + +/// Class that handles PowerShell commands. +public class PowerShellService : IPowerShellService, IDisposable +{ + private readonly IStringResource _stringResource; + + private readonly IPowerShellSession _powerShellSession; + + private readonly object _powerShellSessionLock = new(); + + private bool _disposed; + + public PowerShellService(IStringResource stringResource, IPowerShellSession powerShellSession) + { + _stringResource = stringResource; + _powerShellSession = powerShellSession; + } + + /// + public PowerShellResult Execute(PowerShellCommandlineStatement commandLineStatement) + { + return Execute(new List { commandLineStatement }, PipeType.None); + } + + /// + /// + /// When PipeType is set to PipeOutput, the order of the statements in the list are important. + /// The output of the first statement will be piped to the second statement and so on. + /// + public PowerShellResult Execute(IEnumerable commandLineStatements, PipeType pipeType) + { + try + { + lock (_powerShellSessionLock) + { + // Clear the PowerShell commands and streams to ensure that the previous commands are not run again. + _powerShellSession.ClearSession(); + var psObjectList = ExecuteStatements(commandLineStatements, pipeType); + var commandOutputErrorMessage = _powerShellSession.GetErrorMessages(); + + return new PowerShellResult + { + PsObjects = psObjectList, + CommandOutputErrorMessage = commandOutputErrorMessage, + }; + } + } + catch (Exception ex) + { + var commandStrings = string.Join(Environment.NewLine, commandLineStatements.Select(cmd => cmd.ToString())); + + LogEvent.Create( + nameof(PowerShellService), + string.Empty, + SeverityLevel.Error, + $"Error running PowerShell commands: {commandStrings}", + ex); + + throw; + } + } + + /// Runs the PowerShell statements based on the pipe type passed in. + private Collection ExecuteStatements(IEnumerable commandLineStatements, PipeType pipeType) + { + if (pipeType == PipeType.PipeOutput) + { + return BuildPipelineAndExecuteStatements(commandLineStatements); + } + + return ExecuteIndividualStatements(commandLineStatements, pipeType); + } + + /// + /// Builds the PowerShell pipeline with the command line statements. The output from the previous + /// statement is piped as input for the next statement. + /// + /// A list of statements that houses a PowerShell command and its parameters or a script. + private Collection BuildPipelineAndExecuteStatements(IEnumerable commandLineStatements) + { + // Each iteration will pipe the output of the previous statement to the next statement. + foreach (var statement in commandLineStatements) + { + AddStatementToCommandLine(statement); + } + + return _powerShellSession.Invoke(); + } + + /// + /// Executes every PowerShell command line statement one at a time. The output from the + /// previous statement is not used as input for the next statement. + /// + /// A list of statements that houses a PowerShell command and its parameters or a script. + private Collection ExecuteIndividualStatements(IEnumerable commandLineStatements, PipeType pipeType) + { + Collection result = new(); + + foreach (var statement in commandLineStatements) + { + AddStatementToCommandLine(statement); + result = _powerShellSession.Invoke(); + if (_powerShellSession.GetErrorMessages().Length != 0) + { + break; + } + + // Clear the PowerShell commands and streams to ensure that the previous command is not run again. + if (pipeType != PipeType.DontClearBetweenStatements) + { + _powerShellSession.ClearSession(); + } + } + + return result; + } + + /// Adds a command line statement to the PowerShell session. + /// A statement that houses a PowerShell command and its parameters or a script. + /// + /// If the statement contains a command, only the command and its parameters will be + /// added to the session. When the statement does not contain a command, but contains + /// a script, we add the script to the session. + /// + private void AddStatementToCommandLine(PowerShellCommandlineStatement statement) + { + var isCommandEmptyOrNull = string.IsNullOrEmpty(statement.Command); + var isScriptEmptyOrNull = string.IsNullOrEmpty(statement.Script); + var isCommandUsedWithScript = !isCommandEmptyOrNull && !isScriptEmptyOrNull; + + if (isCommandEmptyOrNull && isScriptEmptyOrNull) + { + throw new ArgumentException("Both the Command and Script properties were empty or null."); + } + + if (isCommandUsedWithScript) + { + throw new ArgumentException("Command and Script properties were used in the same statement. This is not allowed."); + } + + // Add the command if its available. + if (!isCommandEmptyOrNull) + { + _powerShellSession.AddCommand(statement.Command); + + // Add the parameters for the command if any. + if (statement.Parameters.Count != 0) + { + _powerShellSession.AddParameters(statement.Parameters); + } + } + + // Add a script if its available and command is non-empty. + if (isCommandEmptyOrNull && !isScriptEmptyOrNull) + { + _powerShellSession.AddScript(statement.Script, statement.UseLocalScope); + } + } + + protected virtual void Dispose(bool disposing) + { + if (!_disposed) + { + LogEvent.Create( + nameof(PowerShellService), + string.Empty, + SeverityLevel.Debug, + "Disposing PowerShellHostService."); + } + + _disposed = true; + } + + public void Dispose() + { + Dispose(true); + GC.SuppressFinalize(this); + } +} diff --git a/HyperVExtension/src/HyperVExtension/Strings/en-US/Resources.resw b/HyperVExtension/src/HyperVExtension/Strings/en-US/Resources.resw new file mode 100644 index 0000000000..3c3a2c0d1b --- /dev/null +++ b/HyperVExtension/src/HyperVExtension/Strings/en-US/Resources.resw @@ -0,0 +1,161 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + text/microsoft-resx + + + 2.0 + + + System.Resources.ResXResourceReader, System.Windows.Forms, Version=4.0.0.0, Culture=neutral, PublicKeyToken=b77a5c561934e089 + + + System.Resources.ResXResourceWriter, System.Windows.Forms, Version=4.0.0.0, Culture=neutral, PublicKeyToken=b77a5c561934e089 + + + Current Checkpoint + Label text to display in front of the users current checkpoint name. + + + Unable to perform the requested operation. Check the Dev Home Hyper-V extension's log files for more information. + Error text for when the hyper-v extension is unable to perform an operation the user requests + + + Cancel + + + Dev Home needs VM administrator credentials to install DevSetupAgent service. + + + Please enter credential for the VM administrator account + + + Ok + + + Password is required + + + Password + + + Dev Home Hyper-V Extension credential request + + + Username is required + + + Admin username + + + Please login to <Name> Hyper-V VM to continue configuration. Click Ok after logging on. + + + Dev Home Hyper-V Extension login request. + + \ No newline at end of file diff --git a/HyperVExtension/src/HyperVExtensionServer/HyperVExtensionServer.csproj b/HyperVExtension/src/HyperVExtensionServer/HyperVExtensionServer.csproj new file mode 100644 index 0000000000..c06096b047 --- /dev/null +++ b/HyperVExtension/src/HyperVExtensionServer/HyperVExtensionServer.csproj @@ -0,0 +1,37 @@ + + + + + Exe + + + WinExe + + + + enable + enable + false + false + HyperVExtension.Program + win10-x86;win10-x64;win10-arm64 + x86;x64;arm64 + $(SolutionDir)\src\Properties\PublishProfiles\win10-$(Platform).pubxml + + + + + + + + + + + all + runtime; build; native; contentfiles; analyzers; buildtransitive + + + + + + diff --git a/HyperVExtension/src/HyperVExtensionServer/Logging.cs b/HyperVExtension/src/HyperVExtensionServer/Logging.cs new file mode 100644 index 0000000000..fb83c5c419 --- /dev/null +++ b/HyperVExtension/src/HyperVExtensionServer/Logging.cs @@ -0,0 +1,47 @@ +// Copyright (c) Microsoft Corporation. +// Licensed under the MIT License. + +using DevHome.Logging; +using Windows.Storage; + +namespace HyperVExtension.ExtensionServer; + +public class Logging +{ + private static Logger? _logger; + + public static Logger? Logger() + { + try + { + _logger ??= new Logger("Extension", GetLoggingOptions()); + } + catch + { + // Do nothing if logger fails. + } + + return _logger; + } + + public static Options GetLoggingOptions() + { + return new Options + { + LogFileFolderRoot = ApplicationData.Current.TemporaryFolder.Path, + LogFileName = "ExtensionServer_{now}.log", + LogFileFolderName = "ExtensionServer", + DebugListenerEnabled = true, +#if DEBUG + LogStdoutEnabled = true, + LogStdoutFilter = SeverityLevel.Debug, + LogFileFilter = SeverityLevel.Debug, +#else + LogStdoutEnabled = false, + LogStdoutFilter = SeverityLevel.Info, + LogFileFilter = SeverityLevel.Info, +#endif + FailFastSeverity = FailFastSeverityLevel.Critical, + }; + } +} diff --git a/HyperVExtension/src/HyperVExtensionServer/Program.cs b/HyperVExtension/src/HyperVExtensionServer/Program.cs new file mode 100644 index 0000000000..94c90f3efd --- /dev/null +++ b/HyperVExtension/src/HyperVExtensionServer/Program.cs @@ -0,0 +1,134 @@ +// Copyright (c) Microsoft Corporation. +// Licensed under the MIT License. + +using HyperVExtension.Common; +using HyperVExtension.Common.Extensions; +using HyperVExtension.Extensions; +using HyperVExtension.ExtensionServer; +using HyperVExtension.Services; +using Microsoft.Extensions.DependencyInjection; +using Microsoft.Extensions.Hosting; +using Microsoft.Windows.AppLifecycle; +using Microsoft.Windows.AppNotifications; +using Windows.ApplicationModel.Activation; +using Windows.Management.Deployment; + +namespace HyperVExtension; + +public sealed class Program +{ + public static IHost? Host + { + get; set; + } + + [MTAThread] + public static void Main([System.Runtime.InteropServices.WindowsRuntime.ReadOnlyArray] string[] args) + { + Logging.Logger()?.ReportInfo($"Launched with args: {string.Join(' ', args.ToArray())}"); + + // Force the app to be single instanced. + // Get or register the main instance. + var mainInstance = AppInstance.FindOrRegisterForKey("mainInstance"); + var activationArgs = AppInstance.GetCurrent().GetActivatedEventArgs(); + if (!mainInstance.IsCurrent) + { + Logging.Logger()?.ReportInfo($"Not main instance, redirecting."); + mainInstance.RedirectActivationToAsync(activationArgs).AsTask().Wait(); + return; + } + + // Build the host container before handling activation. + BuildHostContainer(); + + // Register for activation redirection. + AppInstance.GetCurrent().Activated += AppActivationRedirected; + + if (args.Length > 0 && args[0] == "-RegisterProcessAsComServer") + { + HandleCOMServerActivation(); + } + else + { + Logging.Logger()?.ReportWarn("Not being launched as a ComServer... exiting."); + } + + Logging.Logger()?.Dispose(); + } + + private static void AppActivationRedirected(object? sender, Microsoft.Windows.AppLifecycle.AppActivationArguments activationArgs) + { + Logging.Logger()?.ReportInfo($"Redirected with kind: {activationArgs.Kind}"); + + // Handle COM server. + if (activationArgs.Kind == ExtendedActivationKind.Launch) + { + var launchActivatedEventArgs = activationArgs.Data as ILaunchActivatedEventArgs; + var args = launchActivatedEventArgs?.Arguments.Split(); + + if (args?.Length > 0 && args[1] == "-RegisterProcessAsComServer") + { + Logging.Logger()?.ReportInfo($"Activation COM Registration Redirect: {string.Join(' ', args.ToList())}"); + HandleCOMServerActivation(); + } + } + + // Handle Protocol. + if (activationArgs.Kind == ExtendedActivationKind.Protocol) + { + var protocolActivatedEventArgs = activationArgs.Data as IProtocolActivatedEventArgs; + if (protocolActivatedEventArgs is not null) + { + Logging.Logger()?.ReportInfo($"Protocol Activation redirected from: {protocolActivatedEventArgs.Uri}"); + HandleProtocolActivation(protocolActivatedEventArgs.Uri); + } + } + } + + private static void HandleProtocolActivation(Uri protocolUri) + { + // TODO: Handle protocol activation if need be. + } + + private static void HandleCOMServerActivation() + { + Logging.Logger()?.ReportInfo($"Activating COM Server"); + + // Register and run COM server. + // This could be called by either of the COM registrations, we will do them all to avoid deadlock and bind all on the extension's lifetime. + using var extensionServer = new Microsoft.Windows.DevHome.SDK.ExtensionServer(); + var hyperVExtension = Host.GetService(); + + // We are instantiating extension instance once above, and returning it every time the callback in RegisterExtension below is called. + // This makes sure that only one instance of the extension is alive, which is returned every time the host asks for the IExtension object. + // If you want to instantiate a new instance each time the host asks, create the new instance inside the delegate. + extensionServer.RegisterExtension(() => hyperVExtension, true); + + // This will make the main thread wait until the event is signalled by the extension class. + // Since we have single instance of the extension object, we exit as soon as it is disposed. + hyperVExtension.ExtensionDisposedEvent.WaitOne(); + Logging.Logger()?.ReportInfo($"Extension is disposed."); + } + + /// + /// Creates the host container for the HyperVExtension server application. This can be used to register + /// services and other dependencies throughout the application. + /// + private static void BuildHostContainer() + { + Host = Microsoft.Extensions.Hosting.Host. + CreateDefaultBuilder(). + UseContentRoot(AppContext.BaseDirectory). + UseDefaultServiceProvider((context, options) => + { + options.ValidateOnBuild = true; + }). + ConfigureServices((context, services) => + { + // Services + services.AddCommonProjectServices(context); + services.AddHyperVExtensionServices(context); + }). + Build(); + } +} diff --git a/HyperVExtension/src/HyperVExtensionServer/Properties/PublishProfiles/win10-arm64.pubxml b/HyperVExtension/src/HyperVExtensionServer/Properties/PublishProfiles/win10-arm64.pubxml new file mode 100644 index 0000000000..2593bad1c5 --- /dev/null +++ b/HyperVExtension/src/HyperVExtensionServer/Properties/PublishProfiles/win10-arm64.pubxml @@ -0,0 +1,17 @@ + + + + + FileSystem + arm64 + win10-arm64 + true + False + False + True + True + True + + \ No newline at end of file diff --git a/HyperVExtension/src/HyperVExtensionServer/Properties/PublishProfiles/win10-x64.pubxml b/HyperVExtension/src/HyperVExtensionServer/Properties/PublishProfiles/win10-x64.pubxml new file mode 100644 index 0000000000..8b6ea06a13 --- /dev/null +++ b/HyperVExtension/src/HyperVExtensionServer/Properties/PublishProfiles/win10-x64.pubxml @@ -0,0 +1,17 @@ + + + + + FileSystem + x64 + win10-x64 + true + False + False + True + True + True + + \ No newline at end of file diff --git a/HyperVExtension/src/HyperVExtensionServer/Properties/PublishProfiles/win10-x86.pubxml b/HyperVExtension/src/HyperVExtensionServer/Properties/PublishProfiles/win10-x86.pubxml new file mode 100644 index 0000000000..99985acadf --- /dev/null +++ b/HyperVExtension/src/HyperVExtensionServer/Properties/PublishProfiles/win10-x86.pubxml @@ -0,0 +1,17 @@ + + + + + FileSystem + x86 + win10-x86 + true + False + False + True + True + True + + \ No newline at end of file diff --git a/HyperVExtension/src/HyperVExtensionServer/Properties/launchSettings.json b/HyperVExtension/src/HyperVExtensionServer/Properties/launchSettings.json new file mode 100644 index 0000000000..cbfc481c83 --- /dev/null +++ b/HyperVExtension/src/HyperVExtensionServer/Properties/launchSettings.json @@ -0,0 +1,8 @@ +{ + "profiles": { + "HyperVExtensionServer": { + "commandName": "Project", + "commandLineArgs": "-RegisterProcessAsComServer" + } + } +} \ No newline at end of file diff --git a/HyperVExtension/src/HyperVExtensionServer/Strings/en-us/Resources.resw b/HyperVExtension/src/HyperVExtensionServer/Strings/en-us/Resources.resw new file mode 100644 index 0000000000..b01c50804a --- /dev/null +++ b/HyperVExtension/src/HyperVExtensionServer/Strings/en-us/Resources.resw @@ -0,0 +1,61 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + text/microsoft-resx + + + 2.0 + + + System.Resources.ResXResourceReader, System.Windows.Forms, Version=4.0.0.0, Culture=neutral, PublicKeyToken=b77a5c561934e089 + + + System.Resources.ResXResourceWriter, System.Windows.Forms, Version=4.0.0.0, Culture=neutral, PublicKeyToken=b77a5c561934e089 + + \ No newline at end of file diff --git a/HyperVExtension/src/Logging/HyperVExtension.Logging.csproj b/HyperVExtension/src/Logging/HyperVExtension.Logging.csproj new file mode 100644 index 0000000000..7f37f4aa5b --- /dev/null +++ b/HyperVExtension/src/Logging/HyperVExtension.Logging.csproj @@ -0,0 +1,17 @@ + + + + enable + enable + x86;x64;arm64 + win-x86;win-x64;win-arm64 + + + + + all + runtime; build; native; contentfiles; analyzers; buildtransitive + + + + diff --git a/HyperVExtension/src/Logging/helpers/DictionaryExtensions.cs b/HyperVExtension/src/Logging/helpers/DictionaryExtensions.cs new file mode 100644 index 0000000000..b129aa2ac8 --- /dev/null +++ b/HyperVExtension/src/Logging/helpers/DictionaryExtensions.cs @@ -0,0 +1,25 @@ +// Copyright (c) Microsoft Corporation. +// Licensed under the MIT License. + +namespace DevHome.Logging.Helpers; + +public static class DictionaryExtensions +{ + public static void DisposeAll(this IDictionary dictionary) + { + ArgumentNullException.ThrowIfNull(dictionary); + + foreach (var kv in dictionary) + { + if (kv.Key is IDisposable keyDisposable) + { + keyDisposable.Dispose(); + } + + if (kv.Value is IDisposable valDisposable) + { + valDisposable.Dispose(); + } + } + } +} diff --git a/HyperVExtension/src/Logging/helpers/FileSystem.cs b/HyperVExtension/src/Logging/helpers/FileSystem.cs new file mode 100644 index 0000000000..c867934975 --- /dev/null +++ b/HyperVExtension/src/Logging/helpers/FileSystem.cs @@ -0,0 +1,36 @@ +// Copyright (c) Microsoft Corporation. +// Licensed under the MIT License. + +namespace DevHome.Logging.Helpers; + +public class FileSystem +{ + public static string BuildOutputFilename(string filename, string outputFolder, bool createPathIfNecessary = true) + { + var outputFilename = SubstituteOutputFilename(filename, outputFolder); + var file = new FileInfo(outputFilename); + if (createPathIfNecessary) + { + file.Directory?.Create(); + } + + return file.FullName; + } + + public static string SubstituteNow(string s) + { + if (s.Contains("{now}", StringComparison.CurrentCulture)) + { + var now = DateTime.Now; + var nowAsString = $"{now:yyyyMMdd-HHmmss}"; + return s.Replace("{now}", nowAsString); + } + + return s; + } + + public static string SubstituteOutputFilename(string filename, string outputDirectory) + { + return Path.Combine(SubstituteNow(outputDirectory), SubstituteNow(filename)); + } +} diff --git a/HyperVExtension/src/Logging/helpers/StringExtensions.cs b/HyperVExtension/src/Logging/helpers/StringExtensions.cs new file mode 100644 index 0000000000..24eb5c4dc6 --- /dev/null +++ b/HyperVExtension/src/Logging/helpers/StringExtensions.cs @@ -0,0 +1,16 @@ +// Copyright (c) Microsoft Corporation. +// Licensed under the MIT License. + +using System.Globalization; + +namespace DevHome.Logging.Helpers; + +public static class StringExtensions +{ + public static string ToStringInvariant(this T value) => Convert.ToString(value, CultureInfo.InvariantCulture)!; + + public static string FormatInvariant(this string value, params object[] arguments) + { + return string.Format(CultureInfo.InvariantCulture, value, arguments); + } +} diff --git a/HyperVExtension/src/Logging/listeners/DebugListener.cs b/HyperVExtension/src/Logging/listeners/DebugListener.cs new file mode 100644 index 0000000000..2dc983102f --- /dev/null +++ b/HyperVExtension/src/Logging/listeners/DebugListener.cs @@ -0,0 +1,38 @@ +// Copyright (c) Microsoft Corporation. +// Licensed under the MIT License. + +using System.Diagnostics; +using System.Globalization; +using System.Text; + +namespace DevHome.Logging.Listeners; + +public class DebugListener : ListenerBase +{ + public DebugListener(string name) + : base(name) + { + } + + public override void HandleLogEvent(LogEvent logEvent) + { + // This listener does nothing unless a Debugger is attached. + // All events will be sent to the debugger. + if (Debugger.IsAttached) + { + DebugHandleLogEvent(logEvent); + } + } + + private void DebugHandleLogEvent(LogEvent logEvent) + { + var sb = new StringBuilder(); + sb.Append(CultureInfo.InvariantCulture, $"[{logEvent.FullSourceName}] {logEvent.Severity.ToString().ToUpper(CultureInfo.InvariantCulture)}: {logEvent.Message}"); + if (logEvent.Exception != null) + { + sb.Append(CultureInfo.InvariantCulture, $"{Environment.NewLine}{logEvent.Exception}"); + } + + Trace.WriteLine(sb.ToString()); + } +} diff --git a/HyperVExtension/src/Logging/listeners/DebugListenerOptions.cs b/HyperVExtension/src/Logging/listeners/DebugListenerOptions.cs new file mode 100644 index 0000000000..bdf9fd4adb --- /dev/null +++ b/HyperVExtension/src/Logging/listeners/DebugListenerOptions.cs @@ -0,0 +1,9 @@ +// Copyright (c) Microsoft Corporation. +// Licensed under the MIT License. + +namespace DevHome.Logging; + +public partial class Options +{ + public bool DebugListenerEnabled { get; set; } = true; +} diff --git a/HyperVExtension/src/Logging/listeners/IListener.cs b/HyperVExtension/src/Logging/listeners/IListener.cs new file mode 100644 index 0000000000..69d8985db6 --- /dev/null +++ b/HyperVExtension/src/Logging/listeners/IListener.cs @@ -0,0 +1,20 @@ +// Copyright (c) Microsoft Corporation. +// Licensed under the MIT License. + +namespace DevHome.Logging.Listeners; + +public interface IListener +{ + string Name + { + get; + } + + ILoggerHost? Host + { + get; + set; + } + + void HandleLogEvent(LogEvent logEvent); +} diff --git a/HyperVExtension/src/Logging/listeners/ListenerBase.cs b/HyperVExtension/src/Logging/listeners/ListenerBase.cs new file mode 100644 index 0000000000..53a1462691 --- /dev/null +++ b/HyperVExtension/src/Logging/listeners/ListenerBase.cs @@ -0,0 +1,30 @@ +// Copyright (c) Microsoft Corporation. +// Licensed under the MIT License. + +using DevHome.Logging; + +namespace DevHome.Logging.Listeners; + +public abstract class ListenerBase : IListener +{ + public ILoggerHost? Host + { + get; + set; + } + + public Options? Options => Host?.Options; + + public string Name + { + get; + } + + public ListenerBase(string name) + { + Host = null; + Name = name; + } + + public abstract void HandleLogEvent(LogEvent logEvent); +} diff --git a/HyperVExtension/src/Logging/listeners/LogFileListener.cs b/HyperVExtension/src/Logging/listeners/LogFileListener.cs new file mode 100644 index 0000000000..edb7baa2fb --- /dev/null +++ b/HyperVExtension/src/Logging/listeners/LogFileListener.cs @@ -0,0 +1,105 @@ +// Copyright (c) Microsoft Corporation. +// Licensed under the MIT License. + +using System.Globalization; + +namespace DevHome.Logging.Listeners; + +public class LogFileListener : ListenerBase, IDisposable +{ + private readonly TextWriter? writer; + + public LogFileListener(string name, string filename) + : base(name) + { + // Should handle locked file situation better. + // For now assume one process is writing each file. + // And fail silently if we can't write for whatever reason. + try + { + var options = new FileStreamOptions + { + Access = FileAccess.Write, + Mode = FileMode.Append, + Share = FileShare.ReadWrite, + }; + writer = TextWriter.Synchronized(new StreamWriter(filename, options)); + } + catch (IOException) + { + // Do nothing, we don't want to crash the program because + // the log file couldn't be written, carry on without it. + } + } + + public override void HandleLogEvent(LogEvent logEvent) + { + HandleLogFileEvent(logEvent, true); + } + + private void HandleLogFileEvent(LogEvent logEvent, bool newline) + { + HandleLogFileEvent(logEvent, newline, LogEvent.NoElapsed); + } + + private void HandleLogFileEvent(LogEvent logEvent, bool newline, TimeSpan elapsed) + { + if (!MeetsFilter(logEvent)) + { + return; + } + + writer?.Write($"[{logEvent.Created:yyyy/MM/dd hh\\:mm\\:ss\\.ffff}][{logEvent.FullSourceName}] {logEvent.Severity.ToString().ToUpper(CultureInfo.InvariantCulture)}: {logEvent.Message}"); + if (elapsed != LogEvent.NoElapsed) + { + writer?.Write($" [Elapsed: {elapsed:hh\\:mm\\:ss\\.ffffff}]"); + } + + if (logEvent.Exception != null) + { + WriteLine(newline); + writer?.Write(logEvent?.Exception.ToString()); + } + + if (newline) + { + writer?.WriteLine(); + } + + writer?.Flush(); + } + + private void WriteLine(bool newline) + { + if (newline) + { + writer?.WriteLine(); + } + } + + private bool MeetsFilter(LogEvent logEvent) => logEvent?.Severity >= Options?.LogFileFilter; + + private bool disposed; // To detect redundant calls + + protected virtual void Dispose(bool disposing) + { + if (disposed) + { + return; + } + + if (disposing) + { + writer?.Dispose(); + } + + disposed = true; + } + + public void Dispose() + { + // Do not change this code. Put cleanup code in Dispose(bool disposing) above. + Dispose(true); + GC.SuppressFinalize(this); + } +} diff --git a/HyperVExtension/src/Logging/listeners/LogFileListenerOptions.cs b/HyperVExtension/src/Logging/listeners/LogFileListenerOptions.cs new file mode 100644 index 0000000000..2a4b816e24 --- /dev/null +++ b/HyperVExtension/src/Logging/listeners/LogFileListenerOptions.cs @@ -0,0 +1,26 @@ +// Copyright (c) Microsoft Corporation. +// Licensed under the MIT License. + +namespace DevHome.Logging; + +public partial class Options +{ + private const string LogFileNameDefault = "DevHomeHyperVExtension.log"; + private const string LogFileFolderNameDefault = "{now}"; + + public string LogFileName { get; set; } = LogFileNameDefault; + + public string LogFileFolderName { get; set; } = LogFileFolderNameDefault; + + // The Temp Path is used for storage by default so tests can run this code without being packaged. + // If we directly put in the ApplicationData folder, it would fail anytime the program was not packaged. + // For use with packaged application, set in Options to: + // ApplicationData.Current.TemporaryFolder.Path + public string LogFileFolderRoot { get; set; } = Path.GetTempPath(); + + public string LogFileFolderPath => Path.Combine(LogFileFolderRoot, LogFileFolderName); + + public bool LogFileEnabled { get; set; } = true; + + public SeverityLevel LogFileFilter { get; set; } = SeverityLevel.Info; +} diff --git a/HyperVExtension/src/Logging/listeners/StdoutListener.cs b/HyperVExtension/src/Logging/listeners/StdoutListener.cs new file mode 100644 index 0000000000..d5eb891b6c --- /dev/null +++ b/HyperVExtension/src/Logging/listeners/StdoutListener.cs @@ -0,0 +1,119 @@ +// Copyright (c) Microsoft Corporation. +// Licensed under the MIT License. + +using System.Globalization; + +namespace DevHome.Logging.Listeners; + +public class StdoutListener : ListenerBase +{ + private static readonly ConsoleColor CDefaultColor = ConsoleColor.White; + private static readonly ConsoleColor CDebugColor = ConsoleColor.DarkGray; + private static readonly ConsoleColor CInfoColor = ConsoleColor.White; + private static readonly ConsoleColor CWarnColor = ConsoleColor.Yellow; + private static readonly ConsoleColor CErrorColor = ConsoleColor.Red; + private static readonly ConsoleColor CCriticalColor = ConsoleColor.Magenta; + private static readonly ConsoleColor CExceptionColor = ConsoleColor.Red; + private static readonly ConsoleColor CElapsedColor = ConsoleColor.Green; + private static readonly ConsoleColor CSourceColor = ConsoleColor.Cyan; + + public StdoutListener(string name) + : base(name) + { + } + + public override void HandleLogEvent(LogEvent logEvent) + { + ConsoleHandleLogEvent(logEvent, true); + } + + private void ConsoleHandleLogEvent(LogEvent logEvent, bool newline) + { + ConsoleHandleLogEvent(logEvent, newline, LogEvent.NoElapsed); + } + + private void ConsoleHandleLogEvent(LogEvent logEvent, bool newline, TimeSpan elapsed) + { + if (!MeetsFilter(logEvent)) + { + return; + } + + var line = new List> + { + Tuple.Create(CDefaultColor, "["), + Tuple.Create(CSourceColor, (logEvent.SubSource != null) ? $"{logEvent.Source}/{logEvent.SubSource}" : $"{logEvent.Source}"), + Tuple.Create(CDefaultColor, "] "), + Tuple.Create(GetSeverityColor(logEvent.Severity), logEvent.Severity.ToString().ToUpper(CultureInfo.InvariantCulture)), + Tuple.Create(CDefaultColor, ": "), + Tuple.Create(GetSeverityColor(logEvent.Severity), logEvent.Message), + }; + + if (elapsed != LogEvent.NoElapsed) + { + line.Add(Tuple.Create(CDefaultColor, " [")); + line.Add(Tuple.Create(CElapsedColor, $"Elapsed: {elapsed:hh\\:mm\\:ss\\.ffffff}")); + line.Add(Tuple.Create(CDefaultColor, "]")); + } + + if (logEvent.Exception != null) + { + line.Add(Tuple.Create(CExceptionColor, $"{Environment.NewLine}{logEvent.Exception}")); + } + + if (newline) + { + line.Add(Tuple.Create(CDefaultColor, Environment.NewLine)); + } + + WriteColor(line); + Console.ResetColor(); + Console.Out.Flush(); + } + + private bool MeetsFilter(LogEvent logEvent) + { + return logEvent.Severity >= Options?.LogStdoutFilter; + } + + private void WriteColor(List> strings) + { + if (strings == null) + { + return; + } + + foreach (var s in strings) + { + WriteColor(s); + } + } + + private void WriteColor(Tuple s) + { + if (Console.IsOutputRedirected) + { + Console.Write(s.Item2); + } + else + { + var currentColor = Console.ForegroundColor; + Console.ForegroundColor = s.Item1; + Console.Write(s.Item2); + Console.ForegroundColor = currentColor; + } + } + + private ConsoleColor GetSeverityColor(SeverityLevel severity) + { + return severity switch + { + SeverityLevel.Debug => CDebugColor, + SeverityLevel.Info => CInfoColor, + SeverityLevel.Warn => CWarnColor, + SeverityLevel.Error => CErrorColor, + SeverityLevel.Critical => CCriticalColor, + _ => CDefaultColor, + }; + } +} diff --git a/HyperVExtension/src/Logging/listeners/StdoutListenerOptions.cs b/HyperVExtension/src/Logging/listeners/StdoutListenerOptions.cs new file mode 100644 index 0000000000..bbfe3aade3 --- /dev/null +++ b/HyperVExtension/src/Logging/listeners/StdoutListenerOptions.cs @@ -0,0 +1,11 @@ +// Copyright (c) Microsoft Corporation. +// Licensed under the MIT License. + +namespace DevHome.Logging; + +public partial class Options +{ + public bool LogStdoutEnabled { get; set; } = true; + + public SeverityLevel LogStdoutFilter { get; set; } = SeverityLevel.Info; +} diff --git a/HyperVExtension/src/Logging/logger/ILoggerHost.cs b/HyperVExtension/src/Logging/logger/ILoggerHost.cs new file mode 100644 index 0000000000..78d860b454 --- /dev/null +++ b/HyperVExtension/src/Logging/logger/ILoggerHost.cs @@ -0,0 +1,71 @@ +// Copyright (c) Microsoft Corporation. +// Licensed under the MIT License. + +using DevHome.Logging.Listeners; + +namespace DevHome.Logging; + +public interface ILoggerHost : IDisposable +{ + string Name + { + get; + } + + Options Options + { + get; + } + + void AddListener(IListener listener); + + void ReportEvent(LogEvent logEvent); + + void ReportEvent(string component, SeverityLevel severity, string message); + + void ReportEvent(string component, SeverityLevel severity, string message, System.Exception exception); + + void ReportEvent(string component, string subComponent, SeverityLevel severity, string message); + + void ReportEvent(string component, string subComponent, SeverityLevel severity, string message, System.Exception exception); + + void ReportDebug(string component, string message); + + void ReportDebug(string component, string message, Exception exception); + + void ReportDebug(string component, string subComponent, string message); + + void ReportDebug(string component, string subComponent, string message, Exception exception); + + void ReportInfo(string component, string message); + + void ReportInfo(string component, string message, Exception exception); + + void ReportInfo(string component, string subComponent, string message); + + void ReportInfo(string component, string subComponent, string message, Exception exception); + + void ReportWarn(string component, string message); + + void ReportWarn(string component, string message, Exception exception); + + void ReportWarn(string component, string subComponent, string message); + + void ReportWarn(string component, string subComponent, string message, Exception exception); + + void ReportError(string component, string message); + + void ReportError(string component, string message, Exception exception); + + void ReportError(string component, string subComponent, string message); + + void ReportError(string component, string subComponent, string message, Exception exception); + + void ReportCritical(string component, string message); + + void ReportCritical(string component, string message, Exception exception); + + void ReportCritical(string component, string subComponent, string message); + + void ReportCritical(string component, string subComponent, string message, Exception exception); +} diff --git a/HyperVExtension/src/Logging/logger/LogEvent.cs b/HyperVExtension/src/Logging/logger/LogEvent.cs new file mode 100644 index 0000000000..b1ef61df8f --- /dev/null +++ b/HyperVExtension/src/Logging/logger/LogEvent.cs @@ -0,0 +1,109 @@ +// Copyright (c) Microsoft Corporation. +// Licensed under the MIT License. + +using DevHome.Logging.Helpers; + +namespace DevHome.Logging; + +public class LogEvent +{ + public string Source + { + get; + } + + public string? SubSource + { + get; + } + + public SeverityLevel Severity + { + get; + } + + public string Message + { + get; + } + + public Exception? Exception + { + get; + } + + public DateTime Created + { + get; + } + + public TimeSpan Elapsed + { + get; + private set; + } + + internal void SetElapsed(TimeSpan elapsed) => Elapsed = elapsed; + + public static long NoElapsedTicks => -1L; + + public static TimeSpan NoElapsed => new(NoElapsedTicks); + + public bool HasElapsed => Elapsed.Ticks >= 0; + + private LogEvent(string source, string subSource, SeverityLevel severity, string message, Exception exception, TimeSpan elapsed) + { + Source = source; + SubSource = subSource; + Severity = severity; + Message = message; + Exception = exception; + Elapsed = elapsed; + Created = DateTime.UtcNow; + } + + public static LogEvent Create(string source, string subSource, SeverityLevel severity, string message) => Create(source, subSource, severity, message, null, NoElapsed); + + public static LogEvent Create(string source, string subSource, SeverityLevel severity, string message, Exception exception) => Create(source, subSource, severity, message, exception, NoElapsed); + + public static LogEvent Create(string source, string subSource, SeverityLevel severity, string message, TimeSpan elapsed) => Create(source, subSource, severity, message, null, elapsed); + + public static LogEvent Create(string source, string subSource, SeverityLevel severity, string message, Exception? exception, TimeSpan elapsed) => new(source, subSource, severity, message, exception!, elapsed); + + public string FullSourceName + { + get + { + if (SubSource != null) + { + return $"{Source}/{SubSource}"; + } + else + { + return Source; + } + } + } + + public override string ToString() + { + var hasException = Exception != null; + + if (hasException && HasElapsed) + { + return "[{0}] {1} {2} {3} {4}".FormatInvariant(FullSourceName, Severity.ToString(), Message, Exception!, Elapsed); + } + else if (hasException && !HasElapsed) + { + return "[{0}] {1} {2} {3}".FormatInvariant(FullSourceName, Severity.ToString(), Message, Exception!); + } + else if (!hasException && HasElapsed) + { + return "[{0}] {1} {2} {3}".FormatInvariant(FullSourceName, Severity.ToString(), Message, Elapsed); + } + else + { + return "[{0}] {1} {2}".FormatInvariant(FullSourceName, Severity.ToString(), Message); + } + } +} diff --git a/HyperVExtension/src/Logging/logger/Logger.cs b/HyperVExtension/src/Logging/logger/Logger.cs new file mode 100644 index 0000000000..c096eacc4b --- /dev/null +++ b/HyperVExtension/src/Logging/logger/Logger.cs @@ -0,0 +1,417 @@ +// Copyright (c) Microsoft Corporation. +// Licensed under the MIT License. + +using System.Collections.Concurrent; +using DevHome.Logging.Helpers; +using DevHome.Logging.Listeners; + +namespace DevHome.Logging; + +public class Logger : ILoggerHost, IDisposable +{ + public Logger(string name, Options options) + { + Name = name; + Options = options; + + // Debug Listneer + if (options.DebugListenerEnabled) + { + var debugListener = new DebugListener("Debug"); + AddListenerInternal(debugListener); + } + + // Console listener + if (options.LogStdoutEnabled) + { + var stdoutListener = new StdoutListener("Stdout"); + AddListenerInternal(stdoutListener); + } + + // Log to file listener + if (options.LogFileEnabled) + { + var logFilename = FileSystem.BuildOutputFilename(options.LogFileName, options.LogFileFolderPath); + var logFileListener = new LogFileListener("LogFile", logFilename); + ReportInfo($"Logging to {logFilename}"); + AddListenerInternal(logFileListener); + } + + StartLogEventProcessor(); + } + + ~Logger() + { + Dispose(); + } + + private readonly BlockingCollection eventQueue = new(new ConcurrentQueue()); + + private readonly ManualResetEvent processorCanceledEvent = new(true); + + private CancellationTokenSource? cancelTokenSource; + + private bool _logEventProcessorIsStopped = true; + + private ConcurrentDictionary Listeners { get; } = new ConcurrentDictionary(); + + public Options Options + { + get; + } + + public string Name + { + get; + } + + public void AddListener(IListener listener) + { + // External adds outside the constructor need to stop the log event processor. + // Otherwise we could be adding while iterating through the list. + StopLogEventProcessor(); + AddListenerInternal(listener); + StartLogEventProcessor(); + } + + private void AddListenerInternal(IListener listener) + { + listener.Host = this; + Listeners.TryAdd(listener.Name, listener); + } + + public void ReportEvent(LogEvent logEvent) + { + try + { + _ = eventQueue.TryAdd(logEvent); + } + catch + { + // Errors trying to add to the log are ignored. + } + } + + private void StartLogEventProcessor() + { + _ = Task.Run(() => + { + _logEventProcessorIsStopped = false; + cancelTokenSource = new CancellationTokenSource(); + processorCanceledEvent.Reset(); + while (!eventQueue.IsCompleted) + { + LogEvent? logEvent; + try + { + _ = eventQueue.TryTake(out logEvent, -1, cancelTokenSource.Token); + } + catch (OperationCanceledException) + { + // It is possible we miss events because cancellation occurred while there was + // still an event queue. Drain the remaining queue and process those events + // before terminating. + try + { + // This is a snapshot of the current collection, it will not block or handle + // new items added after this point. + foreach (var evt in eventQueue) + { + ProcessLogEvent(evt); + } + } + catch + { + // This is best effort, if there are problems, carry on. + } + + _logEventProcessorIsStopped = true; + processorCanceledEvent.Set(); + break; + } + + if (logEvent is not null) + { + ProcessLogEventFailFast(logEvent); + } + } + }); + } + + private void StopLogEventProcessor() + { + if (_logEventProcessorIsStopped) + { + return; + } + + try + { + cancelTokenSource?.Cancel(); + } + catch + { + // if there is a problem cancelling the task, don't wait on it finishing. + return; + } + + // Give the logger at most five seconds to finish writing out events. + processorCanceledEvent.WaitOne(5 * 1000); + } + + private void ProcessLogEvent(LogEvent logEvent) + { + foreach (var listener in Listeners) + { + try + { + listener.Value.HandleLogEvent(logEvent); + } + catch + { + // Do not take down the entire app if a listener fails to log; ignore it. +#if DEBUG + // Throw on debug builds. + throw; +#endif + } + } + } + + private void ProcessLogEventFailFast(LogEvent logEvent) + { + ProcessLogEvent(logEvent); + FailFastIfMeetsFailFastSeverity(logEvent); + } + + private void FailFastIfMeetsFailFastSeverity(LogEvent logEvent) + { + if (FailFast.IsFailFastSeverityLevel(logEvent.Severity, Options.FailFastSeverity)) + { + // Send a final critical event indicating we are intentionally failing fast here. + var failFastNotice = LogEvent.Create( + Name, + null!, + SeverityLevel.Critical, + $"Terminating program: failure event meets FailFast threshold of '{Options.FailFastSeverity}'.\n{Environment.StackTrace}"); + ProcessLogEvent(failFastNotice); + Environment.FailFast(logEvent.Message, logEvent.Exception); + } + } + + public void ReportEvent(SeverityLevel severity, string message) + { + ReportEvent(severity, message, null!); + } + + public void ReportEvent(SeverityLevel severity, string message, Exception exception) + { + var logEvent = LogEvent.Create(Name, null!, severity, message, exception); + ReportEvent(logEvent); + } + + public void ReportEvent(string component, SeverityLevel severity, string message) + { + ReportEvent(component, null!, severity, message, null!); + } + + public void ReportEvent(string component, SeverityLevel severity, string message, Exception exception) + { + ReportEvent(component, null!, severity, message, exception); + } + + public void ReportEvent(string component, string subComponent, SeverityLevel severity, string message) + { + ReportEvent(component, subComponent, severity, message, null!); + } + + public void ReportEvent(string component, string subComponent, SeverityLevel severity, string message, System.Exception exception) + { + var logEvent = LogEvent.Create(component, subComponent, severity, message, exception); + ReportEvent(logEvent); + } + + public void ReportDebug(string message) + { +#if DEBUG + ReportEvent(SeverityLevel.Debug, message); +#endif + } + + public void ReportDebug(string message, Exception exception) + { +#if DEBUG + ReportEvent(SeverityLevel.Debug, message, exception); +#endif + } + + public void ReportDebug(string component, string message) + { +#if DEBUG + ReportEvent(component, SeverityLevel.Debug, message); +#endif + } + + public void ReportDebug(string component, string message, Exception exception) + { +#if DEBUG + ReportEvent(component, SeverityLevel.Debug, message, exception); +#endif + } + + public void ReportDebug(string component, string subComponent, string message) + { +#if DEBUG + ReportEvent(component, subComponent, SeverityLevel.Debug, message); +#endif + } + + public void ReportDebug(string component, string subComponent, string message, Exception exception) + { +#if DEBUG + ReportEvent(component, subComponent, SeverityLevel.Debug, message, exception); +#endif + } + + public void ReportInfo(string message) + { + ReportEvent(SeverityLevel.Info, message); + } + + public void ReportInfo(string message, Exception exception) + { + ReportEvent(SeverityLevel.Info, message, exception); + } + + public void ReportInfo(string component, string message) + { + ReportEvent(component, SeverityLevel.Info, message); + } + + public void ReportInfo(string component, string message, Exception exception) + { + ReportEvent(component, SeverityLevel.Info, message, exception); + } + + public void ReportInfo(string component, string subComponent, string message) + { + ReportEvent(component, subComponent, SeverityLevel.Info, message); + } + + public void ReportInfo(string component, string subComponent, string message, Exception exception) + { + ReportEvent(component, subComponent, SeverityLevel.Info, message, exception); + } + + public void ReportWarn(string message) + { + ReportEvent(SeverityLevel.Warn, message); + } + + public void ReportWarn(string message, Exception exception) + { + ReportEvent(SeverityLevel.Warn, message, exception); + } + + public void ReportWarn(string component, string message) + { + ReportEvent(component, SeverityLevel.Warn, message); + } + + public void ReportWarn(string component, string message, Exception exception) + { + ReportEvent(component, SeverityLevel.Warn, message, exception); + } + + public void ReportWarn(string component, string subComponent, string message) + { + ReportEvent(component, subComponent, SeverityLevel.Warn, message); + } + + public void ReportWarn(string component, string subComponent, string message, Exception exception) + { + ReportEvent(component, subComponent, SeverityLevel.Warn, message, exception); + } + + public void ReportError(string message) + { + ReportEvent(SeverityLevel.Error, message); + } + + public void ReportError(string message, Exception exception) + { + ReportEvent(SeverityLevel.Error, message, exception); + } + + public void ReportError(string component, string message) + { + ReportEvent(component, SeverityLevel.Error, message); + } + + public void ReportError(string component, string message, Exception exception) + { + ReportEvent(component, SeverityLevel.Error, message, exception); + } + + public void ReportError(string component, string subComponent, string message) + { + ReportEvent(component, subComponent, SeverityLevel.Error, message); + } + + public void ReportError(string component, string subComponent, string message, Exception exception) + { + ReportEvent(component, subComponent, SeverityLevel.Error, message, exception); + } + + public void ReportCritical(string message) + { + ReportEvent(SeverityLevel.Critical, message); + } + + public void ReportCritical(string message, Exception exception) + { + ReportEvent(SeverityLevel.Critical, message, exception); + } + + public void ReportCritical(string component, string message) + { + ReportEvent(component, SeverityLevel.Critical, message); + } + + public void ReportCritical(string component, string message, Exception exception) + { + ReportEvent(component, SeverityLevel.Critical, message, exception); + } + + public void ReportCritical(string component, string subComponent, string message) + { + ReportEvent(component, subComponent, SeverityLevel.Critical, message); + } + + public void ReportCritical(string component, string subComponent, string message, Exception exception) + { + ReportEvent(component, subComponent, SeverityLevel.Critical, message, exception); + } + + private bool disposed; // To detect redundant calls + + protected virtual void Dispose(bool disposing) + { + if (!disposed) + { + var disposingEvent = LogEvent.Create(Name, null!, SeverityLevel.Debug, "Disposing of all logging listeners."); + ReportEvent(disposingEvent); + StopLogEventProcessor(); + Listeners.DisposeAll(); + disposed = true; + } + } + + // This code added to correctly implement the disposable pattern. + public void Dispose() + { + // Do not change this code. Put cleanup code in Dispose(bool disposing) above. + Dispose(true); + GC.SuppressFinalize(this); + } +} diff --git a/HyperVExtension/src/Logging/logger/Options.cs b/HyperVExtension/src/Logging/logger/Options.cs new file mode 100644 index 0000000000..d0cbfb1217 --- /dev/null +++ b/HyperVExtension/src/Logging/logger/Options.cs @@ -0,0 +1,11 @@ +// Copyright (c) Microsoft Corporation. +// Licensed under the MIT License. + +namespace DevHome.Logging; + +public partial class Options : ICloneable +{ + public FailFastSeverityLevel FailFastSeverity { get; set; } = FailFastSeverityLevel.Critical; + + public object Clone() => MemberwiseClone(); +} diff --git a/HyperVExtension/src/Logging/logger/SeverityLevel.cs b/HyperVExtension/src/Logging/logger/SeverityLevel.cs new file mode 100644 index 0000000000..295587e2b3 --- /dev/null +++ b/HyperVExtension/src/Logging/logger/SeverityLevel.cs @@ -0,0 +1,40 @@ +// Copyright (c) Microsoft Corporation. +// Licensed under the MIT License. + +namespace DevHome.Logging; + +public enum SeverityLevel +{ + Debug, + Info, + Warn, + Error, + Critical, +} + +// For setting fail-fast behavior, at what level of failure will we conduct a fail-fast? +// This is mostly intended for specifying whether any error or any critical error causes an FailFast. +// By default we assume any critical failure is by definition something we should not continue after detecting. +public enum FailFastSeverityLevel +{ + Ignore = -1, + Warning = SeverityLevel.Warn, + Error = SeverityLevel.Error, + Critical = SeverityLevel.Critical, +} + +#pragma warning disable SA1649 // File name should match first type name. TODO: rename or remove file. +public class FailFast +#pragma warning restore SA1649 // File name should match first type name +{ + public static bool IsFailFastSeverityLevel(SeverityLevel severity, FailFastSeverityLevel failFastSeverity) + { + return failFastSeverity switch + { + FailFastSeverityLevel.Warning => severity >= SeverityLevel.Warn, + FailFastSeverityLevel.Error => severity >= SeverityLevel.Error, + FailFastSeverityLevel.Critical => severity >= SeverityLevel.Critical, + _ => false, + }; + } +} diff --git a/HyperVExtension/src/Telemetry/HyperVExtension.Telemetry.csproj b/HyperVExtension/src/Telemetry/HyperVExtension.Telemetry.csproj new file mode 100644 index 0000000000..b71e233c17 --- /dev/null +++ b/HyperVExtension/src/Telemetry/HyperVExtension.Telemetry.csproj @@ -0,0 +1,20 @@ + + + + HyperVExtension.Telemetry + x86;x64;arm64 + win10-x86;win10-x64;win10-arm64 + true + + + + + + + + + all + runtime; build; native; contentfiles; analyzers; buildtransitive + + + diff --git a/HyperVExtension/src/Telemetry/ILogger.cs b/HyperVExtension/src/Telemetry/ILogger.cs new file mode 100644 index 0000000000..a7cecb85ec --- /dev/null +++ b/HyperVExtension/src/Telemetry/ILogger.cs @@ -0,0 +1,71 @@ +// Copyright (c) Microsoft Corporation. +// Licensed under the MIT License. + +using System; +using System.Collections.Generic; +using System.Diagnostics.CodeAnalysis; +using System.Linq; +using System.Text; +using System.Threading.Tasks; + +namespace HyperVExtension.Telemetry; + +/// +/// To create an instance call LoggerFactory.Get() +/// +public interface ILogger +{ + /// + /// Add a string that we should try stripping out of some of our telemetry for sensitivity reasons (ex. VM name, etc.). + /// We can never be 100% sure we can remove every string, but this should greatly reduce us collecting PII. + /// Note that the order in which AddSensitive is called matters, as later when we call ReplaceSensitiveStrings, it will try + /// finding and replacing the earlier strings first. This can be helpful, since we can target specific + /// strings (like username) first, which should help preserve more information helpful for diagnosis. + /// + /// Sensitive string to add (ex. "c:\xyz") + /// string to replace it with (ex. "-path-") + public void AddSensitiveString(string name, string replaceWith); + + /// + /// Gets a value indicating whether telemetry is on + /// For future use if we add a registry key or some other setting to check if telemetry is turned on. + public bool IsTelemetryOn { get; } + + /// + /// Logs an exception at Measure level. To log at Critical level, the event name needs approval. + /// + /// What we trying to do when the exception occurred. + /// Exception object + /// Optional relatedActivityId which will allow to correlate this telemetry with other telemetry in the same action/activity or thread and corelate them + public void LogException(string action, Exception e, Guid? relatedActivityId = null); + + /// + /// Log the time an action took (ex. time spent on a tool). + /// + /// The measurement we're performing (ex. "DeployTime"). + /// How long the action took in milliseconds. + /// Optional relatedActivityId which will allow to correlate this telemetry with other telemetry in the same action/activity or thread and corelate them + public void LogTimeTaken(string eventName, uint timeTakenMilliseconds, Guid? relatedActivityId = null); + + /// + /// Log an informational event. Typically used for just a single event that's only called one place in the code. + /// If you are logging the same event multiple times, it's best to add a helper method in Logger + /// + /// Name of the error event + /// Determines whether to upload the data to our servers, and on how many machines. + /// Values to send to the telemetry system. + /// Optional relatedActivityId which will allow to correlate this telemetry with other telemetry in the same action/activity or thread and corelate them + /// Anonymous type. + public void Log<[DynamicallyAccessedMembers(DynamicallyAccessedMemberTypes.PublicProperties)] T>(string eventName, LogLevel level, T data, Guid? relatedActivityId = null); + + /// + /// Log an error event. Typically used for just a single event that's only called one place in the code. + /// If you are logging the same event multiple times, it's best to add a helper method in Logger + /// + /// Name of the error event + /// Determines whether to upload the data to our servers, and on how many machines. + /// Values to send to the telemetry system. + /// Optional Optional relatedActivityId which will allow to correlate this telemetry with other telemetry in the same action/activity or thread and corelate them + /// Anonymous type. + public void LogError<[DynamicallyAccessedMembers(DynamicallyAccessedMemberTypes.PublicProperties)] T>(string eventName, LogLevel level, T data, Guid? relatedActivityId = null); +} diff --git a/HyperVExtension/src/Telemetry/LogLevel.cs b/HyperVExtension/src/Telemetry/LogLevel.cs new file mode 100644 index 0000000000..07e6bf10a7 --- /dev/null +++ b/HyperVExtension/src/Telemetry/LogLevel.cs @@ -0,0 +1,45 @@ +// Copyright (c) Microsoft Corporation. +// Licensed under the MIT License. + +using System; +using System.Collections.Generic; +using System.Linq; +using System.Text; +using System.Threading.Tasks; + +namespace HyperVExtension.Telemetry; + +/// +/// Telemetry Levels. +/// These levels are defined by our telemetry system, so it's possible the sampling +/// could change in the future. +/// There aren't any convenient enums we can consume, so create our own. +/// +public enum LogLevel +{ + /// + /// Local. + /// Only log telemetry locally on the machine (similar to an ETW event). + /// + Local, + + /// + /// Info. + /// Send telemetry from internal and flighted machines, but no external retail machines. + /// + Info, + + /// + /// Measure. + /// Send telemetry from internal and flighted machines, plus a small, sample % of retail machines. + /// Should only be used for telemetry we use to derive measures from. + /// + Measure, + + /// + /// Critical. + /// Send telemetry from all devices sampled at 100%. + /// Should only be used for approved events. + /// + Critical, +} diff --git a/HyperVExtension/src/Telemetry/Logger.cs b/HyperVExtension/src/Telemetry/Logger.cs new file mode 100644 index 0000000000..0516f03fb7 --- /dev/null +++ b/HyperVExtension/src/Telemetry/Logger.cs @@ -0,0 +1,306 @@ +// Copyright (c) Microsoft Corporation. +// Licensed under the MIT License. + +using System; +using System.Collections.Generic; +using System.Diagnostics; +using System.Diagnostics.CodeAnalysis; +using System.Diagnostics.Tracing; +using System.IO; +using System.Linq; +using System.Reflection; +using System.Text; +using System.Threading.Tasks; +using System.Windows.Input; +using Microsoft.Diagnostics.Telemetry; +using Microsoft.Win32; + +namespace HyperVExtension.Telemetry; + +public class Logger : ILogger +{ + private const string ProviderName = "Microsoft.HyperVExtension"; + + /// + /// Time Taken Event Name + /// + private const string TimeTakenEventName = "TimeTaken"; + + /// + /// Exception Thrown Event Name + /// + private const string ExceptionThrownEventName = "ExceptionThrown"; + + private static readonly Guid DefaultRelatedActivityId = Guid.Empty; + + /// + /// Can only have one EventSource alive per process, so just create one statically. + /// + private static readonly EventSource TelemetryEventSourceInstance = new TelemetryEventSource(ProviderName); + + /// + /// Logs telemetry locally, but shouldn't upload it. Similar to an ETW event. + /// Should be the same as EventSourceOptions(), as Verbose is the default level. + /// + private static readonly EventSourceOptions LocalOption = new() { Level = EventLevel.Verbose }; + + /// + /// Logs error telemetry locally, but shouldn't upload it. Similar to an ETW event. + /// + private static readonly EventSourceOptions LocalErrorOption = new() { Level = EventLevel.Error }; + + /// + /// Logs telemetry. + /// Currently this is at 0% sampling for both internal and external retail devices. + /// + private static readonly EventSourceOptions InfoOption = new() { Keywords = TelemetryEventSource.TelemetryKeyword }; + + /// + /// Logs error telemetry. + /// Currently this is at 0% sampling for both internal and external retail devices. + /// + private static readonly EventSourceOptions InfoErrorOption = new() { Level = EventLevel.Error, Keywords = TelemetryEventSource.TelemetryKeyword }; + + /// + /// Logs measure telemetry. + /// This should be sent back on internal devices, and a small, sampled % of external retail devices. + /// + private static readonly EventSourceOptions MeasureOption = new() { Keywords = TelemetryEventSource.MeasuresKeyword }; + + /// + /// Logs measure error telemetry. + /// This should be sent back on internal devices, and a small, sampled % of external retail devices. + /// + private static readonly EventSourceOptions MeasureErrorOption = new() { Level = EventLevel.Error, Keywords = TelemetryEventSource.MeasuresKeyword }; + + /// + /// Logs critical telemetry. + /// This should be sent back on all devices sampled at 100%. + /// + private static readonly EventSourceOptions CriticalDataOption = new() { Keywords = TelemetryEventSource.CriticalDataKeyword }; + + /// + /// Logs critical error telemetry. + /// This should be sent back on all devices sampled at 100%. + /// + private static readonly EventSourceOptions CriticalDataErrorOption = new() { Level = EventLevel.Error, Keywords = TelemetryEventSource.CriticalDataKeyword }; + + /// + /// ActivityId so we can correlate all events in the same run + /// + private static Guid activityId = Guid.NewGuid(); + + /// + /// List of strings we should try removing for sensitivity reasons. + /// + private readonly List> sensitiveStrings = new(); + + /// + /// Initializes a new instance of the class. + /// Prevents a default instance of the Logger class from being created. + /// + internal Logger() + { + } + + /// + /// Gets a value indicating whether telemetry is on + /// For future use if we add a registry key or some other setting to check if telemetry is turned on. + public bool IsTelemetryOn => true; + + /// + /// Add a string that we should try stripping out of some of our telemetry for sensitivity reasons (ex. VM name, etc.). + /// We can never be 100% sure we can remove every string, but this should greatly reduce us collecting PII. + /// Note that the order in which AddSensitive is called matters, as later when we call ReplaceSensitiveStrings, it will try + /// finding and replacing the earlier strings first. This can be helpful, since we can target specific + /// strings (like username) first, which should help preserve more information helpful for diagnosis. + /// + /// Sensitive string to add (ex. "c:\xyz") + /// string to replace it with (ex. "-path-") + public void AddSensitiveString(string name, string replaceWith) + { + // Make sure the name isn't blank, hasn't already been added, and is greater than three characters. + // Otherwise they could name their VM "a", and then we would end up replacing every "a" with another string. + if (!string.IsNullOrWhiteSpace(name) && name.Length > 3 && !sensitiveStrings.Exists(item => name.Equals(item.Key, StringComparison.Ordinal))) + { + sensitiveStrings.Add(new KeyValuePair(name, replaceWith ?? string.Empty)); + } + } + + /// + /// Logs an exception at Measure level. To log at Critical level, the event name needs approval. + /// + /// What we trying to do when the exception occurred. + /// Exception object + /// Optional relatedActivityId which will allow to correlate this telemetry with other telemetry in the same action/activity or thread and corelate them + public void LogException(string action, Exception e, Guid? relatedActivityId = null) + { + var innerMessage = ReplaceSensitiveStrings(e.InnerException?.Message); + var innerStackTrace = new StringBuilder(); + var innerException = e.InnerException; + while (innerException != null) + { + innerStackTrace.Append(innerException.StackTrace); + + // Separating by 2 new lines to distinguish between different exceptions. + innerStackTrace.AppendLine(); + innerStackTrace.AppendLine(); + innerException = innerException.InnerException; + } + + LogError( + ExceptionThrownEventName, + LogLevel.Measure, + new + { + action, + name = e.GetType().Name, + stackTrace = e.StackTrace, + innerName = e.InnerException?.GetType().Name, + innerMessage, + innerStackTrace = innerStackTrace.ToString(), + message = ReplaceSensitiveStrings(e.Message), + }, + relatedActivityId ?? DefaultRelatedActivityId); + } + + /// + /// Log the time an action took (ex. deploy time). + /// + /// The measurement we're performing (ex. "DeployTime"). + /// How long the action took in milliseconds. + /// Optional relatedActivityId which will allow to correlate this telemetry with other telemetry in the same action/activity or thread and corelate them + public void LogTimeTaken(string eventName, uint timeTakenMilliseconds, Guid? relatedActivityId = null) + { + Log( + TimeTakenEventName, + LogLevel.Critical, + new + { + eventName, + timeTakenMilliseconds, + }, + relatedActivityId ?? DefaultRelatedActivityId); + } + + /// + /// Log an informational event. Typically used for just a single event that's only called one place in the code. + /// + /// Name of the error event + /// Determines whether to upload the data to our servers, and on how many machines. + /// Values to send to the telemetry system. + /// Optional relatedActivityId which will allow to correlate this telemetry with other telemetry in the same action/activity or thread and corelate them + /// Anonymous type. + public void Log<[DynamicallyAccessedMembers(DynamicallyAccessedMemberTypes.PublicProperties)] T>(string eventName, LogLevel level, T data, Guid? relatedActivityId = null) + { + WriteTelemetryEvent(eventName, level, relatedActivityId ?? DefaultRelatedActivityId, false, data); + } + + /// + /// Log an error event. Typically used for just a single event that's only called one place in the code. + /// + /// Name of the error event + /// Determines whether to upload the data to our servers, and on how many machines. + /// Values to send to the telemetry system. + /// Optional Optional relatedActivityId which will allow to correlate this telemetry with other telemetry in the same action/activity or thread and corelate them + /// Anonymous type. + public void LogError<[DynamicallyAccessedMembers(DynamicallyAccessedMemberTypes.PublicProperties)] T>(string eventName, LogLevel level, T data, Guid? relatedActivityId = null) + { + WriteTelemetryEvent(eventName, level, relatedActivityId ?? DefaultRelatedActivityId, true, data); + } + + /// + /// Replaces sensitive strings in a string with non sensitive strings. + /// + /// Before, unstripped string. + /// After, stripped string + private string ReplaceSensitiveStrings(string message) + { + if (message != null) + { + foreach (var pair in sensitiveStrings) + { + // There's no String.Replace() with case insensitivity. + // We could use Regular Expressions here for searching for case-insensitive string matches, + // but it's not easy to specify the RegEx timeout value for .net 4.0. And we were worried + // about rare cases where the user could accidentally lock us up with RegEx, since we're using strings + // provided by the user, so just use a simple non-RegEx replacement algorithm instead. + var sb = new StringBuilder(); + var i = 0; + while (true) + { + // Find the string to strip out. + var foundPosition = message.IndexOf(pair.Key, i, StringComparison.OrdinalIgnoreCase); + if (foundPosition < 0) + { + sb.Append(message, i, message.Length - i); + message = sb.ToString(); + break; + } + + // Replace the string. + sb.Append(message, i, foundPosition - i); + sb.Append(pair.Value); + i = foundPosition + pair.Key.Length; + } + } + } + + return message; + } + + /// + /// Writes the telemetry event info using the TraceLogging API. + /// + /// Anonymous type. + /// Name of the event. + /// Determines whether to upload the data to our servers, and the sample set of host machines. + /// Set to true if an error condition raised this event. + /// Values to send to the telemetry system. + [UnconditionalSuppressMessage( + "ReflectionAnalysis", + "IL2026:RequiresUnreferencedCode", + Justification = "The type passed for data is an anonymous type consisting of primitive type properties declared in an assembly that is not marked trimmable.")] + private void WriteTelemetryEvent<[DynamicallyAccessedMembers(DynamicallyAccessedMemberTypes.PublicProperties)] T>(string eventName, LogLevel level, Guid relatedActivityId, bool isError, T data) + { + EventSourceOptions telemetryOptions; + if (IsTelemetryOn) + { + telemetryOptions = level switch + { + LogLevel.Critical => isError ? Logger.CriticalDataErrorOption : Logger.CriticalDataOption, + LogLevel.Measure => isError ? Logger.MeasureErrorOption : Logger.MeasureOption, + LogLevel.Info => isError ? Logger.InfoErrorOption : Logger.InfoOption, + _ => isError ? Logger.LocalErrorOption : Logger.LocalOption, + }; + } + else + { + // The telemetry is not turned on, downgrade to local telemetry + telemetryOptions = isError ? Logger.LocalErrorOption : Logger.LocalOption; + } + + TelemetryEventSourceInstance.Write(eventName, ref telemetryOptions, ref activityId, ref relatedActivityId, ref data); + } + + internal void AddWellKnownSensitiveStrings() + { + try + { + // This should convert "c:\users\johndoe" to "". + var userDirectory = Directory.GetParent(Environment.GetFolderPath(Environment.SpecialFolder.ApplicationData)).FullName; + AddSensitiveString(Directory.GetParent(userDirectory).ToString(), ""); + + // Include both these names, since they should cover the logged on user, and the user who is running the tools built on top of these API's + // These names should almost always be the same, but technically could be different. + AddSensitiveString(Environment.UserName, ""); + var currentUserName = System.Security.Principal.WindowsIdentity.GetCurrent().Name.Split('\\').Last(); + AddSensitiveString(currentUserName, ""); + } + catch (Exception e) + { + // Catch and log exception + LogException("AddSensitiveStrings", e); + } + } +} diff --git a/HyperVExtension/src/Telemetry/LoggerFactory.cs b/HyperVExtension/src/Telemetry/LoggerFactory.cs new file mode 100644 index 0000000000..bb6b1a19ba --- /dev/null +++ b/HyperVExtension/src/Telemetry/LoggerFactory.cs @@ -0,0 +1,43 @@ +// Copyright (c) Microsoft Corporation. +// Licensed under the MIT License. + +using System; +using System.Collections.Generic; +using System.Linq; +using System.Text; +using System.Threading.Tasks; + +namespace HyperVExtension.Telemetry; + +/// +/// Creates instance of Logger +/// This would be useful for future when we have updated interfaces for logger like ILogger2, ILogger3 and so on +public class LoggerFactory +{ + private static readonly object LockObj = new(); + + private static Logger loggerInstance; + + private static Logger GetLoggerInstance() + { + if (loggerInstance == null) + { + lock (LockObj) + { + loggerInstance ??= new Logger(); + loggerInstance.AddWellKnownSensitiveStrings(); + } + } + + return loggerInstance; + } + + /// + /// Gets a singleton instance of Logger + /// This would be useful for future when we have updated interfaces for logger like ILogger2, ILogger3 and so on + public static T Get() + where T : ILogger + { + return (T)(object)GetLoggerInstance(); + } +} diff --git a/HyperVExtension/src/Telemetry/TelemetryEventSource.cs b/HyperVExtension/src/Telemetry/TelemetryEventSource.cs new file mode 100644 index 0000000000..46f4cbb17d --- /dev/null +++ b/HyperVExtension/src/Telemetry/TelemetryEventSource.cs @@ -0,0 +1,343 @@ +// +// Copyright (c) Microsoft Corporation. All rights reserved. +// Licensed under the MIT License. See LICENSE in the project root for license information. + +#if TELEMETRYEVENTSOURCE_USE_NUGET +using Microsoft.Diagnostics.Tracing; +#else +using System.Diagnostics.Tracing; +#endif +using System; +using SuppressMessageAttribute = System.Diagnostics.CodeAnalysis.SuppressMessageAttribute; + +#pragma warning disable 3021 // 'type' does not need a CLSCompliant attribute + +namespace Microsoft.Diagnostics.Telemetry +{ + /// + /// + /// An EventSource with extra methods and constants commonly used in Microsoft's + /// TraceLogging-based ETW. This class inherits from EventSource, and is exactly + /// the same as EventSource except that it always enables + /// EtwSelfDescribingEventFormat and never uses traits. It also provides several + /// constants and helpers commonly used by Microsoft code. + /// + /// + /// Different versions of this class use different provider traits. The provider + /// traits in this class are empty. As a result, providers using this class will + /// not join any ETW Provider Groups and will not be given any special treatment + /// by group-sensitive ETW listeners. + /// + /// + /// When including this class in your project, you may define the following + /// conditional-compilation symbols to adjust the default behaviors: + /// + /// + /// TELEMETRYEVENTSOURCE_USE_NUGET - use Microsoft.Diagnostics.Tracing instead + /// of System.Diagnostics.Tracing. + /// + /// + /// TELEMETRYEVENTSOURCE_PUBLIC - define TelemetryEventSource as public instead + /// of internal. + /// + /// +#if TELEMETRYEVENTSOURCE_PUBLIC + public +#else + internal +#endif + class TelemetryEventSource + : EventSource + { + /// + /// Keyword 0x0000100000000000 is reserved for future definition. Do + /// not use keyword 0x0000100000000000 in Microsoft-style ETW. + /// + public const EventKeywords Reserved44Keyword = (EventKeywords)0x0000100000000000; + + /// + /// Add TelemetryKeyword to eventSourceOptions.Keywords to indicate that + /// an event is for general-purpose telemetry. + /// This keyword should not be combined with MeasuresKeyword or + /// CriticalDataKeyword. + /// + public const EventKeywords TelemetryKeyword = (EventKeywords)0x0000200000000000; + + /// + /// Add MeasuresKeyword to eventSourceOptions.Keywords to indicate that + /// an event is for understanding measures and reporting scenarios. + /// This keyword should not be combined with TelemetryKeyword or + /// CriticalDataKeyword. + /// + public const EventKeywords MeasuresKeyword = (EventKeywords)0x0000400000000000; + + /// + /// Add CriticalDataKeyword to eventSourceOptions.Keywords to indicate that + /// an event powers user experiences or is critical to business intelligence. + /// This keyword should not be combined with TelemetryKeyword or + /// MeasuresKeyword. + /// + public const EventKeywords CriticalDataKeyword = (EventKeywords)0x0000800000000000; + + /// + /// Add CostDeferredLatency to eventSourceOptions.Tags to indicate that an event + /// should try to upload over free networks for a period of time before resorting + /// to upload over costed networks. + /// + public const EventTags CostDeferredLatency = (EventTags)0x040000; + + /// + /// Add CoreData to eventSourceOptions.Tags to indicate that an event + /// contains high priority "core data". + /// + public const EventTags CoreData = (EventTags)0x00080000; + + /// + /// Add InjectXToken to eventSourceOptions.Tags to indicate that an XBOX + /// identity token should be injected into the event before the event is + /// uploaded. + /// + public const EventTags InjectXToken = (EventTags)0x00100000; + + /// + /// Add RealtimeLatency to eventSourceOptions.Tags to indicate that an event + /// should be transmitted in real time (via any available connection). + /// + public const EventTags RealtimeLatency = (EventTags)0x0200000; + + /// + /// Add NormalLatency to eventSourceOptions.Tags to indicate that an event + /// should be transmitted via the preferred connection based on device policy. + /// + public const EventTags NormalLatency = (EventTags)0x0400000; + + /// + /// Add CriticalPersistence to eventSourceOptions.Tags to indicate that an + /// event should be deleted last when low on spool space. + /// + public const EventTags CriticalPersistence = (EventTags)0x0800000; + + /// + /// Add NormalPersistence to eventSourceOptions.Tags to indicate that an event + /// should be deleted first when low on spool space. + /// + public const EventTags NormalPersistence = (EventTags)0x1000000; + + /// + /// Add DropPii to eventSourceOptions.Tags to indicate that an event contains + /// PII and should be anonymized by the telemetry client. If this tag is + /// present, PartA fields that might allow identification or cross-event + /// correlation will be removed from the event. + /// + public const EventTags DropPii = (EventTags)0x02000000; + + /// + /// Add HashPii to eventSourceOptions.Tags to indicate that an event contains + /// PII and should be anonymized by the telemetry client. If this tag is + /// present, PartA fields that might allow identification or cross-event + /// correlation will be hashed (obfuscated). + /// + public const EventTags HashPii = (EventTags)0x04000000; + + /// + /// Add MarkPii to eventSourceOptions.Tags to indicate that an event contains + /// PII but may be uploaded as-is. If this tag is present, the event will be + /// marked so that it will only appear on the private stream. + /// + public const EventTags MarkPii = (EventTags)0x08000000; + + /// + /// Add DropPiiField to eventFieldAttribute.Tags to indicate that a field + /// contains PII and should be dropped by the telemetry client. + /// + public const EventFieldTags DropPiiField = (EventFieldTags)0x04000000; + + /// + /// Add HashPiiField to eventFieldAttribute.Tags to indicate that a field + /// contains PII and should be hashed (obfuscated) prior to uploading. + /// + public const EventFieldTags HashPiiField = (EventFieldTags)0x08000000; + + /// + /// Constructs a new instance of the TelemetryEventSource class with the + /// specified name. Sets the EtwSelfDescribingEventFormat option. + /// + /// The name of the event source. + [SuppressMessage("Microsoft.Performance", "CA1811", Justification = "Shared class with tiny helper methods - not all constructors/methods are used by all consumers")] + public TelemetryEventSource( + string eventSourceName) + : base( + eventSourceName, + EventSourceSettings.EtwSelfDescribingEventFormat) + { + return; + } + + /// + /// For use by derived classes that set the eventSourceName via EventSourceAttribute. + /// Sets the EtwSelfDescribingEventFormat option. + /// + [SuppressMessage("Microsoft.Performance", "CA1811", Justification = "Shared class with tiny helper methods - not all constructors/methods are used by all consumers")] + protected TelemetryEventSource() + : base( + EventSourceSettings.EtwSelfDescribingEventFormat) + { + return; + } + + /// + /// Constructs a new instance of the TelemetryEventSource class with the + /// specified name. Sets the EtwSelfDescribingEventFormat option. + /// + /// The name of the event source. + /// The parameter is not used. + [SuppressMessage("Microsoft.Performance", "CA1811", Justification = "Shared class with tiny helper methods - not all constructors/methods are used by all consumers")] + [SuppressMessage("Style", "IDE0060:Remove unused parameter", Justification = "API compatibility")] + public TelemetryEventSource( + string eventSourceName, + TelemetryGroup telemetryGroup) + : base( + eventSourceName, + EventSourceSettings.EtwSelfDescribingEventFormat) + { + return; + } + + /// + /// Returns an instance of EventSourceOptions with the TelemetryKeyword set. + /// + /// Returns an instance of EventSourceOptions with the TelemetryKeyword set. + [SuppressMessage("Microsoft.Performance", "CA1811", Justification = "Shared class with tiny helper methods - not all constructors/methods are used by all consumers")] + public static EventSourceOptions TelemetryOptions() + { + return new EventSourceOptions { Keywords = TelemetryKeyword }; + } + + /// + /// Returns an instance of EventSourceOptions with the MeasuresKeyword set. + /// + /// Returns an instance of EventSourceOptions with the MeasuresKeyword set. + [SuppressMessage("Microsoft.Performance", "CA1811", Justification = "Shared class with tiny helper methods - not all constructors/methods are used by all consumers")] + public static EventSourceOptions MeasuresOptions() + { + return new EventSourceOptions { Keywords = MeasuresKeyword }; + } + } + + /// + /// + /// The PrivTags class defines privacy tags that can be used to specify the privacy + /// category of an event. Add a privacy tag as a field with name "PartA_PrivTags". + /// As a shortcut, you can use _1 as the field name, which will automatically be + /// expanded to "PartA_PrivTags" at runtime. + /// + /// + /// Multiple tags can be OR'ed together if necessary (rarely needed). + /// + /// + /// + /// Typical usage: + /// + /// es.Write("UsageEvent", new + /// { + /// _1 = PrivTags.ProductAndServiceUsage, + /// field1 = fieldValue1, + /// field2 = fieldValue2 + /// }); + /// + /// +#if TELEMETRYEVENTSOURCE_PUBLIC + [CLSCompliant(false)] + public +#else + internal +#endif + static class PrivTags + { + /// + public const Internal.PartA_PrivTags BrowsingHistory = Internal.PartA_PrivTags.BrowsingHistory; + + /// + public const Internal.PartA_PrivTags DeviceConnectivityAndConfiguration = Internal.PartA_PrivTags.DeviceConnectivityAndConfiguration; + + /// + public const Internal.PartA_PrivTags InkingTypingAndSpeechUtterance = Internal.PartA_PrivTags.InkingTypingAndSpeechUtterance; + + /// + public const Internal.PartA_PrivTags ProductAndServicePerformance = Internal.PartA_PrivTags.ProductAndServicePerformance; + + /// + public const Internal.PartA_PrivTags ProductAndServiceUsage = Internal.PartA_PrivTags.ProductAndServiceUsage; + + /// + public const Internal.PartA_PrivTags SoftwareSetupAndInventory = Internal.PartA_PrivTags.SoftwareSetupAndInventory; + } + /// + /// Pass a TelemetryGroup value to the constructor of TelemetryEventSource + /// to control which telemetry group should be joined. + /// Note: has no effect in this version of TelemetryEventSource. + /// +#if TELEMETRYEVENTSOURCE_PUBLIC + public +#else + internal +#endif + enum TelemetryGroup + { + /// + /// The default group. Join this group to log normal, non-critical, non-coredata + /// events. + /// + MicrosoftTelemetry, + + /// + /// Join this group to log CriticalData, CoreData, or other specially approved + /// events. + /// + WindowsCoreTelemetry + } + +#pragma warning disable SA1403 // File may only contain a single namespace + namespace Internal +#pragma warning restore SA1403 // File may only contain a single namespace + { + /// + /// The complete list of privacy tags supported for events. + /// Multiple tags can be OR'ed together if an event belongs in multiple + /// categories. + /// Note that the PartA_PrivTags enum should not be used directly. + /// Instead, use values from the PrivTags class. + /// + [Flags] +#if TELEMETRYEVENTSOURCE_PUBLIC + [CLSCompliant(false)] + public +#else + internal +#endif + enum PartA_PrivTags + : ulong + { + /// + None = 0, + + /// + BrowsingHistory = 0x0000000000000002u, + + /// + DeviceConnectivityAndConfiguration = 0x0000000000000800u, + + /// + InkingTypingAndSpeechUtterance = 0x0000000000020000u, + + /// + ProductAndServicePerformance = 0x0000000001000000u, + + /// + ProductAndServiceUsage = 0x0000000002000000u, + + /// + SoftwareSetupAndInventory = 0x0000000080000000u, + } + } +} diff --git a/HyperVExtension/test/DevSetupAgent.Test/DevSetupAgent.Test.csproj b/HyperVExtension/test/DevSetupAgent.Test/DevSetupAgent.Test.csproj new file mode 100644 index 0000000000..95ac7834c2 --- /dev/null +++ b/HyperVExtension/test/DevSetupAgent.Test/DevSetupAgent.Test.csproj @@ -0,0 +1,29 @@ + + + + enable + enable + win10-x86;win10-x64;win10-arm64 + x86;x64;arm64 + false + true + + + + + + + + + + + + + + + + + + + + diff --git a/HyperVExtension/test/DevSetupAgent.Test/DevSetupAgentIntegrationTest.cs b/HyperVExtension/test/DevSetupAgent.Test/DevSetupAgentIntegrationTest.cs new file mode 100644 index 0000000000..09ccf93005 --- /dev/null +++ b/HyperVExtension/test/DevSetupAgent.Test/DevSetupAgentIntegrationTest.cs @@ -0,0 +1,234 @@ +// Copyright (c) Microsoft Corporation. +// Licensed under the MIT License. + +using System.Text.Json; +using HyperVExtension.DevSetupAgent; +using HyperVExtension.HostGuestCommunication; +using Microsoft.Extensions.DependencyInjection; +using Microsoft.Extensions.Hosting; +using Microsoft.Win32; +using Windows.UI.Accessibility; + +namespace DevSetupAgent.Test; + +[TestClass] +public class DevSetupAgentIntegrationTest +{ + protected IHost TestHost + { + get; set; + } + + public DevSetupAgentIntegrationTest() + { + TestHost = Host.CreateDefaultBuilder() + .ConfigureServices(services => + { + services.AddSingleton(); + services.AddSingleton(); + services.AddSingleton(); + services.AddSingleton(); + services.AddSingleton(); + }).Build(); + } + + [ClassInitialize] + public static void ClassInitialize(TestContext testContext) + { + } + + [ClassCleanup] + public static void ClassCleanup() + { + //// Registry.CurrentUser.DeleteSubKeyTree(@"TEST", false); + } + + [TestInitialize] + public void TestInitialize() + { + TestHost.GetService().StartAsync(CancellationToken.None); + } + + [TestCleanup] + public void TestCleanup() + { + TestHost.GetService().StopAsync(CancellationToken.None).Wait(); + } + + [TestMethod] + public void TestGetVersionRequest() + { + var registryChannelSettings = TestHost.GetService(); + var inputkey = Registry.CurrentUser.CreateSubKey(registryChannelSettings.FromHostRegistryKeyPath); + var messageId = "DevSetup{10000000-1000-1000-1000-100000000000}"; + var messageName = messageId + "~1~1"; + inputkey.SetValue(messageName, $"{{\"RequestId\": \"{messageId}\", \"RequestType\": \"GetVersion\", \"Version\": 1, \"Timestamp\":\"2023-11-21T08:08:58.6287789Z\"}}"); + + Thread.Sleep(3000); + + var outputKey = Registry.CurrentUser.CreateSubKey(registryChannelSettings.ToHostRegistryKeyPath); + var responseMessage = (string?)outputKey.GetValue(messageName); + Assert.IsNotNull(responseMessage); + var json = JsonDocument.Parse(responseMessage).RootElement; + Assert.AreEqual(messageId, json.GetProperty("RequestId").GetString()); + + // Check that the timestamp is within 5 second of the current + var time = json.GetProperty("Timestamp").GetDateTime(); + var now = DateTime.UtcNow; + Assert.IsTrue(now - time < TimeSpan.FromSeconds(5)); + + var version = json.GetProperty("Version").GetInt32(); + Assert.AreEqual(1, version); + + // TODO: Check that the response message is deleted + } + + /// + /// Test that a simple IsUserLoggedIn request can be sent to DevSetupAgent and that it responds + /// with IsUserLoggedIn property set to true. + /// Only works from elevated command prompt and LsaEnumerateLogonSessions requires Admin, System, or SeTcbPrivilege. + /// + [TestMethod] + public void TestIsUserLoggedInRequest() + { + var registryChannelSettings = TestHost.GetService(); + var inputkey = Registry.CurrentUser.CreateSubKey(registryChannelSettings.FromHostRegistryKeyPath); + var messageId = "DevSetup{10000000-1000-1000-1000-100000000000}"; + var messageName = messageId + "~1~1"; + inputkey.SetValue(messageName, $"{{\"RequestId\": \"{messageId}\", \"RequestType\": \"IsUserLoggedIn\", \"Version\": 1, \"Timestamp\":\"2023-11-21T08:08:58.6287789Z\"}}"); + + Thread.Sleep(3000); + + var outputKey = Registry.CurrentUser.CreateSubKey(registryChannelSettings.ToHostRegistryKeyPath); + var responseMessage = (string?)outputKey.GetValue(messageName); + Assert.IsNotNull(responseMessage); + var json = JsonDocument.Parse(responseMessage).RootElement; + Assert.AreEqual(messageId, json.GetProperty("RequestId").GetString()); + + // Check that the timestamp is within 5 second of the current + var time = json.GetProperty("Timestamp").GetDateTime(); + var now = DateTime.UtcNow; + Assert.IsTrue(now - time < TimeSpan.FromSeconds(5)); + + var isUserLoggedIn = json.GetProperty("IsUserLoggedIn").GetBoolean(); + Assert.AreEqual(true, isUserLoggedIn); + } + + [TestMethod] + public void TestInvalidRequest() + { + var registryChannelSettings = TestHost.GetService(); + var inputkey = Registry.CurrentUser.CreateSubKey(registryChannelSettings.FromHostRegistryKeyPath); + var messageId = "DevSetup{10000000-1000-1000-1000-200000000000}"; + var messageName = messageId + "~1~1"; + inputkey.SetValue(messageName, $"{{\"RequestId\": \"{messageId}\", \"Version\": 1, \"Timestamp\":\"2023-11-21T08:08:58.6287789Z\"}}"); + + Thread.Sleep(3000); + + var outputKey = Registry.CurrentUser.CreateSubKey(registryChannelSettings.ToHostRegistryKeyPath); + var responseMessage = (string?)outputKey.GetValue(messageName); + Assert.IsNotNull(responseMessage); + var json = JsonDocument.Parse(responseMessage).RootElement; + Assert.AreEqual(messageId, json.GetProperty("RequestId").GetString()); + + // Check that the timestamp is within 5 second of the current + var time = json.GetProperty("Timestamp").GetDateTime(); + var now = DateTime.UtcNow; + Assert.IsTrue(now - time < TimeSpan.FromSeconds(5)); + + var status = json.GetProperty("Status").GetUInt32(); + Assert.AreNotEqual(0, status); + } + + /// + /// Test that a simple Configure request can be sent to DevSetupEngine and that it responds with + /// Progress and Completed results. + /// Currently DevSetupEngine needs to be started manually from command line for this test. + /// + [TestMethod] + public void TestConfigureRequest() + { + var yaml = +@"# yaml-language-server: $schema=https://aka.ms/configuration-dsc-schema/0.2 +properties: + assertions: + - resource: Microsoft.Windows.Developer/OsVersion + directives: + description: Verify min OS version requirement + allowPrerelease: true + settings: + MinVersion: '10.0.22000' + resources: + - resource: Microsoft.Windows.Developer/DeveloperMode + directives: + description: Enable Developer Mode + allowPrerelease: true + settings: + Ensure: Present + configurationVersion: 0.2.0"; + + var noNewLinesYaml = yaml.Replace(System.Environment.NewLine, "\\n"); + + var registryChannelSettings = TestHost.GetService(); + var inputkey = Registry.CurrentUser.CreateSubKey(registryChannelSettings.FromHostRegistryKeyPath); + var messageId = "DevSetup{10000000-1000-1000-1000-100000000001}"; + var messageName = messageId + "~1~1"; + var requestData = + $"{{\"RequestId\": \"{messageId}\"," + + $" \"RequestType\": \"Configure\", \"Version\": 1, \"Timestamp\":\"2023-11-21T08:08:58.6287789Z\"," + + $" \"Configure\": \"{noNewLinesYaml}\" }}"; + + inputkey.SetValue(messageName, requestData); + + var outputKey = Registry.CurrentUser.CreateSubKey(registryChannelSettings.ToHostRegistryKeyPath); + var waitTime = DateTime.Now + TimeSpan.FromMinutes(3); + var foundProgressMessage = false; + var foundCompletedMessage = false; + while ((waitTime > DateTime.Now) && !foundCompletedMessage) + { + Thread.Sleep(1000); + var messages = MessageHelper.MergeMessageParts(MessageHelper.GetRegistryMessageKvp(outputKey)); + if (messages.Count == 0) + { + continue; + } + + foreach (var message in messages) + { + System.Diagnostics.Trace.WriteLine($"Found response registry value '{message.Key}'"); + + var responseMessage = message.Value; + if (responseMessage != null) + { + var json = JsonDocument.Parse(responseMessage).RootElement; + Assert.AreEqual(messageId, json.GetProperty("RequestId").GetString()); + + var responseType = json.GetProperty("ResponseType").GetString(); + if (responseType == "Completed") + { + var applyConfigurationResult = json.GetProperty("ApplyConfigurationResult").GetString(); + Assert.IsNotNull(applyConfigurationResult); + System.Diagnostics.Trace.WriteLine(applyConfigurationResult); + foundCompletedMessage = true; + } + else if (responseType == "Progress") + { + var configurationSetChangeData = json.GetProperty("ConfigurationSetChangeData").GetString(); + Assert.IsNotNull(configurationSetChangeData); + System.Diagnostics.Trace.WriteLine(configurationSetChangeData); + foundProgressMessage = true; + } + else + { + Assert.Fail($"Unexpected response type: {responseType}"); + } + } + + MessageHelper.DeleteAllMessages(Registry.CurrentUser, registryChannelSettings.FromHostRegistryKeyPath, message.Key); + } + } + + Assert.IsNotNull(foundProgressMessage); + Assert.IsNotNull(foundCompletedMessage); + } +} diff --git a/HyperVExtension/test/DevSetupAgent.Test/GlobalUsings.cs b/HyperVExtension/test/DevSetupAgent.Test/GlobalUsings.cs new file mode 100644 index 0000000000..df47f2a770 --- /dev/null +++ b/HyperVExtension/test/DevSetupAgent.Test/GlobalUsings.cs @@ -0,0 +1,4 @@ +// Copyright (c) Microsoft Corporation. +// Licensed under the MIT License. + +global using Microsoft.VisualStudio.TestTools.UnitTesting; diff --git a/HyperVExtension/test/DevSetupAgent.Test/TestRegistryChannelSettings.cs b/HyperVExtension/test/DevSetupAgent.Test/TestRegistryChannelSettings.cs new file mode 100644 index 0000000000..69f14e1862 --- /dev/null +++ b/HyperVExtension/test/DevSetupAgent.Test/TestRegistryChannelSettings.cs @@ -0,0 +1,16 @@ +// Copyright (c) Microsoft Corporation. +// Licensed under the MIT License. + +namespace DevSetupAgent.Test; + +using HyperVExtension.DevSetupAgent; +using Microsoft.Win32; + +public class TestRegistryChannelSettings : IRegistryChannelSettings +{ + public string FromHostRegistryKeyPath => @"TEST\External"; + + public string ToHostRegistryKeyPath => @"TEST\Guest"; + + public RegistryHive RegistryHive => RegistryHive.CurrentUser; +} diff --git a/HyperVExtension/test/DevSetupEngine.Test/DevSetupEngine.Test.csproj b/HyperVExtension/test/DevSetupEngine.Test/DevSetupEngine.Test.csproj new file mode 100644 index 0000000000..3eabd33ae5 --- /dev/null +++ b/HyperVExtension/test/DevSetupEngine.Test/DevSetupEngine.Test.csproj @@ -0,0 +1,36 @@ + + + + enable + enable + win10-x86;win10-x64;win10-arm64 + x86;x64;arm64 + false + true + + + + + + + + + + + + + all + runtime; build; native; contentfiles; analyzers; buildtransitive + + + + + + + + + + + + + diff --git a/HyperVExtension/test/DevSetupEngine.Test/DevSetupEngineIntegrationTest.cs b/HyperVExtension/test/DevSetupEngine.Test/DevSetupEngineIntegrationTest.cs new file mode 100644 index 0000000000..006833b30a --- /dev/null +++ b/HyperVExtension/test/DevSetupEngine.Test/DevSetupEngineIntegrationTest.cs @@ -0,0 +1,169 @@ +// Copyright (c) Microsoft Corporation. +// Licensed under the MIT License. + +using System.Runtime.InteropServices; +using System.Text.Json; +using Microsoft.Extensions.DependencyInjection; +using Microsoft.Extensions.Hosting; +using Microsoft.Win32; +using Microsoft.Windows.DevHome.DevSetupEngine; +using Windows.Win32; +using Windows.Win32.System.Com; +using WinRT; + +namespace DevSetupEngine.Test; + +/// +/// These tests currently require the DevSetupEngine COM server to be running. +/// It can be started manually from the command line: "DevSetupEngine.exe -RegisterProcessAsComServer" +/// +[TestClass] +public class DevSetupEngineIntegrationTest +{ + protected IHost TestHost + { + get; set; + } + + public DevSetupEngineIntegrationTest() + { + TestHost = Host.CreateDefaultBuilder() + .ConfigureServices(services => + { + }).Build(); + } + + [ClassInitialize] + public static void ClassInitialize(TestContext testContext) + { + } + + [ClassCleanup] + public static void ClassCleanup() + { + } + + [TestInitialize] + public void TestInitialize() + { + } + + [TestCleanup] + public void TestCleanup() + { + } + + [TestMethod] + public void TestDevSetupEngineCreation() + { + // DevSetupEngine needs to be started manually from command line in the test. + var devSetupEnginePtr = IntPtr.Zero; + try + { + var hr = PInvoke.CoCreateInstance(Guid.Parse("82E86C64-A8B9-44F9-9323-C37982F2D8BE"), null, CLSCTX.CLSCTX_LOCAL_SERVER, typeof(IDevSetupEngine).GUID, out var devSetupEngineObj); + if (hr < 0) + { + Marshal.ThrowExceptionForHR(hr); + } + + devSetupEnginePtr = Marshal.GetIUnknownForObject(devSetupEngineObj); + + var devSetupEngine = MarshalInterface.FromAbi(devSetupEnginePtr); + Assert.IsNotNull(devSetupEngine); + } + finally + { + if (devSetupEnginePtr != IntPtr.Zero) + { + Marshal.Release(devSetupEnginePtr); + } + } + } + + [TestMethod] + public void TestConfigureRequest() + { + // DevSetupEngine needs to be started manually from command line in the test. + var devSetupEnginePtr = IntPtr.Zero; + try + { + var hr = PInvoke.CoCreateInstance(Guid.Parse("82E86C64-A8B9-44F9-9323-C37982F2D8BE"), null, CLSCTX.CLSCTX_LOCAL_SERVER, typeof(IDevSetupEngine).GUID, out var devSetupEngineObj); + if (hr < 0) + { + Marshal.ThrowExceptionForHR(hr); + } + + devSetupEnginePtr = Marshal.GetIUnknownForObject(devSetupEngineObj); + + var yaml = +@"# yaml-language-server: $schema=https://aka.ms/configuration-dsc-schema/0.2 +properties: + assertions: + - resource: Microsoft.Windows.Developer/OsVersion + directives: + description: Verify min OS version requirement + allowPrerelease: true + settings: + MinVersion: '10.0.22000' + resources: + - resource: Microsoft.Windows.Developer/DeveloperMode + directives: + description: Enable Developer Mode + allowPrerelease: true + settings: + Ensure: Present + configurationVersion: 0.2.0"; + + Dictionary> progressResults = new() + { + { "OsVersion", new Dictionary() { { ConfigurationUnitState.Pending, false }, { ConfigurationUnitState.InProgress, false }, { ConfigurationUnitState.Completed, false } } }, + { "DeveloperMode", new Dictionary() { { ConfigurationUnitState.Pending, false }, { ConfigurationUnitState.InProgress, false }, { ConfigurationUnitState.Completed, false } } }, + }; + + var devSetupEngine = MarshalInterface.FromAbi(devSetupEnginePtr); + var operation = devSetupEngine.ApplyConfigurationAsync(yaml); + + operation.Progress = (operation, data) => + { + System.Diagnostics.Trace.WriteLine($" - Unit: {data.Unit.Type} [{data.UnitState}]"); + Assert.IsTrue(data.Change == ConfigurationSetChangeEventType.UnitStateChanged); + progressResults[data.Unit.Type][data.UnitState] = true; + }; + + operation.AsTask().Wait(); + var result = operation.GetResults(); + + Assert.IsTrue(result.OpenConfigurationSetResult != null); + Assert.IsTrue(result.OpenConfigurationSetResult.ResultCode == null); + + Assert.IsTrue(result.ApplyConfigurationSetResult != null); + Assert.IsTrue(result.ApplyConfigurationSetResult.ResultCode == null); + + for (var i = 0; i < result.ApplyConfigurationSetResult.UnitResults.Count; i++) + { + var unitResult = result.ApplyConfigurationSetResult.UnitResults[i]; + Assert.IsTrue(unitResult.ResultInformation.ResultCode == null); + Assert.IsTrue(unitResult.RebootRequired == false); + } + + foreach (var unitName in progressResults.Keys) + { + foreach (var unitState in progressResults[unitName].Keys) + { + Assert.IsTrue(progressResults[unitName][unitState]); + } + } + } + catch (Exception ex) + { + Assert.Fail(ex.Message); + } + finally + { + if (devSetupEnginePtr != IntPtr.Zero) + { + Marshal.Release(devSetupEnginePtr); + } + } + } +} diff --git a/HyperVExtension/test/DevSetupEngine.Test/GlobalUsings.cs b/HyperVExtension/test/DevSetupEngine.Test/GlobalUsings.cs new file mode 100644 index 0000000000..df47f2a770 --- /dev/null +++ b/HyperVExtension/test/DevSetupEngine.Test/GlobalUsings.cs @@ -0,0 +1,4 @@ +// Copyright (c) Microsoft Corporation. +// Licensed under the MIT License. + +global using Microsoft.VisualStudio.TestTools.UnitTesting; diff --git a/HyperVExtension/test/DevSetupEngine.Test/IHostExtensions.cs b/HyperVExtension/test/DevSetupEngine.Test/IHostExtensions.cs new file mode 100644 index 0000000000..567f8a620d --- /dev/null +++ b/HyperVExtension/test/DevSetupEngine.Test/IHostExtensions.cs @@ -0,0 +1,39 @@ +// Copyright (c) Microsoft Corporation. +// Licensed under the MIT License. + +using System; +using Microsoft.Extensions.DependencyInjection; +using Microsoft.Extensions.Hosting; + +namespace DevSetupEngine.Test; + +public static class IHostExtensions +{ + /// + /// + /// + public static T CreateInstance(this IHost host, params object[] parameters) + { + return ActivatorUtilities.CreateInstance(host.Services, parameters); + } + + /// + /// Gets the service object for the specified type, or throws an exception + /// if type was not registered. + /// + /// Service type + /// Host object + /// Service object + /// Throw an exception if the specified + /// type is not registered + public static T GetService(this IHost host) + where T : class + { + if (host.Services.GetService(typeof(T)) is not T service) + { + throw new ArgumentException($"{typeof(T)} needs to be registered in ConfigureServices."); + } + + return service; + } +} diff --git a/HyperVExtension/test/DevSetupEngine.Test/NativeMethods.txt b/HyperVExtension/test/DevSetupEngine.Test/NativeMethods.txt new file mode 100644 index 0000000000..935f55bc49 --- /dev/null +++ b/HyperVExtension/test/DevSetupEngine.Test/NativeMethods.txt @@ -0,0 +1,12 @@ +CoRegisterClassObject +CoResumeClassObjects +CoRevokeClassObject +CoCreateInstance +CLSCTX +REGCLS +HANDLE +WIN32_ERROR +S_OK +E_NOINTERFACE +CLASS_E_NOAGGREGATION +E_ACCESSDENIED diff --git a/HyperVExtension/test/HyperVExtension/HyperVExtension.UnitTest.csproj b/HyperVExtension/test/HyperVExtension/HyperVExtension.UnitTest.csproj new file mode 100644 index 0000000000..9247fc3d65 --- /dev/null +++ b/HyperVExtension/test/HyperVExtension/HyperVExtension.UnitTest.csproj @@ -0,0 +1,39 @@ + + + + HyperVExtension.UnitTest + win10-x86;win10-x64;win10-arm64 + x86;x64;arm64 + false + enable + enable + true + resources.pri + true + + + + all + runtime; build; native; contentfiles; analyzers; buildtransitive + + + + + + + + + + + + + all + runtime; build; native; contentfiles; analyzers; buildtransitive + + + + + + + + diff --git a/HyperVExtension/test/HyperVExtension/HyperVExtensionTests/Services/HyperVExtensionIntegrationTest.cs b/HyperVExtension/test/HyperVExtension/HyperVExtensionTests/Services/HyperVExtensionIntegrationTest.cs new file mode 100644 index 0000000000..f5e1f28bf6 --- /dev/null +++ b/HyperVExtension/test/HyperVExtension/HyperVExtensionTests/Services/HyperVExtensionIntegrationTest.cs @@ -0,0 +1,364 @@ +// Copyright (c) Microsoft Corporation. +// Licensed under the MIT License. + +using System.Globalization; +using System.Management.Automation; +using System.Net; +using System.Text; +using HyperVExtension.Common; +using HyperVExtension.Common.Extensions; +using HyperVExtension.Helpers; +using HyperVExtension.Models; +using HyperVExtension.Providers; +using HyperVExtension.Services; +using HyperVExtension.UnitTest.Mocks; +using Microsoft.Extensions.DependencyInjection; +using Microsoft.Extensions.Hosting; +using Microsoft.Windows.DevHome.SDK; +using Moq; + +using Communication = HyperVExtension.CommunicationWithGuest; + +namespace HyperVExtension.UnitTest.HyperVExtensionTests.Services; + +/// +/// Hyper-V extension integration tests. +/// +[TestClass] +public class HyperVExtensionIntegrationTest +{ + protected Mock? MockedStringResource + { + get; set; + } + + protected IHost? TestHost + { + get; set; + } + + private sealed class OperationData + { + public OperationData() + { + } + + public List ProgressData { get; } = new(); + + public ApplyConfigurationResult? ConfigurationResult { get; set; } + + public ManualResetEvent Completed { get; } = new ManualResetEvent(false); + } + + [TestInitialize] + public void TestInitialize() + { + MockedStringResource = new Mock(); + TestHost = CreateTestHost(); + + // Configure string resource localization to return the input key by default + MockedStringResource + .Setup(strResource => strResource.GetLocalized(It.IsAny(), It.IsAny())) + .Returns((string key, object[] args) => key); + } + + /// + /// Create a test host with mock service instances + /// + /// Test host + private IHost CreateTestHost() + { + return Host.CreateDefaultBuilder() + .ConfigureServices(services => + { + // Services + services.AddSingleton(MockedStringResource!.Object); + services.AddSingleton(); + services.AddSingleton(); + services.AddSingleton(); + services.AddSingleton(); + + // Pattern to allow multiple non-service registered interfaces to be used with registered interfaces during construction. + services.AddSingleton(psService => + ActivatorUtilities.CreateInstance(psService, new PowerShellSession())); + services.AddSingleton(sp => psObject => ActivatorUtilities.CreateInstance(sp, psObject)); + + services.AddTransient(); + }).Build(); + } + + /// + /// Requirements to run this test: + /// Hyper-V VM named "TestVM" running + /// DevSetupAgent service is installed and running + /// Developer mode enabled (DeveloperMode task in Yaml required elevation and will fail, + /// but if it's already enabled it will succeed.) + /// User logged on to the VM. + /// + [TestMethod] + public async Task TestConfigureRequest() + { + IHyperVManager hyperVManager = TestHost!.GetService(); + var machines = hyperVManager.GetAllVirtualMachines(); + HyperVVirtualMachine? testVm = null; + foreach (var vm in machines) + { + if (string.Equals(vm.DisplayName, "TestVM", StringComparison.OrdinalIgnoreCase)) + { + testVm = vm; + break; + } + } + + Assert.IsNotNull(testVm); + + var configurationYaml = +@"# yaml-language-server: $schema=https://aka.ms/configuration-dsc-schema/0.2 +properties: + assertions: + - resource: Microsoft.Windows.Developer/OsVersion + directives: + description: Verify min OS version requirement + allowPrerelease: true + settings: + MinVersion: '10.0.22000' + resources: + - resource: Microsoft.Windows.Developer/DeveloperMode + directives: + description: Enable Developer Mode + allowPrerelease: true + settings: + Ensure: Present + configurationVersion: 0.2.0"; + + var operationData = new OperationData(); + var operation = testVm.CreateApplyConfigurationOperation(configurationYaml)!; + + operation.ConfigurationSetStateChanged += (sender, progressData) => + { + operationData.ProgressData.Add(progressData.ConfigurationSetChangeData); + PrintProgressData(progressData.ConfigurationSetChangeData); + }; + + operation.ActionRequired += async (sender, actionRequired) => + { + if (actionRequired.CorrectiveActionCardSession is VmCredentialAdaptiveCardSession credentialsCardSession) + { + var extensionAdaptiveCard = new Mock(); + extensionAdaptiveCard + .Setup(x => x.Update(It.IsAny(), It.IsAny(), It.IsAny())) + .Returns((string templateJson, string dataJson, string state) => new ProviderOperationResult(ProviderOperationStatus.Success, null, null, null)); + + extensionAdaptiveCard + .Setup(x => x.State) + .Returns("VmCredential"); + + credentialsCardSession.Initialize(extensionAdaptiveCard.Object); + var op = credentialsCardSession.OnAction(@"{ ""Type"": ""Action.Execute"", ""Id"": ""okAction"" }", @"{ ""id"": ""okAction"", ""UserVal"": """", ""PassVal"": """" }"); + await op.AsTask(); + } + else if (actionRequired.CorrectiveActionCardSession is WaitForLoginAdaptiveCardSession waitForLoginCardSession) + { + var extensionAdaptiveCard = new Mock(); + extensionAdaptiveCard + .Setup(x => x.Update(It.IsAny(), It.IsAny(), It.IsAny())) + .Returns((string templateJson, string dataJson, string state) => new ProviderOperationResult(ProviderOperationStatus.Success, null, null, null)); + + extensionAdaptiveCard + .Setup(x => x.State) + .Returns("WaitForVmUserLogin"); + + // TODO: figure out how to wait for user's login + waitForLoginCardSession.Initialize(extensionAdaptiveCard.Object); + var op = waitForLoginCardSession.OnAction(@"{ ""Type"": ""Action.Execute"", ""Id"": ""okAction"" }", @"{ }"); + await op.AsTask(); + } + }; + + var result = await operation.StartAsync(); + if (result != null) + { + operationData.ConfigurationResult = result; + } + + Assert.IsNotNull(operationData.ConfigurationResult); + PrintResultData(operationData.ConfigurationResult); + + Assert.IsTrue(operationData.ConfigurationResult.Result.Status == ProviderOperationStatus.Success); + Assert.IsNotNull(operationData.ConfigurationResult.OpenConfigurationSetResult); + Assert.IsNull(operationData.ConfigurationResult.OpenConfigurationSetResult.ResultCode); + Assert.IsNotNull(operationData.ConfigurationResult.ApplyConfigurationSetResult); + Assert.IsNull(operationData.ConfigurationResult.ApplyConfigurationSetResult.ResultCode); + Assert.IsNotNull(operationData.ConfigurationResult.ApplyConfigurationSetResult.UnitResults); + Assert.AreEqual(operationData.ConfigurationResult.ApplyConfigurationSetResult.UnitResults.Count, 2); + + foreach (var unitResult in operationData.ConfigurationResult.ApplyConfigurationSetResult.UnitResults) + { + // Assert.AreEqual(unitResult.Unit.Type, ""); + // Assert.AreEqual(unitResult.Unit.Identifier, ""); + // Assert.AreEqual(unitResult.Unit.IsGroup, ""); + // TODO: This state remains "Unknown", need to investigate why. + // Assert.AreEqual(unitResult.Unit.State, ConfigurationUnitState.Completed); + Assert.IsNull(unitResult.ResultInformation.ResultCode); + } + } + + private void PrintResultData(ApplyConfigurationResult configurationResult) + { + StringBuilder sb = new StringBuilder(); + sb.Append(CultureInfo.InvariantCulture, $"Result:\n"); + sb.Append(CultureInfo.InvariantCulture, $" ProviderOperation result status: {configurationResult.Result.Status}\n"); + sb.Append(CultureInfo.InvariantCulture, $" ProviderOperation diagnostic text: {configurationResult.Result.DiagnosticText}\n"); + + if (configurationResult.OpenConfigurationSetResult != null) + { + sb.Append(CultureInfo.InvariantCulture, $" OpenConfigurationSetResult:\n"); + sb.Append(CultureInfo.InvariantCulture, $" ResultCode: {configurationResult.OpenConfigurationSetResult.ResultCode}\n"); + sb.Append(CultureInfo.InvariantCulture, $" Field: {configurationResult.OpenConfigurationSetResult.Field}\n"); + sb.Append(CultureInfo.InvariantCulture, $" Value: {configurationResult.OpenConfigurationSetResult.Value}\n"); + sb.Append(CultureInfo.InvariantCulture, $" Line: {configurationResult.OpenConfigurationSetResult.Line}\n"); + sb.Append(CultureInfo.InvariantCulture, $" Column: {configurationResult.OpenConfigurationSetResult.Column}\n"); + } + else + { + sb.Append(CultureInfo.InvariantCulture, $" OpenConfigurationSetResult: null\n"); + } + + if (configurationResult.ApplyConfigurationSetResult != null) + { + sb.Append(CultureInfo.InvariantCulture, $" ApplyConfigurationSetResult:\n"); + sb.Append(CultureInfo.InvariantCulture, $" ResultCode: {configurationResult.ApplyConfigurationSetResult.ResultCode}\n"); + + if (configurationResult.ApplyConfigurationSetResult.UnitResults != null) + { + sb.Append(CultureInfo.InvariantCulture, $" UnitResults:\n"); + foreach (var unitResult in configurationResult.ApplyConfigurationSetResult.UnitResults) + { + if (unitResult.Unit != null) + { + sb.Append(CultureInfo.InvariantCulture, $" Unit:\n"); + sb.Append(CultureInfo.InvariantCulture, $" Type: {unitResult.Unit.Type}\n"); + sb.Append(CultureInfo.InvariantCulture, $" Identifier: {unitResult.Unit.Identifier}\n"); + sb.Append(CultureInfo.InvariantCulture, $" State: {unitResult.Unit.State}\n"); + sb.Append(CultureInfo.InvariantCulture, $" IsGroup: {unitResult.Unit.IsGroup}\n"); + sb.Append(CultureInfo.InvariantCulture, $" Units: {unitResult.Unit.Units}\n"); + } + else + { + sb.Append(CultureInfo.InvariantCulture, $" Unit: null\n"); + } + + sb.Append(CultureInfo.InvariantCulture, $" PreviouslyInDesiredState: {unitResult.PreviouslyInDesiredState}\n"); + sb.Append(CultureInfo.InvariantCulture, $" RebootRequired: {unitResult.RebootRequired}\n"); + + if (unitResult.ResultInformation != null) + { + sb.Append(CultureInfo.InvariantCulture, $" ResultInformation: \n"); + sb.Append(CultureInfo.InvariantCulture, $" ResultCode: {unitResult.ResultInformation.ResultCode}\n"); + sb.Append(CultureInfo.InvariantCulture, $" Description: {unitResult.ResultInformation.Description}\n"); + sb.Append(CultureInfo.InvariantCulture, $" Details: {unitResult.ResultInformation.Details}\n"); + sb.Append(CultureInfo.InvariantCulture, $" ResultSource: {unitResult.ResultInformation.ResultSource}\n"); + } + else + { + sb.Append(CultureInfo.InvariantCulture, $" ResultInformation: null\n"); + } + } + } + else + { + sb.Append(CultureInfo.InvariantCulture, $" UnitResults: null\n"); + } + } + else + { + sb.Append(CultureInfo.InvariantCulture, $" ApplyConfigurationSetResult: null\n"); + } + + System.Diagnostics.Trace.WriteLine(sb); + } + + private void PrintProgressData(ConfigurationSetChangeData progressData) + { + StringBuilder sb = new StringBuilder(); + + sb.Append(CultureInfo.InvariantCulture, $"Progress:\n"); + sb.Append(CultureInfo.InvariantCulture, $" Change: {progressData.Change}\n"); + sb.Append(CultureInfo.InvariantCulture, $" SetState: {progressData.SetState}\n"); + sb.Append(CultureInfo.InvariantCulture, $" UnitState: {progressData.UnitState}\n"); + + if (progressData.ResultInformation != null) + { + sb.Append(CultureInfo.InvariantCulture, $" ResultInformation: \n"); + sb.Append(CultureInfo.InvariantCulture, $" ResultCode: {progressData.ResultInformation.ResultCode}\n"); + sb.Append(CultureInfo.InvariantCulture, $" Description: {progressData.ResultInformation.Description}\n"); + sb.Append(CultureInfo.InvariantCulture, $" Details: {progressData.ResultInformation.Details}\n"); + sb.Append(CultureInfo.InvariantCulture, $" ResultSource: {progressData.ResultInformation.ResultSource}\n"); + } + else + { + sb.Append(CultureInfo.InvariantCulture, $" ResultInformation: null\n"); + } + + if (progressData.Unit != null) + { + sb.Append(CultureInfo.InvariantCulture, $" ConfigurationUnit: \n"); + sb.Append(CultureInfo.InvariantCulture, $" Type: {progressData.Unit.Type}\n"); + sb.Append(CultureInfo.InvariantCulture, $" Identifier: {progressData.Unit.Identifier}\n"); + sb.Append(CultureInfo.InvariantCulture, $" State: {progressData.Unit.State}\n"); + sb.Append(CultureInfo.InvariantCulture, $" IsGroup: {progressData.Unit.IsGroup}\n"); + sb.Append(CultureInfo.InvariantCulture, $" Units: {progressData.Unit.Units}\n"); + } + else + { + sb.Append(CultureInfo.InvariantCulture, $" ConfigurationUnit: null\n"); + } + + System.Diagnostics.Trace.WriteLine(sb); + } + + [TestMethod] + public void TestDevSetupAgentDeployment() + { + IHyperVManager hyperVManager = TestHost!.GetService(); + var machines = hyperVManager.GetAllVirtualMachines(); + HyperVVirtualMachine? testVm = null; + foreach (var vm in machines) + { + if (string.Equals(vm.DisplayName, "TestVM", StringComparison.OrdinalIgnoreCase)) + { + testVm = vm; + break; + } + } + + Assert.IsNotNull(testVm); + + var powerShell = TestHost!.GetService(); + + var deploymentHelperMock = new Mock(powerShell, testVm.Id); + deploymentHelperMock.CallBase = true; + deploymentHelperMock.Setup(x => x.GetSourcePath(It.IsAny())).Returns(Path.Combine(AppDomain.CurrentDomain.BaseDirectory, @"..\..\..\..\DevSetupAgent.zip")); + + // TODO: figure out how to get the password from the user + var userName = string.Empty; + var pwd = new NetworkCredential(string.Empty, string.Empty).SecurePassword; + deploymentHelperMock.Object.DeployDevSetupAgent(userName, pwd); + + var session = deploymentHelperMock.Object.GetSessionObject(new PSCredential(userName, pwd)); + + // Verify that the DevSetupAgent service is installed and running in the VM. + var getService = new StatementBuilder() + .AddCommand("Invoke-Command") + .AddParameter("Session", session) + .AddParameter("ScriptBlock", ScriptBlock.Create("Get-Service DevSetupAgent")) + .Build(); + + var result = powerShell!.Execute(getService, PipeType.None); + Assert.IsTrue(string.IsNullOrEmpty(result.CommandOutputErrorMessage)); + + var psObject = result.PsObjects.FirstOrDefault(); + Assert.IsNotNull(psObject); + Assert.AreEqual(psObject!.Properties["Status"].Value, "Running"); + } +} diff --git a/HyperVExtension/test/HyperVExtension/HyperVExtensionTests/Services/HyperVExtensionTestsBase.cs b/HyperVExtension/test/HyperVExtension/HyperVExtensionTests/Services/HyperVExtensionTestsBase.cs new file mode 100644 index 0000000000..3f231dc4f7 --- /dev/null +++ b/HyperVExtension/test/HyperVExtension/HyperVExtensionTests/Services/HyperVExtensionTestsBase.cs @@ -0,0 +1,128 @@ +// Copyright (c) Microsoft Corporation. +// Licensed under the MIT License. + +using System.Collections.ObjectModel; +using System.Management.Automation; +using System.ServiceProcess; +using HyperVExtension.Common; +using HyperVExtension.Models; +using HyperVExtension.Providers; +using HyperVExtension.Services; +using HyperVExtension.UnitTest.Mocks; +using Microsoft.Extensions.DependencyInjection; +using Microsoft.Extensions.Hosting; +using Microsoft.Windows.DevHome.SDK; +using Moq; +using Moq.Language; + +namespace HyperVExtension.UnitTest.HyperVExtensionTests.Services; + +/// +/// Base class that can be used to test services throughout the HyperV extension. +/// +public class HyperVExtensionTestsBase +{ + protected Mock? MockedStringResource { get; set; } + + protected Mock? MockedPowerShellSession { get; set; } + + protected PSCustomObjectMock PowerShellHyperVModule { get; set; } = new() { Name = string.Empty }; + + protected ServiceControllerStatus VirtualMachineManagementServiceStatus { get; set; } = ServiceControllerStatus.Running; + + protected IHost? TestHost { get; set; } + + [TestInitialize] + public void TestInitialize() + { + MockedStringResource = new Mock(); + MockedPowerShellSession = new Mock(); + TestHost = CreateTestHost(); + + // Configure string resource localization to return the input key by default + MockedStringResource + .Setup(strResource => strResource.GetLocalized(It.IsAny(), It.IsAny())) + .Returns((string key, object[] args) => key); + + // setup the PoewrShell session for tests that don't produce an error. + MockedPowerShellSession! + .Setup(pss => pss.GetErrorMessages()) + .Returns(() => { return string.Empty; }); + } + + /// + /// Gets a collection of PSObjects, that can be used to mock the collection of PSObjects returned + /// MockedPowerShellSession. + /// Use this when you need to mock functionality that uses the the PowerShell session. + /// + protected Collection CreatePSObjectCollection(object? mockedObject) + { + if (mockedObject == null) + { + // For cases where we want the PsObjects list to be empty; + return new Collection { }; + } + + return new Collection + { + new(mockedObject), + }; + } + + /// + /// Sets up the PowerShellSession and returns an ISetupSequentialResult that derived classes can use + /// to continue specifying a Collection per 'Invoke' call to the PowerShellSession. + /// + protected ISetupSequentialResult> SetupPowerShellSessionInvokeResults() + { + // We Return the setup sequential result so other tests can add more ISetupSequentialResult's + // to the setup for their individual test. + return MockedPowerShellSession! + .SetupSequence(pss => pss.Invoke()); + } + + /// + /// Sets up the PowerShellSession Error messages and returns an ISetupSequentialResult that derived classes can use + /// to continue specifying an error message values per 'Invoke' call to the PowerShellSession. + /// + protected ISetupSequentialResult SetupPowerShellSessionErrorMessages() + { + // We Return the setup sequential result so other tests can add more ISetupSequentialResult's + // to the setup for their individual test. + return MockedPowerShellSession! + .SetupSequence(pss => pss.GetErrorMessages()); + } + + protected void SetupHyperVTestMethod(string moduleName, ServiceControllerStatus status) + { + VirtualMachineManagementServiceStatus = status; + PowerShellHyperVModule.Name = moduleName; + } + + /// + /// Create a test host with mock service instances + /// + /// Test host + private IHost CreateTestHost() + { + return Host.CreateDefaultBuilder() + .ConfigureServices(services => + { + // Services + services.AddSingleton(MockedStringResource!.Object); + services.AddSingleton(); + services.AddSingleton(); + services.AddSingleton(); + services.AddSingleton(); + + // Pattern to allow multiple non-service registered interfaces to be used with registered interfaces during construction. + services.AddSingleton(psService => + ActivatorUtilities.CreateInstance(psService, MockedPowerShellSession!.Object)); + + services.AddTransient(controller => + ActivatorUtilities.CreateInstance(controller, VirtualMachineManagementServiceStatus)); + + services.AddSingleton(sp => psObject => ActivatorUtilities.CreateInstance(sp, psObject)); + }).Build(); + } +} diff --git a/HyperVExtension/test/HyperVExtension/HyperVExtensionTests/Services/HyperVManagerTest.cs b/HyperVExtension/test/HyperVExtension/HyperVExtensionTests/Services/HyperVManagerTest.cs new file mode 100644 index 0000000000..b6d6eea435 --- /dev/null +++ b/HyperVExtension/test/HyperVExtension/HyperVExtensionTests/Services/HyperVManagerTest.cs @@ -0,0 +1,419 @@ +// Copyright (c) Microsoft Corporation. +// Licensed under the MIT License. + +using System.ServiceProcess; +using HyperVExtension.Common.Extensions; +using HyperVExtension.Exceptions; +using HyperVExtension.Helpers; +using HyperVExtension.Models; +using HyperVExtension.Services; +using HyperVExtension.UnitTest.Mocks; +using Moq; +using TimeoutException = System.ServiceProcess.TimeoutException; + +namespace HyperVExtension.UnitTest.HyperVExtensionTests.Services; + +[TestClass] +public class HyperVManagerTest : HyperVExtensionTestsBase +{ + /// + /// Gets common PowerShell results for loading the Hyper-V module and starting the VM management service. + /// Use this when you need to mock functionality that uses the Hyper-V Manager + /// + [TestMethod] + public void IsHyperVModuleLoadedReturnsFalseWhenModuleNotLoaded() + { + // Arrange + SetupHyperVTestMethod(string.Empty, ServiceControllerStatus.Running); + SetupPowerShellSessionInvokeResults() + .Returns(() => { return CreatePSObjectCollection(PowerShellHyperVModule); }); + + // Act + var hyperVManager = TestHost.GetService(); + var actualValue = hyperVManager.IsHyperVModuleLoaded(); + + // Assert + Assert.IsFalse(actualValue); + } + + [TestMethod] + public void IsHyperVModuleLoadedReturnsTrueWhenModuleIsLoaded() + { + // Arrange + SetupHyperVTestMethod(HyperVStrings.HyperVModuleName, ServiceControllerStatus.Running); + var hyperVManager = TestHost.GetService(); + SetupPowerShellSessionInvokeResults() + .Returns(() => { return CreatePSObjectCollection(PowerShellHyperVModule); }); + + // Act + var actualValue = hyperVManager.IsHyperVModuleLoaded(); + + // Assert + Assert.IsTrue(actualValue); + } + + [TestMethod] + [ExpectedException(typeof(HyperVAdminGroupException))] + public void StartVirtualMachineManagementServiceFailsWhenUserNotInHyperVAdminGroup() + { + // Arrange + SetupHyperVTestMethod(HyperVStrings.HyperVModuleName, ServiceControllerStatus.Running); + var hyperVManager = TestHost.GetService(); + var identityService = TestHost.GetService() as WindowsIdentityServiceMock; + identityService!.SecuritySidIdentifier = string.Empty; + SetupPowerShellSessionInvokeResults() + .Returns(() => { return CreatePSObjectCollection(PowerShellHyperVModule); }); + + // Assert + hyperVManager.StartVirtualMachineManagementService(); + } + + [TestMethod] + [ExpectedException(typeof(HyperVModuleNotLoadedException))] + public void StartVirtualMachineManagementServiceFailsWhenModuleNotLoaded() + { + // Arrange + SetupHyperVTestMethod(string.Empty, ServiceControllerStatus.Running); + var hyperVManager = TestHost.GetService(); + SetupPowerShellSessionInvokeResults() + .Returns(() => { return CreatePSObjectCollection(PowerShellHyperVModule); }); + + // Assert + hyperVManager.StartVirtualMachineManagementService(); + } + + [TestMethod] + public void StartVirtualMachineManagementServiceDoesNotThrowWhenServiceIsRunning() + { + // Arrange + SetupHyperVTestMethod(HyperVStrings.HyperVModuleName, ServiceControllerStatus.Running); + var hyperVManager = TestHost.GetService(); + SetupPowerShellSessionInvokeResults() + .Returns(() => { return CreatePSObjectCollection(PowerShellHyperVModule); }); + + // If no exceptions are thrown, the service was started successfully + hyperVManager.StartVirtualMachineManagementService(); + } + + [TestMethod] + [ExpectedException(typeof(TimeoutException))] + public void StartVirtualMachineManagementServiceThrowsExceptionWhenServiceNotRunning() + { + // Make sure the service appears to be stopped the next time we create an instance + // of the IWindowsServiceController. + SetupHyperVTestMethod(HyperVStrings.HyperVModuleName, ServiceControllerStatus.Stopped); + var hyperVManager = TestHost.GetService(); + SetupPowerShellSessionInvokeResults() + .Returns(() => { return CreatePSObjectCollection(PowerShellHyperVModule); }); + + // Assert + hyperVManager.StartVirtualMachineManagementService(); + } + + [TestMethod] + public void GetAllVirtualMachinesReturnsEmptyListWhenNoVMsExist() + { + // Arrange + SetupHyperVTestMethod(HyperVStrings.HyperVModuleName, ServiceControllerStatus.Running); + var hyperVManager = TestHost.GetService(); + SetupPowerShellSessionInvokeResults() + .Returns(() => { return CreatePSObjectCollection(PowerShellHyperVModule); }) + .Returns(() => { return CreatePSObjectCollection(null); }); + + // Act + var virtualMachines = hyperVManager.GetAllVirtualMachines(); + var numberOfVirtualMachinesExpected = 0; + + // Assert + Assert.IsNotNull(virtualMachines); + Assert.AreEqual(numberOfVirtualMachinesExpected, virtualMachines.Count()); + } + + [TestMethod] + public void GetAllVirtualMachinesReturnsListOfHyperVVirtualMachinesWhenTheyExist() + { + // Arrange + SetupHyperVTestMethod(HyperVStrings.HyperVModuleName, ServiceControllerStatus.Running); + var hyperVManager = TestHost.GetService(); + SetupPowerShellSessionInvokeResults() + .Returns(() => { return CreatePSObjectCollection(PowerShellHyperVModule); }) + .Returns(() => + { + // get two virtual machines. + var collectionPsObjects = CreatePSObjectCollection(new PSCustomObjectMock()); + collectionPsObjects!.Add(new(new PSCustomObjectMock())); + return collectionPsObjects; + }); + + // Act + var virtualMachines = hyperVManager.GetAllVirtualMachines(); + var numberOfVirtualMachinesExpected = 2; + + // Assert + Assert.IsNotNull(virtualMachines); + Assert.AreEqual(numberOfVirtualMachinesExpected, virtualMachines.Count()); + } + + [TestMethod] + public void GetVirtualMachineReturnsAHyperVVirtualMachineWhenItExists() + { + // Arrange + SetupHyperVTestMethod(HyperVStrings.HyperVModuleName, ServiceControllerStatus.Running); + var hyperVManager = TestHost.GetService(); + var expectedVmGuid = Guid.NewGuid(); + SetupPowerShellSessionInvokeResults() + .Returns(() => { return CreatePSObjectCollection(PowerShellHyperVModule); }) + .Returns(() => + { + // get VM with Id = expectedVmGuid + return CreatePSObjectCollection(new PSCustomObjectMock { Id = expectedVmGuid, }); + }); + + // Act + var virtualMachine = hyperVManager.GetVirtualMachine(expectedVmGuid); + + // Assert + Assert.IsNotNull(virtualMachine); + Assert.AreEqual(virtualMachine.Id, expectedVmGuid.ToString()); + } + + [TestMethod] + public void StopVirtualMachineCanShutdownAVirtualMachine() + { + // Arrange + SetupHyperVTestMethod(HyperVStrings.HyperVModuleName, ServiceControllerStatus.Running); + var hyperVManager = TestHost.GetService(); + SetupPowerShellSessionInvokeResults() + .Returns(() => { return CreatePSObjectCollection(PowerShellHyperVModule); }) + .Returns(() => + { + // VM returned so we can check the state. + return CreatePSObjectCollection(new PSCustomObjectMock { State = HyperVState.Off, }); + }); + + // Act + var wasVMShutdown = hyperVManager.StopVirtualMachine(Guid.NewGuid(), StopVMKind.Default); + + // Assert + Assert.IsTrue(wasVMShutdown); + } + + [TestMethod] + public void StopVirtualMachineCanTurnOffAVirtualMachine() + { + // Arrange + SetupHyperVTestMethod(HyperVStrings.HyperVModuleName, ServiceControllerStatus.Running); + var hyperVManager = TestHost.GetService(); + SetupPowerShellSessionInvokeResults() + .Returns(() => { return CreatePSObjectCollection(PowerShellHyperVModule); }) + .Returns(() => + { + // Return VM that is in the off state. + return CreatePSObjectCollection(new PSCustomObjectMock { State = HyperVState.Off, }); + }); + + var wasVMTurnedOff = hyperVManager.StopVirtualMachine(Guid.NewGuid(), StopVMKind.TurnOff); + + // Assert + Assert.IsTrue(wasVMTurnedOff); + } + + [TestMethod] + public void StopVirtualMachineCanSaveAVirtualMachinesState() + { + // Arrange + SetupHyperVTestMethod(HyperVStrings.HyperVModuleName, ServiceControllerStatus.Running); + var hyperVManager = TestHost.GetService(); + SetupPowerShellSessionInvokeResults() + .Returns(() => { return CreatePSObjectCollection(PowerShellHyperVModule); }) + .Returns(() => { return CreatePSObjectCollection(new PSCustomObjectMock { State = HyperVState.Saved, }); }); + + // Act + var wasVMStateSaved = hyperVManager.StopVirtualMachine(Guid.NewGuid(), StopVMKind.Save); + + // Assert + Assert.IsTrue(wasVMStateSaved); + } + + [TestMethod] + public void StartVirtualMachineCanStartAVirtualMachine() + { + // Arrange + SetupHyperVTestMethod(HyperVStrings.HyperVModuleName, ServiceControllerStatus.Running); + var hyperVManager = TestHost.GetService(); + SetupPowerShellSessionInvokeResults() + .Returns(() => { return CreatePSObjectCollection(PowerShellHyperVModule); }) + .Returns(() => { return CreatePSObjectCollection(new PSCustomObjectMock { State = HyperVState.Running, }); }); + + // Act + var wasVMStarted = hyperVManager.StartVirtualMachine(Guid.NewGuid()); + + // Assert + Assert.IsTrue(wasVMStarted); + } + + [TestMethod] + public void PauseVirtualMachineCanPauseAVirtualMachine() + { + // Arrange + SetupHyperVTestMethod(HyperVStrings.HyperVModuleName, ServiceControllerStatus.Running); + var hyperVManager = TestHost.GetService(); + SetupPowerShellSessionInvokeResults() + .Returns(() => { return CreatePSObjectCollection(PowerShellHyperVModule); }) + .Returns(() => { return CreatePSObjectCollection(new PSCustomObjectMock { State = HyperVState.Paused, }); }); + + // Act + var wasVMPaused = hyperVManager.PauseVirtualMachine(Guid.NewGuid()); + + // Assert + Assert.IsTrue(wasVMPaused); + } + + [TestMethod] + public void ResumeVirtualMachineCanResumeAVirtualMachine() + { + // Arrange + SetupHyperVTestMethod(HyperVStrings.HyperVModuleName, ServiceControllerStatus.Running); + var hyperVManager = TestHost.GetService(); + SetupPowerShellSessionInvokeResults() + .Returns(() => { return CreatePSObjectCollection(PowerShellHyperVModule); }) + .Returns(() => { return CreatePSObjectCollection(new PSCustomObjectMock { State = HyperVState.Running, }); }); + + // Act + var wasVMResumed = hyperVManager.ResumeVirtualMachine(Guid.NewGuid()); + + // Assert + Assert.IsTrue(wasVMResumed); + } + + [TestMethod] + public void RemoveVirtualMachineCanRemoveAVirtualMachine() + { + // Arrange + SetupHyperVTestMethod(HyperVStrings.HyperVModuleName, ServiceControllerStatus.Running); + var hyperVManager = TestHost.GetService(); + SetupPowerShellSessionInvokeResults() + .Returns(() => { return CreatePSObjectCollection(PowerShellHyperVModule); }) + .Returns(() => { return CreatePSObjectCollection(new PSCustomObjectMock { IsDeleted = true, }); }); + + // Act + var wasVMRemoved = hyperVManager.RemoveVirtualMachine(Guid.NewGuid()); + + // Assert + Assert.IsTrue(wasVMRemoved); + } + + [TestMethod] + public void ConnectToVirtualMachineDoesNotThrow() + { + // Arrange + SetupHyperVTestMethod(HyperVStrings.HyperVModuleName, ServiceControllerStatus.Running); + var hyperVManager = TestHost.GetService(); + SetupPowerShellSessionInvokeResults() + .Returns(() => { return CreatePSObjectCollection(PowerShellHyperVModule); }) + .Returns(() => + { + // Simulate PowerShell won't return an object here, so simulate this. + return CreatePSObjectCollection(new PSCustomObjectMock()); + }); + + // This should not throw an exception + hyperVManager.ConnectToVirtualMachine(Guid.NewGuid()); + } + + [TestMethod] + public void GetVirtualMachineCheckpointsReturnCheckpoints() + { + // Arrange + SetupHyperVTestMethod(HyperVStrings.HyperVModuleName, ServiceControllerStatus.Running); + var hyperVManager = TestHost.GetService(); + var expectedCheckpointGuid = Guid.NewGuid(); + var checkpoint = CreatePSObjectCollection(new PSCustomObjectMock + { + Id = expectedCheckpointGuid, + Name = "TestCheckpoint", + ParentCheckpointId = Guid.NewGuid(), + ParentCheckpointName = "TestCheckpointParent", + }); + SetupPowerShellSessionInvokeResults() + .Returns(() => { return CreatePSObjectCollection(PowerShellHyperVModule); }) + .Returns(() => + { + // Simulate PowerShell returning a checkpoint. + return checkpoint; + }); + + // Act + var checkpoints = hyperVManager.GetVirtualMachineCheckpoints(Guid.NewGuid()); + var expectedCount = 1; + + // Assert + Assert.AreEqual(expectedCount, checkpoints.Count()); + Assert.AreEqual(checkpoints.First().Id.ToString(), expectedCheckpointGuid.ToString()); + } + + [TestMethod] + public void ApplyCheckpointToAVirtualMachine() + { + // Arrange + SetupHyperVTestMethod(HyperVStrings.HyperVModuleName, ServiceControllerStatus.Running); + var hyperVManager = TestHost.GetService(); + var expectedCheckpointGuid = Guid.NewGuid(); + SetupPowerShellSessionInvokeResults() + .Returns(() => { return CreatePSObjectCollection(PowerShellHyperVModule); }) + .Returns(() => { return CreatePSObjectCollection(new PSCustomObjectMock()); }) + .Returns(() => + { + // Simulate PowerShell returning a VM whole parent checkpoint Id is now the Checkpoint Id of the one passed in. + return CreatePSObjectCollection(new PSCustomObjectMock { ParentCheckpointId = expectedCheckpointGuid }); + }); + + var wasCheckpointApplied = hyperVManager.ApplyCheckpoint(Guid.NewGuid(), expectedCheckpointGuid); + + // Assert + Assert.IsTrue(wasCheckpointApplied); + } + + [TestMethod] + public void RemoveCheckpointFromAVirtualMachine() + { + // Arrange + SetupHyperVTestMethod(HyperVStrings.HyperVModuleName, ServiceControllerStatus.Running); + var hyperVManager = TestHost.GetService(); + var initialCheckpointGuid = Guid.NewGuid(); + SetupPowerShellSessionInvokeResults() + .Returns(() => { return CreatePSObjectCollection(PowerShellHyperVModule); }) + .Returns(() => { return CreatePSObjectCollection(new PSCustomObjectMock()); }) + .Returns(() => + { + // Simulate PowerShell returning An empty object when no more checkpoints exist for the VM. + return CreatePSObjectCollection(new PSCustomObjectMock()); + }); + + var wasCheckpointRemoved = hyperVManager.RemoveCheckpoint(Guid.NewGuid(), initialCheckpointGuid); + + // Assert + Assert.IsTrue(wasCheckpointRemoved); + } + + [TestMethod] + public void CreateCheckpointFromAVirtualMachine() + { + // Arrange + SetupHyperVTestMethod(HyperVStrings.HyperVModuleName, ServiceControllerStatus.Running); + var hyperVManager = TestHost.GetService(); + var newCheckpointId = Guid.NewGuid(); + SetupPowerShellSessionInvokeResults() + .Returns(() => { return CreatePSObjectCollection(PowerShellHyperVModule); }) + .Returns(() => + { + // Simulate PowerShell returning the new Checkpoint for the VM. + return CreatePSObjectCollection(new PSCustomObjectMock { Id = newCheckpointId }); + }); + + var wasCheckpointCreated = hyperVManager.CreateCheckpoint(Guid.NewGuid()); + + // Assert + Assert.IsTrue(wasCheckpointCreated); + } +} diff --git a/HyperVExtension/test/HyperVExtension/HyperVExtensionTests/Services/HyperVVirtualMachineTest.cs b/HyperVExtension/test/HyperVExtension/HyperVExtensionTests/Services/HyperVVirtualMachineTest.cs new file mode 100644 index 0000000000..1794a1304c --- /dev/null +++ b/HyperVExtension/test/HyperVExtension/HyperVExtensionTests/Services/HyperVVirtualMachineTest.cs @@ -0,0 +1,284 @@ +// Copyright (c) Microsoft Corporation. +// Licensed under the MIT License. + +using System.Collections.ObjectModel; +using System.Management.Automation; +using System.Security.Claims; +using System.Security.Principal; +using System.ServiceProcess; +using HyperVExtension.Common.Extensions; +using HyperVExtension.Helpers; +using HyperVExtension.Models; +using HyperVExtension.Services; +using HyperVExtension.UnitTest.Mocks; +using Microsoft.Windows.DevHome.SDK; +using Windows.System; + +namespace HyperVExtension.UnitTest.HyperVExtensionTests.Services; + +[TestClass] +public class HyperVVirtualMachineTest : HyperVExtensionTestsBase +{ + private readonly PSCustomObjectMock _psVirtualMachineObject = new() + { + Id = Guid.Parse("be3776d4-5082-4ca6-b352-58543365ba2d"), + ParentCheckpointId = Guid.Parse("bcd583ed-f857-4182-9f77-d13ccb6032f2"), + }; + + private readonly Checkpoint _psCheckpointAfterItWasCreated = new() + { + ParentCheckpointId = Guid.Parse("bcd583ed-f857-4182-9f77-d13ccb6032f2"), + ParentCheckpointName = "ParentCheckPoint", + Id = Guid.Parse("be3776d4-5082-4ca6-b352-58543365ba2d"), + Name = "CurrentCheckPoint", + }; + + private readonly Checkpoint _psVirtualMachineObjectAfterDeletingCheckpoint = new() + { + Id = Guid.Parse("be3776d4-5082-4ca6-b352-58543365ba2d"), + ParentCheckpointId = Guid.Parse("00000000-0000-0000-0000-000000000000"), + }; + + private readonly PSCustomObjectMock _psVirtualMachineObjectAfterDeletion = new() + { + IsDeleted = true, + }; + + [TestMethod] + public async Task HyperVVirtualMachineCanStartSuccessfully() + { + // Arrange + SetupHyperVTestMethod(HyperVStrings.HyperVModuleName, ServiceControllerStatus.Running); + _psVirtualMachineObject.State = HyperVState.Running; + var expectedPsObjectCollection = CreatePSObjectCollection(_psVirtualMachineObject); + SetupPowerShellSessionInvokeResults() + .Returns(() => { return CreatePSObjectCollection(PowerShellHyperVModule); }) + .Returns(() => { return expectedPsObjectCollection; }) + .Returns(() => { return expectedPsObjectCollection; }); + var expectedProviderOperationStatus = ProviderOperationStatus.Success; + var hyperVManager = TestHost!.GetService(); + var hyperVVirtualMachineFactory = TestHost!.GetService(); + var hyperVVirtualMachine = hyperVVirtualMachineFactory(expectedPsObjectCollection.First()); + + // Act + var computeSystemOperationResult = await hyperVVirtualMachine.StartAsync(string.Empty); + + // Assert + Assert.IsNotNull(computeSystemOperationResult); + Assert.AreEqual(expectedProviderOperationStatus, computeSystemOperationResult.Result.Status); + } + + [TestMethod] + public async Task HyperVVirtualMachineCanShutdownSuccessfully() + { + // Arrange + SetupHyperVTestMethod(HyperVStrings.HyperVModuleName, ServiceControllerStatus.Running); + _psVirtualMachineObject.State = HyperVState.Off; + var expectedPsObjectCollection = CreatePSObjectCollection(_psVirtualMachineObject); + SetupPowerShellSessionInvokeResults() + .Returns(() => { return CreatePSObjectCollection(PowerShellHyperVModule); }) + .Returns(() => { return expectedPsObjectCollection; }) + .Returns(() => { return expectedPsObjectCollection; }); + var expectedProviderOperationStatus = ProviderOperationStatus.Success; + var hyperVManager = TestHost!.GetService(); + var hyperVVirtualMachineFactory = TestHost!.GetService(); + var hyperVVirtualMachine = hyperVVirtualMachineFactory(expectedPsObjectCollection.First()); + + // Act + var computeSystemOperationResult = await hyperVVirtualMachine.ShutDownAsync(string.Empty); + + // Assert + Assert.IsNotNull(computeSystemOperationResult); + Assert.AreEqual(expectedProviderOperationStatus, computeSystemOperationResult.Result.Status); + } + + [TestMethod] + public async Task HyperVVirtualMachineCanTerminateSuccessfully() + { + // Arrange + SetupHyperVTestMethod(HyperVStrings.HyperVModuleName, ServiceControllerStatus.Running); + _psVirtualMachineObject.State = HyperVState.Off; + var expectedPsObjectCollection = CreatePSObjectCollection(_psVirtualMachineObject); + SetupPowerShellSessionInvokeResults() + .Returns(() => { return CreatePSObjectCollection(PowerShellHyperVModule); }) + .Returns(() => { return expectedPsObjectCollection; }) + .Returns(() => { return expectedPsObjectCollection; }); + var expectedProviderOperationStatus = ProviderOperationStatus.Success; + var hyperVManager = TestHost!.GetService(); + var hyperVVirtualMachineFactory = TestHost!.GetService(); + var hyperVVirtualMachine = hyperVVirtualMachineFactory(expectedPsObjectCollection.First()); + + // Act + var computeSystemOperationResult = await hyperVVirtualMachine.TerminateAsync(string.Empty); + + // Assert + Assert.IsNotNull(computeSystemOperationResult); + Assert.AreEqual(expectedProviderOperationStatus, computeSystemOperationResult.Result.Status); + } + + [TestMethod] + public async Task HyperVVirtualMachineCanBeDeletedSuccessfully() + { + // Arrange + SetupHyperVTestMethod(HyperVStrings.HyperVModuleName, ServiceControllerStatus.Running); + var expectedPsObjectCollection = CreatePSObjectCollection(_psVirtualMachineObjectAfterDeletion); + SetupPowerShellSessionInvokeResults() + .Returns(() => { return CreatePSObjectCollection(PowerShellHyperVModule); }) + .Returns(() => { return expectedPsObjectCollection; }); + var expectedProviderOperationStatus = ProviderOperationStatus.Success; + var hyperVManager = TestHost!.GetService(); + var hyperVVirtualMachineFactory = TestHost!.GetService(); + var hyperVVirtualMachine = hyperVVirtualMachineFactory(expectedPsObjectCollection.First()); + + // Act + var computeSystemOperationResult = await hyperVVirtualMachine.DeleteAsync(string.Empty); + + // Assert + Assert.IsNotNull(computeSystemOperationResult); + Assert.AreEqual(expectedProviderOperationStatus, computeSystemOperationResult.Result.Status); + } + + [TestMethod] + public async Task HyperVVirtualMachineCanSaveItsStateSuccessfully() + { + // Arrange + SetupHyperVTestMethod(HyperVStrings.HyperVModuleName, ServiceControllerStatus.Running); + _psVirtualMachineObject.State = HyperVState.Saved; + var expectedPsObjectCollection = CreatePSObjectCollection(_psVirtualMachineObject); + SetupPowerShellSessionInvokeResults() + .Returns(() => { return CreatePSObjectCollection(PowerShellHyperVModule); }) + .Returns(() => { return expectedPsObjectCollection; }) + .Returns(() => { return expectedPsObjectCollection; }); + var expectedProviderOperationStatus = ProviderOperationStatus.Success; + var hyperVManager = TestHost!.GetService(); + var hyperVVirtualMachineFactory = TestHost!.GetService(); + var hyperVVirtualMachine = hyperVVirtualMachineFactory(expectedPsObjectCollection.First()); + + // Act + var computeSystemOperationResult = await hyperVVirtualMachine.SaveAsync(string.Empty); + + // Assert + Assert.IsNotNull(computeSystemOperationResult); + Assert.AreEqual(expectedProviderOperationStatus, computeSystemOperationResult.Result.Status); + } + + [TestMethod] + public async Task HyperVVirtualMachineCanPauseItsStateSuccessfully() + { + // Arrange + SetupHyperVTestMethod(HyperVStrings.HyperVModuleName, ServiceControllerStatus.Running); + _psVirtualMachineObject.State = HyperVState.Paused; + var expectedPsObjectCollection = CreatePSObjectCollection(_psVirtualMachineObject); + SetupPowerShellSessionInvokeResults() + .Returns(() => { return CreatePSObjectCollection(PowerShellHyperVModule); }) + .Returns(() => { return expectedPsObjectCollection; }) + .Returns(() => { return expectedPsObjectCollection; }); + var expectedProviderOperationStatus = ProviderOperationStatus.Success; + var hyperVManager = TestHost!.GetService(); + var hyperVVirtualMachineFactory = TestHost!.GetService(); + var hyperVVirtualMachine = hyperVVirtualMachineFactory(expectedPsObjectCollection.First()); + + // Act + var computeSystemOperationResult = await hyperVVirtualMachine.PauseAsync(string.Empty); + + // Assert + Assert.IsNotNull(computeSystemOperationResult); + Assert.AreEqual(expectedProviderOperationStatus, computeSystemOperationResult.Result.Status); + } + + [TestMethod] + public async Task HyperVVirtualMachineCanResumeItsStateSuccessfully() + { + // Arrange + SetupHyperVTestMethod(HyperVStrings.HyperVModuleName, ServiceControllerStatus.Running); + _psVirtualMachineObject.State = HyperVState.Running; + var expectedPsObjectCollection = CreatePSObjectCollection(_psVirtualMachineObject); + SetupPowerShellSessionInvokeResults() + .Returns(() => { return CreatePSObjectCollection(PowerShellHyperVModule); }) + .Returns(() => { return expectedPsObjectCollection; }) + .Returns(() => { return expectedPsObjectCollection; }); + var expectedProviderOperationStatus = ProviderOperationStatus.Success; + var hyperVManager = TestHost!.GetService(); + var hyperVVirtualMachineFactory = TestHost!.GetService(); + var hyperVVirtualMachine = hyperVVirtualMachineFactory(expectedPsObjectCollection.First()); + + // Act + var computeSystemOperationResult = await hyperVVirtualMachine.ResumeAsync(string.Empty); + + // Assert + Assert.IsNotNull(computeSystemOperationResult); + Assert.AreEqual(expectedProviderOperationStatus, computeSystemOperationResult.Result.Status); + } + + [TestMethod] + public async Task HyperVVirtualMachineCanCreateCheckpointSuccessfully() + { + // Arrange + SetupHyperVTestMethod(HyperVStrings.HyperVModuleName, ServiceControllerStatus.Running); + _psVirtualMachineObject.State = HyperVState.Running; + var expectedPsObjectCollection = CreatePSObjectCollection(_psCheckpointAfterItWasCreated); + SetupPowerShellSessionInvokeResults() + .Returns(() => { return CreatePSObjectCollection(PowerShellHyperVModule); }) + .Returns(() => { return expectedPsObjectCollection; }); + var expectedProviderOperationStatus = ProviderOperationStatus.Success; + var hyperVManager = TestHost!.GetService(); + var hyperVVirtualMachineFactory = TestHost!.GetService(); + var hyperVVirtualMachine = hyperVVirtualMachineFactory(new PSObject(_psVirtualMachineObject)); + + // Act + var computeSystemOperationResult = await hyperVVirtualMachine.CreateSnapshotAsync(string.Empty); + + // Assert + Assert.IsNotNull(computeSystemOperationResult); + Assert.AreEqual(expectedProviderOperationStatus, computeSystemOperationResult.Result.Status); + } + + [TestMethod] + public async Task HyperVVirtualMachineCanRevertCheckpointSuccessfully() + { + // Arrange + SetupHyperVTestMethod(HyperVStrings.HyperVModuleName, ServiceControllerStatus.Running); + _psVirtualMachineObject.State = HyperVState.Running; + var expectedPsObjectCollection = CreatePSObjectCollection(_psVirtualMachineObject); + SetupPowerShellSessionInvokeResults() + .Returns(() => { return CreatePSObjectCollection(PowerShellHyperVModule); }) + .Returns(() => { return expectedPsObjectCollection; }) + .Returns(() => { return expectedPsObjectCollection; }); + var expectedProviderOperationStatus = ProviderOperationStatus.Success; + var hyperVManager = TestHost!.GetService(); + var hyperVVirtualMachineFactory = TestHost!.GetService(); + var hyperVVirtualMachine = hyperVVirtualMachineFactory(new PSObject(_psVirtualMachineObject)); + + // Act + var computeSystemOperationResult = await hyperVVirtualMachine.RevertSnapshotAsync(string.Empty); + + // Assert + Assert.IsNotNull(computeSystemOperationResult); + Assert.AreEqual(expectedProviderOperationStatus, computeSystemOperationResult.Result.Status); + } + + [TestMethod] + public async Task HyperVVirtualMachineCanDeleteCheckpointSuccessfully() + { + // Arrange + SetupHyperVTestMethod(HyperVStrings.HyperVModuleName, ServiceControllerStatus.Running); + _psVirtualMachineObject.State = HyperVState.Running; + var initialReturnedPsObjectCollection = CreatePSObjectCollection(_psVirtualMachineObject); + var psObjectCollectionReturnedAfterDeletion = CreatePSObjectCollection(_psVirtualMachineObjectAfterDeletingCheckpoint); + SetupPowerShellSessionInvokeResults() + .Returns(() => { return CreatePSObjectCollection(PowerShellHyperVModule); }) + .Returns(() => { return initialReturnedPsObjectCollection; }) + .Returns(() => { return psObjectCollectionReturnedAfterDeletion; }); + var expectedProviderOperationStatus = ProviderOperationStatus.Success; + var hyperVManager = TestHost!.GetService(); + var hyperVVirtualMachineFactory = TestHost!.GetService(); + var hyperVVirtualMachine = hyperVVirtualMachineFactory(initialReturnedPsObjectCollection.First()); + + // Act + var computeSystemOperationResult = await hyperVVirtualMachine.DeleteSnapshotAsync(string.Empty); + + // Assert + Assert.IsNotNull(computeSystemOperationResult); + Assert.AreEqual(expectedProviderOperationStatus, computeSystemOperationResult.Result.Status); + } +} diff --git a/HyperVExtension/test/HyperVExtension/HyperVExtensionTests/Services/PowerShellServiceTest.cs b/HyperVExtension/test/HyperVExtension/HyperVExtensionTests/Services/PowerShellServiceTest.cs new file mode 100644 index 0000000000..ac82d116cc --- /dev/null +++ b/HyperVExtension/test/HyperVExtension/HyperVExtensionTests/Services/PowerShellServiceTest.cs @@ -0,0 +1,109 @@ +// Copyright (c) Microsoft Corporation. +// Licensed under the MIT License. + +using HyperVExtension.Common.Extensions; +using HyperVExtension.Helpers; +using HyperVExtension.Services; +using HyperVExtension.UnitTest.Mocks; + +namespace HyperVExtension.UnitTest.HyperVExtensionTests.Services; + +[TestClass] +public class PowerShellServiceTest : HyperVExtensionTestsBase +{ + private readonly PSCustomObjectMock _powerShellSessionReturnObject = new(); + + [TestMethod] + public void PowerShellServiceCanRunASingleCommand() + { + var powerShellService = TestHost.GetService(); + var expectedDate = "11/30/2023"; + var propertyName = "Date"; + _powerShellSessionReturnObject.Date = expectedDate; + SetupPowerShellSessionInvokeResults() + .Returns(() => { return CreatePSObjectCollection(_powerShellSessionReturnObject); }); + + // Uses the Get-Date cmdlet to produce a string in the format MM/dd/yyyy + // PowerShell Equivalent: Get-Date -Date "November 30 2023" -Format "MM/dd/yyyy" + var commandLineStatements = new StatementBuilder() + .AddCommand("Get-Date") + .AddParameter("Date", "November 30 2023") + .AddParameter("Format", @"MM/dd/yyyy") + .Build(); + + // Act + var result = powerShellService.Execute(commandLineStatements.First()); + + // Assert + Assert.IsNotNull(result); + Assert.IsFalse(result.CommandOutputErrorMessage?.Length > 0); + var helper = new PsObjectHelper(result.PsObjects.First()); + var actualValue = helper.MemberNameToValue(propertyName); + Assert.AreEqual(expectedDate, actualValue); + } + + [TestMethod] + public void PowerShellServiceCanPipeMultipleStatements() + { + // Arrange + var powerShellService = TestHost.GetService(); + var expectedValueTimeZone = "Pacific Standard Time"; + var propertyName = "StandardName"; + _powerShellSessionReturnObject.StandardName = expectedValueTimeZone; + SetupPowerShellSessionInvokeResults() + .Returns(() => { return CreatePSObjectCollection(_powerShellSessionReturnObject); }); + + // Use the Get TimeZone object and pass it as input through piping to the + // Select-Object cmdlet to get the StandardName property. + // PowerShell Equivalent: Get-TimeZone -Id PST | Select-Object -Property StandardName + var commandLineStatements = new StatementBuilder() + .AddCommand("Get-TimeZone") + .AddParameter("Id", "PST") + .AddCommand("Select-Object") + .AddParameter("Property", @"StandardName") + .Build(); + + // Act + var result = powerShellService.Execute(commandLineStatements, PipeType.PipeOutput); + + // Assert + Assert.IsNotNull(result); + Assert.IsFalse(result.CommandOutputErrorMessage?.Length > 0); + var helper = new PsObjectHelper(result.PsObjects.First()); + var actualValue = helper.MemberNameToValue(propertyName); + Assert.AreEqual(expectedValueTimeZone, actualValue); + } + + [TestMethod] + public void PowerShellServiceReturnsFailureErrors() + { + // Arrange + var powerShellService = TestHost.GetService(); + var expectedValueError = "Attempted to divide by zero."; + SetupPowerShellSessionInvokeResults() + .Returns(() => { return CreatePSObjectCollection(_powerShellSessionReturnObject); }); + + // For every call to the PowerShellService's execute method, two calls to PowerShellSession.GetErrorMessages() is expected. + SetupPowerShellSessionErrorMessages() + .Returns(() => { return expectedValueError; }) + .Returns(() => { return expectedValueError; }); + + // Create a script, make sure its culture language is english and attempt to + // divide 1 by 0. + var commandLineStatements = new StatementBuilder() + .AddScript( + "[cultureinfo]::CurrentUICulture = 'en-US';" + + " 1 / 0", + true) + .Build(); + + // Act + var result = powerShellService.Execute(commandLineStatements.First()); + + // Assert + Assert.IsNotNull(result); + Assert.IsTrue(result.CommandOutputErrorMessage?.Length > 0); + var actualValue = result.CommandOutputErrorMessage; + Assert.AreEqual(expectedValueError, actualValue); + } +} diff --git a/HyperVExtension/test/HyperVExtension/Initialize.cs b/HyperVExtension/test/HyperVExtension/Initialize.cs new file mode 100644 index 0000000000..5c5c6d258c --- /dev/null +++ b/HyperVExtension/test/HyperVExtension/Initialize.cs @@ -0,0 +1,24 @@ +// Copyright (c) Microsoft Corporation. +// Licensed under the MIT License. + +using Microsoft.Windows.ApplicationModel.DynamicDependency; + +namespace HyperVExtension.UnitTest; + +[TestClass] +public class Initialize +{ + [AssemblyInitialize] + public static void AssemblyInitialize(TestContext context) + { + // TODO: Initialize the appropriate version of the Windows App SDK. + // This is required when testing MSIX apps that are framework-dependent on the Windows App SDK. + Bootstrap.TryInitialize(0x00010001, out var _); + } + + [AssemblyCleanup] + public static void AssemblyCleanup() + { + Bootstrap.Shutdown(); + } +} diff --git a/HyperVExtension/test/HyperVExtension/Mocks/PSCustomObjectMock.cs b/HyperVExtension/test/HyperVExtension/Mocks/PSCustomObjectMock.cs new file mode 100644 index 0000000000..aded031154 --- /dev/null +++ b/HyperVExtension/test/HyperVExtension/Mocks/PSCustomObjectMock.cs @@ -0,0 +1,39 @@ +// Copyright (c) Microsoft Corporation. +// Licensed under the MIT License. + +using System.ServiceProcess; + +namespace HyperVExtension.UnitTest.Mocks; + +public enum HyperVState +{ + Running, + Off, + Saved, + Paused, +} + +/// +/// Class used to mock PowerShell objects where a specific property is used +/// to identify output for a calling function. Add new properities to this class +/// or derive from this class should you need to mock output for a function that +/// looks at the properites of a returned PSCustomObject. +/// +public class PSCustomObjectMock +{ + public string Name { get; set; } = string.Empty; + + public string StandardName { get; set; } = string.Empty; + + public Enum? State { get; set; } + + public Guid Id { get; set; } + + public Guid ParentCheckpointId { get; set; } + + public string ParentCheckpointName { get; set; } = string.Empty; + + public string Date { get; set; } = string.Empty; + + public bool IsDeleted { get; set; } +} diff --git a/HyperVExtension/test/HyperVExtension/Mocks/PowerShellSessionMock.cs b/HyperVExtension/test/HyperVExtension/Mocks/PowerShellSessionMock.cs new file mode 100644 index 0000000000..5244d5fcce --- /dev/null +++ b/HyperVExtension/test/HyperVExtension/Mocks/PowerShellSessionMock.cs @@ -0,0 +1,48 @@ +// Copyright (c) Microsoft Corporation. +// Licensed under the MIT License. + +using System.Collections; +using System.Collections.ObjectModel; +using System.Management.Automation; +using HyperVExtension.Models; + +namespace HyperVExtension.UnitTest.Mocks; + +public class PowerShellSessionMock : IPowerShellSession +{ + public Collection TestResultCollection { get; set; } = new(); + + public string ErrorText { get; set; } = string.Empty; + + /// + public void AddCommand(string command) + { + } + + /// + public void AddParameters(IDictionary parameters) + { + } + + /// + public void AddScript(string script, bool useLocalScope) + { + } + + /// + public Collection Invoke() + { + return TestResultCollection; + } + + /// + public void ClearSession() + { + } + + /// + public string GetErrorMessages() + { + return ErrorText; + } +} diff --git a/HyperVExtension/test/HyperVExtension/Mocks/WindowsIdentityServiceMock.cs b/HyperVExtension/test/HyperVExtension/Mocks/WindowsIdentityServiceMock.cs new file mode 100644 index 0000000000..838265d07d --- /dev/null +++ b/HyperVExtension/test/HyperVExtension/Mocks/WindowsIdentityServiceMock.cs @@ -0,0 +1,36 @@ +// Copyright (c) Microsoft Corporation. +// Licensed under the MIT License. + +using System.Security.Claims; +using System.Security.Principal; +using HyperVExtension.Helpers; +using HyperVExtension.Models; +using Moq; + +namespace HyperVExtension.UnitTest.Mocks; + +public class WindowsIdentityServiceMock : IWindowsIdentityService +{ + public Mock WindowsIdentityWrapperMock { get; set; } = new(); + + public IdentityReferenceCollection WindowsIdentityGroups { get; set; } = new(); + + public string SecuritySidIdentifier { get; set; } = HyperVStrings.HyperVAdminGroupWellKnownSid; + + public WindowsIdentityServiceMock() + { + WindowsIdentityWrapperMock.Setup(x => x.Groups).Returns(WindowsIdentityGroups); + } + + public WindowsIdentityWrapper GetCurrentWindowsIdentity() + { + // Clear the identity collection so tests can update the SecuritySidIdentifier before GetCurrentWindowsIdentity is called. + WindowsIdentityGroups.Clear(); + if (!string.IsNullOrEmpty(SecuritySidIdentifier)) + { + WindowsIdentityGroups.Add(new SecurityIdentifier(SecuritySidIdentifier)); + } + + return WindowsIdentityWrapperMock.Object; + } +} diff --git a/HyperVExtension/test/HyperVExtension/Mocks/WindowsServiceControllerMock.cs b/HyperVExtension/test/HyperVExtension/Mocks/WindowsServiceControllerMock.cs new file mode 100644 index 0000000000..0de66e8e89 --- /dev/null +++ b/HyperVExtension/test/HyperVExtension/Mocks/WindowsServiceControllerMock.cs @@ -0,0 +1,38 @@ +// Copyright (c) Microsoft Corporation. +// Licensed under the MIT License. + +using System.ServiceProcess; +using HyperVExtension.Models; + +namespace HyperVExtension.UnitTest.Mocks; + +public class WindowsServiceControllerMock : IWindowsServiceController +{ + public ServiceControllerStatus Status => MockStatus; + + public ServiceControllerStatus MockStatus { get; set; } + + public string ServiceName { get; set; } = string.Empty; + + public WindowsServiceControllerMock(ServiceControllerStatus status) + { + MockStatus = status; + } + + public void ContinueService() + { + } + + public void StartService() + { + } + + public void WaitForStatusChange(ServiceControllerStatus desiredStatus, TimeSpan timeout) + { + // simulate us attempting to change the status of a service and timing out. + if (Status != ServiceControllerStatus.Running) + { + throw new System.ServiceProcess.TimeoutException(); + } + } +} diff --git a/HyperVExtension/test/HyperVExtension/README.md b/HyperVExtension/test/HyperVExtension/README.md new file mode 100644 index 0000000000..8124f3771a --- /dev/null +++ b/HyperVExtension/test/HyperVExtension/README.md @@ -0,0 +1,67 @@ +*Recommended Markdown Viewer: [Markdown Editor](https://marketplace.visualstudio.com/items?itemName=MadsKristensen.MarkdownEditor2)* + +## Getting Started + +[Get started with unit testing](https://docs.microsoft.com/visualstudio/test/getting-started-with-unit-testing?view=vs-2022&tabs=dotnet%2Cmstest), [Use the MSTest framework in unit tests](https://docs.microsoft.com/visualstudio/test/using-microsoft-visualstudio-testtools-unittesting-members-in-unit-tests), and [Run unit tests with Test Explorer](https://docs.microsoft.com/visualstudio/test/run-unit-tests-with-test-explorer) provide an overview of the MSTest framework and Test Explorer. + +## Testing UI Controls + +Unit tests that exercise UI controls must run on the WinUI UI thread or they will throw an exception. To run a test on the WinUI UI thread, mark the test method with `[UITestMethod]` instead of `[TestMethod]`. During test execution, the test host will launch the app and dispatch the test to the app's UI thread. + +The below example creates a `new Grid()` and then validates that its `ActualWidth` is `0`. + +```csharp +[UITestMethod] +public void UITestMethod() +{ + Assert.AreEqual(0, new Grid().ActualWidth); +} +``` + +## Dependency Injection and Mocking + +Template Studio uses [dependency injection](https://docs.microsoft.com/dotnet/core/extensions/dependency-injection) which means class dependencies implement interfaces and those dependencies are injected via class constructors. + +One of the many benefits of this approach is improved testability, since tests can produce mock implementations of the interfaces and pass them into the object being tested, isolating the object being tested from its dependencies. To mock an interface, create a class that implements the interface, create stub implementations of the interface members, then pass an instance of the class into the object constructor. + +The below example demonstrates testing the ViewModel for the Settings page. `SettingsViewModel` depends on `IThemeSelectorService`, so a `MockThemeSelectorService` class is introduced that implements the interface with stub implementations, and then an instance of that class is passed into the `SettingsViewModel` constructor. The `VerifyVersionDescription` test then validates that the `VersionDescription` property of the `SettingsViewModel` returns the expected value. + +```csharp +// SettingsViewModelTests.cs + +[TestClass] +public class SettingsViewModelTests +{ + private readonly SettingsViewModel _viewModel; + + public SettingsViewModelTests() + { + _viewModel = new SettingsViewModel(new MockThemeSelectorService()); + } + + [TestMethod] + public void VerifyVersionDescription() + { + Assert.IsTrue(Regex.IsMatch(_viewModel.VersionDescription, @"App1 - \d\.\d\.\d\.\d")); + } +} +``` + +```csharp +// Mocks/MockThemeSelectorService.cs + +internal class MockThemeSelectorService : IThemeSelectorService +{ + public ElementTheme Theme => ElementTheme.Default; + + public Task InitializeAsync() => Task.CompletedTask; + + public Task SetRequestedThemeAsync() => Task.CompletedTask; + + public Task SetThemeAsync(ElementTheme theme) => Task.CompletedTask; +} +``` + +## CI Pipelines + +See [README.md](https://github.com/microsoft/TemplateStudio/blob/main/docs/WinUI/pipelines/README.md) for guidance on building and testing projects in CI pipelines. diff --git a/HyperVExtension/test/HyperVExtension/TestClass.cs b/HyperVExtension/test/HyperVExtension/TestClass.cs new file mode 100644 index 0000000000..07852445bf --- /dev/null +++ b/HyperVExtension/test/HyperVExtension/TestClass.cs @@ -0,0 +1,52 @@ +// Copyright (c) Microsoft Corporation. +// Licensed under the MIT License. + +using System.Diagnostics; + +namespace HyperVExtension.UnitTest; + +/* + * Class showing how to write unit tests for the extension. + * https://docs.microsoft.com/visualstudio/test/getting-started-with-unit-testing + * https://docs.microsoft.com/visualstudio/test/using-microsoft-visualstudio-testtools-unittesting-members-in-unit-tests + * https://docs.microsoft.com/visualstudio/test/run-unit-tests-with-test-explorer + */ + +[TestClass] +public class TestClass : IDisposable +{ + public void Dispose() + { + GC.SuppressFinalize(this); + } + + [ClassInitialize] + public static void ClassInitialize(TestContext context) + { + Debug.WriteLine("ClassInitialize"); + } + + [ClassCleanup] + public static void ClassCleanup() + { + Debug.WriteLine("ClassCleanup"); + } + + [TestInitialize] + public void TestInitialize() + { + Debug.WriteLine("TestInitialize"); + } + + [TestCleanup] + public void TestCleanup() + { + Debug.WriteLine("TestCleanup"); + } + + [TestMethod] + public void TestMethod() + { + Assert.IsTrue(true); + } +} diff --git a/HyperVExtension/test/HyperVExtension/Usings.cs b/HyperVExtension/test/HyperVExtension/Usings.cs new file mode 100644 index 0000000000..b5ac66bdaf --- /dev/null +++ b/HyperVExtension/test/HyperVExtension/Usings.cs @@ -0,0 +1,5 @@ +// Copyright (c) Microsoft Corporation. +// Licensed under the MIT License. + +global using Microsoft.VisualStudio.TestTools.UnitTesting; +global using Microsoft.VisualStudio.TestTools.UnitTesting.AppContainer; diff --git a/Test.ps1 b/Test.ps1 index 2d11060946..ad9bd162eb 100644 --- a/Test.ps1 +++ b/Test.ps1 @@ -1,4 +1,4 @@ -Param( +param ( [string]$Platform = "x64", [string]$Configuration = "debug", [switch]$IsAzurePipelineBuild = $false, @@ -33,118 +33,118 @@ Options: -Help Display this usage message. "@ - Exit + Exit } $env:Build_SourcesDirectory = (Split-Path $MyInvocation.MyCommand.Path) $env:Build_Platform = $Platform.ToLower() $env:Build_Configuration = $Configuration.ToLower() -$vstestPath = &"${env:ProgramFiles(x86)}\Microsoft Visual Studio\Installer\vswhere.exe" -latest -prerelease -products * -find **\TestPlatform\vstest.console.exe +$vstestPath = &"${env:ProgramFiles(x86)}\Microsoft Visual Studio\Installer\vswhere.exe" -latest -prerelease -products * -find '**\TestPlatform\vstest.console.exe' $ErrorActionPreference = "Stop" -$isInstalled = Get-ChildItem HKLM:\SOFTWARE\$_\Microsoft\Windows\CurrentVersion\Uninstall\ | ? {($_.GetValue("DisplayName")) -like "*Windows Application Driver*"} - -if (-not($IsAzurePipelineBuild)) { - if ($isInstalled){ - Write-Host "WinAppDriver is already installed on this computer." - } - else { - Write-Host "WinAppDriver will be installed in the background." - $url = "https://github.com/microsoft/WinAppDriver/releases/download/v1.2.99/WindowsApplicationDriver-1.2.99-win-x64.exe" - $outpath = "$env:Build_SourcesDirectory\temp" - if (-not(Test-Path -Path $outpath)) { - New-Item -ItemType Directory -Path $outpath | Out-Null - } - Invoke-WebRequest -Uri $url -OutFile "$env:Build_SourcesDirectory\temp\WinAppDriverx64.exe" +$isInstalled = Get-ItemProperty -Path HKLM:\SOFTWARE\Microsoft\Windows\CurrentVersion\Uninstall\* | Where-Object { $_.DisplayName -like "*Windows Application Driver*" } + +if (-not $IsAzurePipelineBuild) { + if ($isInstalled) { + Write-Host "WinAppDriver is already installed on this computer." + } else { + Write-Host "WinAppDriver will be installed in the background." + $url = "https://github.com/microsoft/WinAppDriver/releases/download/v1.2.99/WindowsApplicationDriver-1.2.99-win-x64.exe" + $outpath = Join-Path $env:Build_SourcesDirectory "temp" + + if (-not (Test-Path -Path $outpath)) { + New-Item -ItemType Directory -Path $outpath | Out-Null + } + + Invoke-WebRequest -Uri $url -OutFile (Join-Path $outpath "WinAppDriverx64.exe") - Start-Process -Wait -Filepath $env:Build_SourcesDirectory\temp\WinAppDriverx64.exe -ArgumentList "/S" -PassThru - } + Start-Process -Wait -FilePath (Join-Path $env:Build_SourcesDirectory "temp\WinAppDriverx64.exe") -ArgumentList "/S" -PassThru + } - Start-Process -FilePath "C:\Program Files\Windows Application Driver\WinAppDriver.exe" + Start-Process -FilePath "C:\Program Files\Windows Application Driver\WinAppDriver.exe" } -Function ShutDownTests { - if (-not($IsAzurePipelineBuild)) { - Stop-Process -Name "WinAppDriver" - } - - $TotalTime = (Get-Date)-$StartTime - $TotalMinutes = [math]::Floor($TotalTime.TotalMinutes) - $TotalSeconds = [math]::Ceiling($TotalTime.TotalSeconds) +function ShutDownTests { + if (-not $IsAzurePipelineBuild) { + Stop-Process -Name "WinAppDriver" -ErrorAction SilentlyContinue + } - Write-Host @" + $TotalTime = (Get-Date) - $StartTime + $TotalMinutes = [math]::Floor($TotalTime.TotalMinutes) + $TotalSeconds = [math]::Ceiling($TotalTime.TotalSeconds) - Total Running Time: - $TotalMinutes minutes and $TotalSeconds seconds -"@ -ForegroundColor CYAN + Write-Host @" + Total Running Time: + $TotalMinutes minutes and $TotalSeconds seconds +"@ -ForegroundColor Cyan } -if (-not(Test-Path -Path "AppxPackages")) { - Write-Host "Nothing to test. Ensure you have built via the command line before running tests. Exiting." -ForegroundColor YELLOW - Exit 1 +if (-not (Test-Path -Path "AppxPackages")) { + Write-Host "Nothing to test. Ensure you have built via the command line before running tests. Exiting." -ForegroundColor Yellow + Exit 1 } -Try { - foreach ($platform in $env:Build_Platform.Split(",")) { - foreach ($configuration in $env:Build_Configuration.Split(",")) { - # TODO: UI tests are currently disabled in pipeline until signing is solved - if (-not($IsAzurePipelineBuild)) { - $DevHomePackage = Get-AppPackage "Microsoft.DevHome" - if ($DevHomePackage) { - Write-Host "Uninstalling old Dev Home" - Remove-AppPackage -Package $DevHomePackage.PackageFullName - } - Write-Host "Installing Dev Home" - Add-AppPackage "AppxPackages\$configuration\DevHome-$platform.msix" - } - - $vstestArgs = @( - ("/Platform:$platform"), - ("/Logger:trx;LogFileName=DevHome.Test-$platform-$configuration.trx"), - ("test\bin\$platform\$configuration\net8.0-windows10.0.22000.0\DevHome.Test.dll") - ) - $winAppTestArgs = @( - ("/Platform:$platform"), - ("/Logger:trx;LogFileName=DevHome.UITest-$platform-$configuration.trx"), - ("uitest\bin\$platform\$configuration\net8.0-windows10.0.22000.0\DevHome.UITest.dll") - ) - - & $vstestPath $vstestArgs - # TODO: UI tests are currently disabled in pipeline until signing is solved - if (-not($IsAzurePipelineBuild)) { - & $vstestPath $winAppTestArgs - } - - foreach ($toolPath in (Get-ChildItem "tools")) { - $tool = $toolPath.Name - $vstestArgs = @( - ("/Platform:$platform"), - ("/Logger:trx;LogFileName=$tool.Test-$platform-$configuration.trx"), - ("tools\$tool\*UnitTest\bin\$platform\$configuration\net8.0-windows10.0.22000.0\*.UnitTest.dll") - ) - - $winAppTestArgs = @( - ("/Platform:$platform"), - ("/Logger:trx;LogFileName=$tool.UITest-$platform-$configuration.trx"), - ("tools\$tool\*UITest\bin\$platform\$configuration\net8.0-windows10.0.22000.0\*.UITest.dll") - ) - - & $vstestPath $vstestArgs - # TODO: UI tests are currently disabled in pipeline until signing is solved - if (-not($IsAzurePipelineBuild)) { - & $vstestPath $winAppTestArgs +try { + foreach ($platform in $env:Build_Platform.Split(",")) { + foreach ($configuration in $env:Build_Configuration.Split(",")) { + # TODO: UI tests are currently disabled in the pipeline until signing is solved + if (-not $IsAzurePipelineBuild) { + $DevHomePackage = Get-AppPackage "Microsoft.DevHome" -ErrorAction SilentlyContinue + if ($DevHomePackage) { + Write-Host "Uninstalling old Dev Home" + Remove-AppPackage -Package $DevHomePackage.PackageFullName + } + Write-Host "Installing Dev Home" + Add-AppPackage (Join-Path "AppxPackages" "$configuration\DevHome-$platform.msix") + } + + $vstestArgs = @( + "/Platform:$platform", + "/Logger:trx;LogFileName=DevHome.Test-$platform-$configuration.trx", + "test\bin\$platform\$configuration\net8.0-windows10.0.22000.0\DevHome.Test.dll" + ) + $winAppTestArgs = @( + "/Platform:$platform", + "/Logger:trx;LogFileName=DevHome.UITest-$platform-$configuration.trx", + "uitest\bin\$platform\$configuration\net8.0-windows10.0.22000.0\DevHome.UITest.dll" + ) + + & $vstestPath $vstestArgs + # TODO: UI tests are currently disabled in the pipeline until signing is solved + if (-not $IsAzurePipelineBuild) { + & $vstestPath $winAppTestArgs + } + + foreach ($toolPath in (Get-ChildItem "tools")) { + $tool = $toolPath.Name + $vstestArgs = @( + "/Platform:$platform", + "/Logger:trx;LogFileName=$tool.Test-$platform-$configuration.trx", + "tools\$tool\*UnitTest\bin\$platform\$configuration\net8.0-windows10.0.22000.0\*.UnitTest.dll" + ) + + $winAppTestArgs = @( + "/Platform:$platform", + "/Logger:trx;LogFileName=$tool.UITest-$platform-$configuration.trx", + "tools\$tool\*UITest\bin\$platform\$configuration\net8.0-windows10.0.22000.0\*.UITest.dll" + ) + + & $vstestPath $vstestArgs + # TODO: UI tests are currently disabled in the pipeline until signing is solved + if (-not $IsAzurePipelineBuild) { + & $vstestPath $winAppTestArgs + } + } } - } } - } -} Catch { - $formatString = "`n{0}`n`n{1}`n`n" - $fields = $_, $_.ScriptStackTrace - Write-Host ($formatString -f $fields) -ForegroundColor RED - ShutDownTests - Exit 1 +} catch { + $formatString = "`n{0}`n`n{1}`n`n" + $fields = $_, $_.ScriptStackTrace + Write-Host ($formatString -f $fields) -ForegroundColor Red + ShutDownTests + Exit 1 } ShutDownTests diff --git a/build/azure-pipelines.yml b/build/azure-pipelines.yml index c15647ff2d..76bd2ac4c3 100644 --- a/build/azure-pipelines.yml +++ b/build/azure-pipelines.yml @@ -21,8 +21,8 @@ parameters: variables: # MSIXVersion's second part should always be odd to account for stub app's version - MSIXVersion: '0.1101' - VersionOfSDK: '0.100' + MSIXVersion: '0.1201' + VersionOfSDK: '0.200' solution: '**/DevHome.sln' appxPackageDir: 'AppxPackages' testOutputArtifactDir: 'TestResults' @@ -208,7 +208,7 @@ extends: retryCountOnTaskFailure: 2 inputs: filePath: 'Build.ps1' - arguments: -Platform "${{ platform }}" -Configuration "${{ configuration }}" -Version $(MSIXVersion) -BuildStep "msix" -AzureBuildingBranch "$(BuildingBranch)" -IsAzurePipelineBuild + arguments: -Platform "${{ platform }}" -Configuration "${{ configuration }}" -Version $(MSIXVersion) -BuildStep "fullMsix" -AzureBuildingBranch "$(BuildingBranch)" -IsAzurePipelineBuild - task: EsrpCodeSigning@2 inputs: diff --git a/build/scripts/CreateBuildInfo.ps1 b/build/scripts/CreateBuildInfo.ps1 index dea9b45048..b4c72db5c4 100644 --- a/build/scripts/CreateBuildInfo.ps1 +++ b/build/scripts/CreateBuildInfo.ps1 @@ -6,7 +6,7 @@ Param( ) $Major = "0" -$Minor = "11" +$Minor = "12" $Patch = "99" # default to 99 for local builds $versionSplit = $Version.Split("."); diff --git a/build/scripts/Unstub.ps1 b/build/scripts/Unstub.ps1 index e2a68f64e8..b79e3d83aa 100644 --- a/build/scripts/Unstub.ps1 +++ b/build/scripts/Unstub.ps1 @@ -5,13 +5,20 @@ Remove-Item "$($PSScriptRoot)\..\..\telemetry\DevHome.Telemetry\TelemetryEventSo $projFile = "$($PSScriptRoot)\..\..\telemetry\DevHome.Telemetry\DevHome.Telemetry.csproj" $projFileContent = Get-Content $projFile -Encoding UTF8 -Raw +$xml = [xml]$projFileContent +$xml.PreserveWhitespace = $true + +$defineConstantsNode = $xml.SelectSingleNode("//DefineConstants") +if ($defineConstantsNode -ne $null) { + $defineConstantsNode.ParentNode.RemoveChild($defineConstantsNode) + $xml.Save($projFile) +} + if ($projFileContent.Contains('Microsoft.Telemetry.Inbox.Managed')) { Write-Output "Project file already contains a reference to the internal package." return; } -$xml = [xml]$projFileContent -$xml.PreserveWhitespace = $true $packageReferenceNode = $xml.CreateElement("PackageReference"); $packageReferenceNode.SetAttribute("Include", "Microsoft.Telemetry.Inbox.Managed") $packageReferenceNode.SetAttribute("Version", "10.0.25148.1001-220626-1600.rs-fun-deploy-dev5") diff --git a/codeAnalysis/GlobalSuppressions.cs b/codeAnalysis/GlobalSuppressions.cs index 4855e1bc5a..19eeee6190 100644 --- a/codeAnalysis/GlobalSuppressions.cs +++ b/codeAnalysis/GlobalSuppressions.cs @@ -55,6 +55,3 @@ // Code quality [assembly: SuppressMessage("CodeQuality", "IDE0076:Invalid global 'SuppressMessageAttribute'", Justification = "Affect predefined suppressions.")] - -// Generated code -[assembly: SuppressMessage("StyleCop.CSharp.DocumentationRules", "SA1636:FileHeaderCopyrightTextMustMatch", Justification = "CommunityToolkit generated files are causing analyzer error because the file header does not match the expected value.")] diff --git a/codeAnalysis/StubSuppressions.cs b/codeAnalysis/StubSuppressions.cs new file mode 100644 index 0000000000..264be0531a --- /dev/null +++ b/codeAnalysis/StubSuppressions.cs @@ -0,0 +1,63 @@ +// Copyright (c) Microsoft Corporation. +// Licensed under the MIT License. + +// This file is used by Code Analysis to maintain SuppressMessage +// attributes that are applied to this project. +// Project-level suppressions either have no target or are given +// a specific target and scoped to a namespace, type, member, etc. +using System.Diagnostics.CodeAnalysis; + +[assembly: SuppressMessage("StyleCop.CSharp.ReadabilityRules", "SA1101:PrefixLocalCallsWithThis", Justification = "We follow the C# Core Coding Style which avoids using `this` unless absolutely necessary.")] +[assembly: SuppressMessage("StyleCop.CSharp.ReadabilityRules", "SA1414:Tuple types in signatures should have element names", Justification = "It is not a priority and have hight impact in code changes.")] + +[assembly: SuppressMessage("StyleCop.CSharp.OrderingRules", "SA1200:UsingDirectivesMustBePlacedWithinNamespace", Justification = "We follow the C# Core Coding Style which puts using statements outside the namespace.")] +[assembly: SuppressMessage("StyleCop.CSharp.OrderingRules", "SA1201:ElementsMustAppearInTheCorrectOrder", Justification = "It is not a priority and have hight impact in code changes.")] +[assembly: SuppressMessage("StyleCop.CSharp.OrderingRules", "SA1202:ElementsMustBeOrderedByAccess", Justification = "It is not a priority and have hight impact in code changes.")] +[assembly: SuppressMessage("StyleCop.CSharp.OrderingRules", "SA1203:ConstantsMustAppearBeforeFields", Justification = "It is not a priority and have hight impact in code changes.")] +[assembly: SuppressMessage("StyleCop.CSharp.OrderingRules", "SA1204:StaticElementsMustAppearBeforeInstanceElements", Justification = "It is not a priority and have hight impact in code changes.")] + +[assembly: SuppressMessage("StyleCop.CSharp.NamingRules", "SA1309:FieldNamesMustNotBeginWithUnderscore", Justification = "We follow the C# Core Coding Style which uses underscores as prefixes rather than using `this.`.")] +[assembly: SuppressMessage("StyleCop.CSharp.NamingRules", "SA1316:Tuple element names should use correct casing", Justification = "It is not a priority and have hight impact in code changes.")] + +[assembly: SuppressMessage("StyleCop.CSharp.SpecialRules", "SA0001:XmlCommentAnalysisDisabled", Justification = "Not enabled as we don't want or need XML documentation.")] +[assembly: SuppressMessage("StyleCop.CSharp.MaintainabilityRules", "SA1402:File may only contain a single type", Justification = "It is not a priority and have hight impact in code changes.")] +[assembly: SuppressMessage("StyleCop.CSharp.DocumentationRules", "SA1629:DocumentationTextMustEndWithAPeriod", Justification = "Not enabled as we don't want or need XML documentation.")] + +[assembly: SuppressMessage("Microsoft.Design", "CA1009:DeclareEventHandlersCorrectly", Scope = "member", Target = "Microsoft.Templates.Core.Locations.TemplatesSynchronization.#SyncStatusChanged", Justification = "Using an Action does not allow the required notation")] + +// Non general suppressions +[assembly: SuppressMessage("Microsoft.Globalization", "CA1303:Do not pass literals as localized parameters", Justification = "This is part of the markdown processing", MessageId = "System.Windows.Documents.Run.#ctor(System.String)", Scope = "member", Target = "Microsoft.Templates.UI.Controls.Markdown.#ImageInlineEvaluator(System.Text.RegularExpressions.Match)")] +[assembly: SuppressMessage("Microsoft.Globalization", "CA1308:NormalizeStringsToUppercase", Justification = "We need to have the names of these keys in lowercase to be able to compare with the keys becoming form the template json. ContainsKey does not allow StringComparer specification to IgnoreCase", Scope = "member", Target = "Microsoft.Templates.Core.ITemplateInfoExtensions.#GetQueryableProperties(Microsoft.TemplateEngine.Abstractions.ITemplateInfo)")] +[assembly: SuppressMessage("Microsoft.Globalization", "CA1308:NormalizeStringsToUppercase", Justification = "We need to have the names of these keys in lowercase to be able to compare with the keys becoming form the template json. ContainsKey does not allow StringComparer specification to IgnoreCase", Scope = "member", Target = "Microsoft.Templates.Core.Composition.CompositionQuery.#Match(System.Collections.Generic.IEnumerable`1,Microsoft.Templates.Core.Composition.QueryablePropertyDictionary)")] +[assembly: SuppressMessage("Usage", "VSTHRD103:Call async methods when in an async method", Justification = "Resource DictionaryWriter does not implement flush async", Scope = "member", Target = "~M:Microsoft.Templates.Core.PostActions.Catalog.Merge.MergeResourceDictionaryPostAction.ExecuteInternalAsync~System.Threading.Tasks.Task")] +[assembly: SuppressMessage("Naming", "CA1707:Identifiers should not contain underscores", Justification = "Used in a lot of places for meaningful method names")] +[assembly: SuppressMessage("Performance", "CA1822:Mark members as static", Justification = "Static methods may improve performance but decrease maintainability")] +[assembly: SuppressMessage("Naming", "CA1711:Identifiers should not have incorrect suffix", Justification = "Renaming everything would be a lot of work. It does not do any harm if an EventHandler delegate ends with the suffix EventHandler. Besides this, the Rule causes some false positives.")] +[assembly: SuppressMessage("Performance", "CA1838:Avoid 'StringBuilder' parameters for P/Invokes", Justification = "We are not concerned about the performance impact of marshaling a StringBuilder")] +[assembly: SuppressMessage("Performance", "CA1861:Avoid constant arrays as arguments", Justification = "Constant arrays are required for DataRow", Scope = "member", Target = "~M:DevHome.Tests.UITest.WidgetTest.AddWidgetsTest(System.String[])")] + +// Threading suppressions +[assembly: SuppressMessage("Microsoft.VisualStudio.Threading.Analyzers", "VSTHRD100:Avoid async void methods", Justification = "Event handlers needs async void", Scope = "member", Target = "~M:Microsoft.Templates.UI.Controls.Notification.OnClose")] +[assembly: SuppressMessage("Microsoft.VisualStudio.Threading.Analyzers", "VSTHRD100:Avoid async void methods", Justification = "Event handlers needs async void", Scope = "member", Target = "~M:Microsoft.Templates.UI.ViewModels.Common.SavedTemplateViewModel.OnDelete")] +[assembly: SuppressMessage("Microsoft.VisualStudio.Threading.Analyzers", "VSTHRD100:Avoid async void methods", Justification = "Event handlers needs async void", Scope = "member", Target = "~M:Microsoft.Templates.UI.ViewModels.Common.WizardNavigation.GoBack")] +[assembly: SuppressMessage("Microsoft.VisualStudio.Threading.Analyzers", "VSTHRD100:Avoid async void methods", Justification = "Event handlers needs async void", Scope = "member", Target = "~M:Microsoft.Templates.UI.ViewModels.Common.WizardNavigation.GoForward")] +[assembly: SuppressMessage("Microsoft.VisualStudio.Threading.Analyzers", "VSTHRD100:Avoid async void methods", Justification = "Event handlers needs async void", Scope = "member", Target = "~M:Microsoft.Templates.UI.ViewModels.Common.SavedTemplateViewModel.OnDelete(Microsoft.Templates.UI.ViewModels.Common.SavedTemplateViewModel)")] + +// Localization suppressions +[assembly: SuppressMessage("Microsoft.Globalization", "CA1303:Do not pass literals as localized parameters", MessageId = "Microsoft.Templates.Core.Locations.JunctionNativeMethods.ThrowLastWin32Error(System.String)", Scope = "member", Target = "Microsoft.Templates.Core.Locations.JunctionNativeMethods.#CreateJunction(System.String,System.String,System.Boolean)", Justification = "Only used for local generation")] +[assembly: SuppressMessage("Microsoft.Globalization", "CA1303:Do not pass literals as localized parameters", MessageId = "Microsoft.Templates.Core.Locations.JunctionNativeMethods.ThrowLastWin32Error(System.String)", Scope = "member", Target = "Microsoft.Templates.Core.Locations.JunctionNativeMethods.#DeleteJunction(System.String)", Justification = "Only used for local generation")] +[assembly: SuppressMessage("Microsoft.Globalization", "CA1303:Do not pass literals as localized parameters", MessageId = "Microsoft.Templates.Core.Locations.JunctionNativeMethods.ThrowLastWin32Error(System.String)", Scope = "member", Target = "Microsoft.Templates.Core.Locations.JunctionNativeMethods.#InternalGetTarget(Microsoft.Win32.SafeHandles.SafeFileHandle)", Justification = "Only used for local generation")] +[assembly: SuppressMessage("Microsoft.Globalization", "CA1303:Do not pass literals as localized parameters", MessageId = "Microsoft.Templates.Core.Locations.JunctionNativeMethods.ThrowLastWin32Error(System.String)", Scope = "member", Target = "Microsoft.Templates.Core.Locations.JunctionNativeMethods.#OpenReparsePoint(System.String,Microsoft.Templates.Core.Locations.JunctionNativeMethods+EFileAccess)", Justification = "Only used for local generation")] +[assembly: SuppressMessage("Microsoft.Globalization", "CA1303:Do not pass literals as localized parameters", MessageId = "System.Windows.Documents.InlineCollection.Add(System.String)", Scope = "member", Target = "Microsoft.Templates.UI.Extensions.TextBlockExtensions.#OnSequentialFlowStepChanged(System.Windows.DependencyObject,System.Windows.DependencyPropertyChangedEventArgs)", Justification = "No text here")] + +// Uninstantiated TestFixture classes +[assembly: SuppressMessage("Microsoft.Performance", "CA1812: Avoid uninstantiated internal classes", Scope = "module", Justification = "CA1812 will be thrown for every file in the test project. This is mentioned here: dotnet/roslyn-analyzers#1830")] + +// Code quality +[assembly: SuppressMessage("CodeQuality", "IDE0076:Invalid global 'SuppressMessageAttribute'", Justification = "Affect predefined suppressions.")] + +// Generated code +[assembly: SuppressMessage( + "StyleCop.CSharp.DocumentationRules", + "SA1636:FileHeaderCopyrightTextMustMatch", + Justification = "Stub files are causing analyzer error because the file header does not match the expected value.")] diff --git a/common/Contracts/IComputeSystemService.cs b/common/Contracts/IComputeSystemService.cs new file mode 100644 index 0000000000..40948b5367 --- /dev/null +++ b/common/Contracts/IComputeSystemService.cs @@ -0,0 +1,17 @@ +// Copyright (c) Microsoft Corporation. +// Licensed under the MIT License. + +using System; +using System.Collections.Generic; +using System.Threading.Tasks; +using DevHome.Common.Environments.Models; +using DevHome.Common.Models; +using DevHome.Common.Services; +using Microsoft.Windows.DevHome.SDK; + +namespace DevHome.Common.Contracts.Services; + +public interface IComputeSystemService +{ + public Task> GetComputeSystemProvidersAsync(); +} diff --git a/common/Contracts/IWindowsIdentityService.cs b/common/Contracts/IWindowsIdentityService.cs new file mode 100644 index 0000000000..cbc931a041 --- /dev/null +++ b/common/Contracts/IWindowsIdentityService.cs @@ -0,0 +1,15 @@ +// Copyright (c) Microsoft Corporation. +// Licensed under the MIT License. + +using System; +using System.Collections.Generic; +using System.Linq; +using System.Text; +using System.Threading.Tasks; + +namespace DevHome.Common.Contracts; + +public interface IWindowsIdentityService +{ + public bool IsUserHyperVAdmin(); +} diff --git a/common/DevHome.Common.csproj b/common/DevHome.Common.csproj index 5ca7cc2426..f2c411809a 100644 --- a/common/DevHome.Common.csproj +++ b/common/DevHome.Common.csproj @@ -9,6 +9,10 @@ $(DevHomeSDKVersion) + + + + @@ -27,10 +31,10 @@ all runtime; build; native; contentfiles; analyzers; buildtransitive - - + + - + @@ -39,6 +43,8 @@ + + @@ -47,6 +53,18 @@ + + $(DefaultXamlRuntime) + + + $(DefaultXamlRuntime) + + + $(DefaultXamlRuntime) + + + $(DefaultXamlRuntime) + MSBuild:Compile diff --git a/common/Environments/Assets/EnvironmentsDefaultWallpaper.png b/common/Environments/Assets/EnvironmentsDefaultWallpaper.png new file mode 100644 index 0000000000..086ae8f588 Binary files /dev/null and b/common/Environments/Assets/EnvironmentsDefaultWallpaper.png differ diff --git a/common/Environments/Converters/CardStateColorToBrushConverter.cs b/common/Environments/Converters/CardStateColorToBrushConverter.cs new file mode 100644 index 0000000000..0ac1303b9b --- /dev/null +++ b/common/Environments/Converters/CardStateColorToBrushConverter.cs @@ -0,0 +1,39 @@ +// Copyright (c) Microsoft Corporation. +// Licensed under the MIT License. + +using System; +using DevHome.Common.Environments.Models; +using Microsoft.UI.Xaml; +using Microsoft.UI.Xaml.Data; +using Microsoft.UI.Xaml.Media; + +namespace DevHome.Common.Environments.Converters; + +/// +/// Converter to convert the CardStateColor enum value to a brush that will be displayed in the Environment Card. +/// +public class CardStateColorToBrushConverter : IValueConverter +{ + public object Convert(object value, Type targetType, object parameter, string language) + { + SolidColorBrush signalBrush = new(); + + if (value is CardStateColor status) + { + signalBrush = status switch + { + CardStateColor.Success => (SolidColorBrush)Application.Current.Resources["SystemFillColorSuccessBrush"], + CardStateColor.Neutral => (SolidColorBrush)Application.Current.Resources["SystemFillColorSolidNeutralBrush"], + CardStateColor.Caution => (SolidColorBrush)Application.Current.Resources["SystemFillColorCautionBrush"], + _ => (SolidColorBrush)Application.Current.Resources["SystemFillColorCautionBrush"], + }; + } + + return signalBrush; + } + + public object ConvertBack(object value, Type targetType, object parameter, string language) + { + throw new NotImplementedException(); + } +} diff --git a/common/Environments/Converters/CardStateToLocalizedTextConverter.cs b/common/Environments/Converters/CardStateToLocalizedTextConverter.cs new file mode 100644 index 0000000000..19c9c3672e --- /dev/null +++ b/common/Environments/Converters/CardStateToLocalizedTextConverter.cs @@ -0,0 +1,38 @@ +// Copyright (c) Microsoft Corporation. +// Licensed under the MIT License. + +using System; +using DevHome.Common.Services; +using Microsoft.UI.Xaml.Data; +using Microsoft.Windows.DevHome.SDK; + +namespace DevHome.Common.Environments.Converters; + +/// +/// Converter to convert the ComputeSystemState enum to its localized text version. +/// Note: the 'ComputeSystem' prefix should be added to every new state in the +/// resources.resw file. +/// +public class CardStateToLocalizedTextConverter : IValueConverter +{ + private static readonly StringResource _stringResource = new("DevHome.Common/Resources"); + private const string Prefix = "ComputeSystem"; + + public object Convert(object value, Type targetType, object parameter, string language) + { + var localizedText = string.Empty; + + if (value is ComputeSystemState status) + { + var localizationKey = Prefix + status; + localizedText = _stringResource.GetLocalized(localizationKey); + } + + return localizedText; + } + + public object ConvertBack(object value, Type targetType, object parameter, string language) + { + throw new NotImplementedException(); + } +} diff --git a/common/Environments/CustomControls/CardBody.xaml b/common/Environments/CustomControls/CardBody.xaml new file mode 100644 index 0000000000..5fa356c1ff --- /dev/null +++ b/common/Environments/CustomControls/CardBody.xaml @@ -0,0 +1,95 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/common/Environments/CustomControls/CardBody.xaml.cs b/common/Environments/CustomControls/CardBody.xaml.cs new file mode 100644 index 0000000000..02ace4d499 --- /dev/null +++ b/common/Environments/CustomControls/CardBody.xaml.cs @@ -0,0 +1,94 @@ +// Copyright (c) Microsoft Corporation. +// Licensed under the MIT License. + +using System; +using System.Collections.ObjectModel; +using DevHome.Common.Environments.Models; +using DevHome.Common.Windows; +using Microsoft.UI.Xaml; +using Microsoft.UI.Xaml.Controls; +using Microsoft.UI.Xaml.Media.Imaging; +using Microsoft.Windows.DevHome.SDK; + +namespace DevHome.Common.Environments.CustomControls; + +public sealed partial class CardBody : UserControl +{ + public const string DefaultCardBodyImagePath = "ms-appx:///DevHome.Common/Environments/Assets/EnvironmentsDefaultWallpaper.png"; + + public CardBody() + { + this.InitializeComponent(); + } + + public DataTemplate ActionControlTemplate + { + get => (DataTemplate)GetValue(ActionControlTemplateProperty); + set => SetValue(ActionControlTemplateProperty, value); + } + + public string ComputeSystemTitle + { + get => (string)GetValue(ComputeSystemTitleProperty); + set => SetValue(ComputeSystemTitleProperty, value); + } + + public string ComputeSystemAlternativeTitle + { + get => (string)GetValue(ComputeSystemAlternativeTitleProperty); + set => SetValue(ComputeSystemAlternativeTitleProperty, value); + } + + public BitmapImage ComputeSystemImage + { + get => (BitmapImage)GetValue(ComputeSystemImageProperty); + set => SetValue(ComputeSystemImageProperty, value); + } + + public CardStateColor StateColor + { + get => (CardStateColor)GetValue(StateColorProperty); + set => SetValue(StateColorProperty, value); + } + + public ComputeSystemState CardState + { + get => (ComputeSystemState)GetValue(CardStateProperty); + set => SetValue(CardStateProperty, value); + } + + public ObservableCollection ComputeSystemProperties + { + get => (ObservableCollection)GetValue(ComputeSystemPropertiesProperty); + set => SetValue(ComputeSystemPropertiesProperty, value); + } + + public DataTemplate ComputeSystemPropertyTemplate + { + get => (DataTemplate)GetValue(ComputeSystemPropertyTemplateProperty); + set => SetValue(ComputeSystemPropertyTemplateProperty, value); + } + + private static void OnCardBodyChanged(CardBody cardBody, BitmapImage args) + { + if (cardBody != null) + { + if (args == null) + { + cardBody.ComputeSystemImage = new BitmapImage(new Uri(DefaultCardBodyImagePath)); + return; + } + + cardBody.ComputeSystemImage = args; + } + } + + private static readonly DependencyProperty ActionControlTemplateProperty = DependencyProperty.Register(nameof(ActionControlTemplate), typeof(DataTemplate), typeof(CardBody), new PropertyMetadata(null)); + private static readonly DependencyProperty ComputeSystemTitleProperty = DependencyProperty.Register(nameof(ComputeSystemTitle), typeof(string), typeof(CardBody), new PropertyMetadata(null)); + private static readonly DependencyProperty ComputeSystemAlternativeTitleProperty = DependencyProperty.Register(nameof(ComputeSystemAlternativeTitle), typeof(string), typeof(CardBody), new PropertyMetadata(null)); + private static readonly DependencyProperty StateColorProperty = DependencyProperty.Register(nameof(StateColor), typeof(CardStateColor), typeof(CardBody), new PropertyMetadata(CardStateColor.Neutral)); + private static readonly DependencyProperty CardStateProperty = DependencyProperty.Register(nameof(CardState), typeof(ComputeSystemState), typeof(CardBody), new PropertyMetadata(ComputeSystemState.Unknown)); + private static readonly DependencyProperty ComputeSystemImageProperty = DependencyProperty.Register(nameof(ComputeSystemImage), typeof(BitmapImage), typeof(CardBody), new PropertyMetadata(new BitmapImage { UriSource = new Uri(DefaultCardBodyImagePath) }, (s, e) => OnCardBodyChanged((CardBody)s, (BitmapImage)e.NewValue))); + private static readonly DependencyProperty ComputeSystemPropertiesProperty = DependencyProperty.Register(nameof(ComputeSystemProperties), typeof(ObservableCollection), typeof(CardBody), new PropertyMetadata(null)); + private static readonly DependencyProperty ComputeSystemPropertyTemplateProperty = DependencyProperty.Register(nameof(ComputeSystemPropertyTemplate), typeof(DataTemplate), typeof(CardBody), new PropertyMetadata(null)); +} diff --git a/common/Environments/CustomControls/CardHeader.xaml b/common/Environments/CustomControls/CardHeader.xaml new file mode 100644 index 0000000000..21b3e5270e --- /dev/null +++ b/common/Environments/CustomControls/CardHeader.xaml @@ -0,0 +1,57 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/common/Environments/CustomControls/CardHeader.xaml.cs b/common/Environments/CustomControls/CardHeader.xaml.cs new file mode 100644 index 0000000000..c243ff0925 --- /dev/null +++ b/common/Environments/CustomControls/CardHeader.xaml.cs @@ -0,0 +1,38 @@ +// Copyright (c) Microsoft Corporation. +// Licensed under the MIT License. + +using Microsoft.UI.Xaml; +using Microsoft.UI.Xaml.Controls; +using Microsoft.UI.Xaml.Media.Imaging; + +namespace DevHome.Common.Environments.CustomControls; + +public sealed partial class CardHeader : UserControl +{ + public CardHeader() + { + this.InitializeComponent(); + } + + public DataTemplate ActionControlTemplate + { + get => (DataTemplate)GetValue(ActionControlTemplateProperty); + set => SetValue(ActionControlTemplateProperty, value); + } + + public string HeaderCaption + { + get => (string)GetValue(HeaderCaptionProperty); + set => SetValue(HeaderCaptionProperty, value); + } + + public BitmapImage HeaderIcon + { + get => (BitmapImage)GetValue(HeaderIconProperty); + set => SetValue(HeaderIconProperty, value); + } + + private static readonly DependencyProperty ActionControlTemplateProperty = DependencyProperty.Register(nameof(ActionControlTemplate), typeof(DataTemplate), typeof(CardHeader), new PropertyMetadata(null)); + private static readonly DependencyProperty HeaderCaptionProperty = DependencyProperty.Register(nameof(HeaderCaption), typeof(string), typeof(CardHeader), new PropertyMetadata(null)); + private static readonly DependencyProperty HeaderIconProperty = DependencyProperty.Register(nameof(HeaderIcon), typeof(BitmapImage), typeof(CardHeader), new PropertyMetadata(null)); +} diff --git a/common/Environments/Helpers/ComputeSystemHelpers.cs b/common/Environments/Helpers/ComputeSystemHelpers.cs new file mode 100644 index 0000000000..af2d977172 --- /dev/null +++ b/common/Environments/Helpers/ComputeSystemHelpers.cs @@ -0,0 +1,75 @@ +// Copyright (c) Microsoft Corporation. +// Licensed under the MIT License. + +using System; +using System.Collections.Generic; +using System.IO; +using System.Linq; +using System.Runtime.InteropServices.WindowsRuntime; +using System.Text; +using System.Threading.Tasks; +using DevHome.Common.Environments.Models; +using DevHome.Common.Helpers; +using Microsoft.UI.Xaml.Media.Imaging; +using Microsoft.Windows.DevHome.SDK; + +namespace DevHome.Common.Environments.Helpers; + +public static class ComputeSystemHelpers +{ + public static async Task GetBitmapImageAsync(ComputeSystem computeSystemWrapper) + { + try + { + var result = await computeSystemWrapper.GetComputeSystemThumbnailAsync(string.Empty); + + if ((result.Result.Status == ProviderOperationStatus.Failure) || (result.ThumbnailInBytes.Length <= 0)) + { + Log.Logger()?.ReportError($"Failed to get thumbnail for compute system {computeSystemWrapper}. Error: {result.Result.DiagnosticText}"); + + // No thumbnail available, return null so that the card will display the default image. + return null; + } + + var bitmap = new BitmapImage(); + bitmap.SetSource(result.ThumbnailInBytes.AsBuffer().AsStream().AsRandomAccessStream()); + return bitmap; + } + catch (Exception ex) + { + Log.Logger()?.ReportError($"Failed to get thumbnail for compute system {computeSystemWrapper}.", ex); + return null; + } + } + + public static async Task> GetComputeSystemPropertiesAsync(ComputeSystem computeSystemWrapper, string packageFullName) + { + var propertyList = new List(); + + try + { + var cuurentProperties = await computeSystemWrapper.GetComputeSystemPropertiesAsync(string.Empty); + foreach (var property in cuurentProperties) + { + propertyList.Add(new CardProperty(property, packageFullName)); + } + + return propertyList; + } + catch (Exception ex) + { + Log.Logger()?.ReportError($"Failed to get all properties for compute system {computeSystemWrapper}.", ex); + return propertyList; + } + } + + public static CardStateColor GetColorBasedOnState(ComputeSystemState state) + { + return state switch + { + ComputeSystemState.Running => CardStateColor.Success, + ComputeSystemState.Stopped => CardStateColor.Neutral, + _ => CardStateColor.Caution, + }; + } +} diff --git a/common/Environments/Helpers/StringResourceHelper.cs b/common/Environments/Helpers/StringResourceHelper.cs new file mode 100644 index 0000000000..af14fc599d --- /dev/null +++ b/common/Environments/Helpers/StringResourceHelper.cs @@ -0,0 +1,37 @@ +// Copyright (c) Microsoft Corporation. +// Licensed under the MIT License. + +using System; +using System.Collections.Generic; +using System.Linq; +using System.Text; +using System.Threading.Tasks; +using DevHome.Common.Helpers; +using DevHome.Common.Services; + +namespace DevHome.Common.Environments.Helpers; + +public static class StringResourceHelper +{ + private static readonly StringResource _stringResource = new("DevHome.Common/Resources"); + private const string ComputeSystemCpu = "ComputeSystemCpu"; + private const string ComputeSystemAssignedMemory = "ComputeSystemAssignedMemory"; + private const string ComputeSystemUptime = "ComputeSystemUptime"; + private const string ComputeSystemStorage = "ComputeSystemStorage"; + private const string ComputeSystemUnknownWithColon = "ComputeSystemUnknownWithColon"; + public const string UserNotInHyperAdminGroupButton = "UserNotInHyperAdminGroupButton"; + public const string UserNotInHyperAdminGroupMessage = "UserNotInHyperAdminGroupMessage"; + + public static string GetResource(string key, params object[] args) + { + try + { + return _stringResource.GetLocalized(key, args); + } + catch (Exception ex) + { + Log.Logger()?.ReportError($"Failed to get resource for key {key}.", ex); + return key; + } + } +} diff --git a/common/Environments/Models/CardProperty.cs b/common/Environments/Models/CardProperty.cs new file mode 100644 index 0000000000..1a1644b008 --- /dev/null +++ b/common/Environments/Models/CardProperty.cs @@ -0,0 +1,290 @@ +// Copyright (c) Microsoft Corporation. +// Licensed under the MIT License. + +using System; +using System.Globalization; +using System.IO; +using CommunityToolkit.Common; +using CommunityToolkit.Mvvm.ComponentModel; +using DevHome.Common.Environments.Helpers; +using DevHome.Common.Helpers; +using DevHome.Common.Services; +using Microsoft.UI.Xaml.Media.Imaging; +using Microsoft.Windows.DevHome.SDK; +using Newtonsoft.Json.Linq; +using Windows.Win32; +using Windows.Win32.Foundation; +using Windows.Win32.UI.Shell; + +namespace DevHome.Common.Environments.Models; + +/// +/// Enum value that represent additional non compute system specific actions that can be taken by +/// the user from Dev Home. +/// +public enum EnvironmentAdditionalActions +{ + PinToStart, + PinToTaskBar, +} + +/// +/// Enum values that are used to visually represent the state of a compute system in the UI. +/// +public enum CardStateColor +{ + Success, + Neutral, + Caution, +} + +public partial class CardProperty : ObservableObject +{ + private const int MaxBufferLength = 1024; + + [ObservableProperty] + private BitmapImage? _icon; + + [ObservableProperty] + private string? _title; + + [ObservableProperty] + private object? _value; + + public string PackageFullName { get; private set; } + + public CardProperty(ComputeSystemProperty property, string packageFullName) + { + Title = property.Name; + PackageFullName = packageFullName; + UpdateTitleBasedOnPropertyKind(property.Name, property.PropertyKind); + UpdateValueBasedOnPropertyKind(property.Value, property.PropertyKind); + + if (property.Icon != null) + { + Icon = ConvertMsResourceToIcon(property.Icon, packageFullName); + } + } + + public void UpdateValueBasedOnPropertyKind(object? value, ComputeSystemPropertyKind propertyKind) + { + switch (propertyKind) + { + case ComputeSystemPropertyKind.AssignedMemorySizeInBytes: + case ComputeSystemPropertyKind.StorageSizeInBytes: + Value = ConvertBytesToString(value) ?? "-"; + break; + case ComputeSystemPropertyKind.UptimeIn100ns: + Value = Convert100nsToString(value as TimeSpan?) ?? "-"; + break; + + // for generic and cpu count cases. + default: + Value = ConvertObjectToString(value) ?? "-"; + break; + } + } + + public void UpdateTitleBasedOnPropertyKind(string title, ComputeSystemPropertyKind propertyKind) + { + switch (propertyKind) + { + case ComputeSystemPropertyKind.CpuCount: + Title = StringResourceHelper.GetResource("ComputeSystemCpu"); + break; + case ComputeSystemPropertyKind.AssignedMemorySizeInBytes: + Title = StringResourceHelper.GetResource("ComputeSystemAssignedMemory"); + break; + case ComputeSystemPropertyKind.StorageSizeInBytes: + Title = StringResourceHelper.GetResource("ComputeSystemStorage"); + break; + case ComputeSystemPropertyKind.UptimeIn100ns: + Title = StringResourceHelper.GetResource("ComputeSystemUptime"); + break; + + // for generic + default: + Title = string.IsNullOrEmpty(title) ? StringResourceHelper.GetResource("ComputeSystemUnknownWithColon") : title + ":"; + break; + } + } + + /// + /// Converts a passed in ms-resource URI and package full name to a BitmapImage. + /// + /// the ms-resource:// path to an image resource in an app packages pri file. + /// The bitmap image that represents the icon. + public static unsafe BitmapImage ConvertMsResourceToIcon(Uri iconPathUri, string packageFullName) + { + try + { + var indirectPathToResource = "@{" + packageFullName + "? " + iconPathUri.AbsoluteUri + "}"; + Span outputBuffer = new char[MaxBufferLength]; + + fixed (char* outBufferPointer = outputBuffer) + { + fixed (char* resourcePathPointer = indirectPathToResource) + { + var res = PInvoke.SHLoadIndirectString(resourcePathPointer, new PWSTR(outBufferPointer), (uint)outputBuffer.Length, null); + if (res.Succeeded) + { + var iconImageLocation = new string(outputBuffer.TrimEnd('\0')); + + if (File.Exists(iconImageLocation)) + { + var bitmap = new BitmapImage(); + bitmap.UriSource = new Uri(iconImageLocation); + return bitmap; + } + } + + Log.Logger()?.ReportError($"Failed to find icon image in path: {iconPathUri} for package: {packageFullName} due to error: 0x{res.Value:X}"); + } + } + } + catch (Exception ex) + { + Log.Logger()?.ReportError($"Failed to load icon from ms-resource: {iconPathUri} for package: {packageFullName} due to error:", ex); + } + + return new BitmapImage(); + } + + public string? Convert100nsToString(TimeSpan? timeSpan) + { + if (timeSpan == null) + { + return null; + } + + return timeSpan.Value.ToString("g", CultureInfo.CurrentCulture); + } + + /// + /// Convert bytes to localized string. + /// + /// value in bytes/> + /// Localized string in Mb, Gb or Tb. + public string ConvertBytesToString(object? size) + { + try + { + if (size == null) + { + return string.Empty; + } + + var sizeInBytes = Convert.ToUInt64(size, CultureInfo.CurrentCulture); + + unsafe + { + // 15 characters + null terminator. + var buffer = new string(' ', 16); + fixed (char* tempPath = buffer) + { + var result = + PInvoke.StrFormatByteSizeEx( + sizeInBytes, + SFBS_FLAGS.SFBS_FLAGS_TRUNCATE_UNDISPLAYED_DECIMAL_DIGITS, + tempPath, + PInvoke.MAX_PATH); + if (result != 0) + { + // fallback to using community toolkit which shows this unlocalized. In the form of 50 GB, 40 TB etc. + return CommunityToolkit.Common.Converters.ToFileSizeString((long)sizeInBytes); + } + else + { + return buffer.Trim(); + } + } + } + } + catch (Exception ex) + { + Log.Logger()?.ReportError($"Failed to convert size in bytes to ulong. Error: {ex}"); + return string.Empty; + } + } + + /// + /// Attempt to find an object's type and convert it to that type and then to a string. + /// Only a few types are supported. More can be added as needed. + /// + /// value returned by an extension + public string ConvertObjectToString(object? value) + { + if (value == null) + { + return "-"; + } + + var type = value.GetType(); + if (type == typeof(string)) + { + return value.ToString() ?? "-"; + } + else if (type == typeof(int)) + { + return ((int)value).ToString(CultureInfo.CurrentCulture); + } + else if (type == typeof(uint)) + { + return ((uint)value).ToString(CultureInfo.CurrentCulture); + } + else if (type == typeof(long)) + { + return ((long)value).ToString(CultureInfo.CurrentCulture); + } + else if (type == typeof(ulong)) + { + return ((ulong)value).ToString(CultureInfo.CurrentCulture); + } + else if (type == typeof(short)) + { + return ((short)value).ToString(CultureInfo.CurrentCulture); + } + else if (type == typeof(ushort)) + { + return ((ushort)value).ToString(CultureInfo.CurrentCulture); + } + else if (type == typeof(byte)) + { + return ((byte)value).ToString(CultureInfo.CurrentCulture); + } + else if (type == typeof(sbyte)) + { + return ((sbyte)value).ToString(CultureInfo.CurrentCulture); + } + else if (type == typeof(float)) + { + return ((float)value).ToString(CultureInfo.CurrentCulture); + } + else if (type == typeof(double)) + { + return ((double)value).ToString(CultureInfo.CurrentCulture); + } + else if (type == typeof(decimal)) + { + return ((decimal)value).ToString(CultureInfo.CurrentCulture); + } + else if (type == typeof(bool)) + { + return ((bool)value).ToString(CultureInfo.CurrentCulture); + } + else if (type == typeof(DateTime)) + { + return ((DateTime)value).ToString(CultureInfo.CurrentCulture); + } + else if (type == typeof(TimeSpan)) + { + return ((TimeSpan)value).ToString("g", CultureInfo.CurrentCulture); + } + else if (type == typeof(Guid)) + { + return ((Guid)value).ToString(); + } + + return "-"; + } +} diff --git a/common/Environments/Models/ComputeSystem.cs b/common/Environments/Models/ComputeSystem.cs new file mode 100644 index 0000000000..e448529578 --- /dev/null +++ b/common/Environments/Models/ComputeSystem.cs @@ -0,0 +1,307 @@ +// Copyright (c) Microsoft Corporation. +// Licensed under the MIT License. + +using System; +using System.Collections.Generic; +using System.Globalization; +using System.Linq; +using System.Text; +using System.Threading.Tasks; +using DevHome.Common.Environments.Helpers; +using DevHome.Common.Helpers; +using DevHome.Common.Services; +using Microsoft.Windows.DevHome.SDK; +using Windows.Foundation; + +namespace DevHome.Common.Environments.Models; + +/// +/// Wrapper class for the IComputeSystem interface that can be used throughout the application. +/// Note: Additional methods added to this class should be wrapped in try/catch blocks to ensure that +/// exceptions don't bubble up to the caller as the methods are cross proc COM calls. +/// +public class ComputeSystem +{ + private readonly string errorString; + + private readonly string _componentName = "ComputeSystem"; + + private readonly IComputeSystem _computeSystem; + + public string? Id { get; private set; } = string.Empty; + + public string DisplayName { get; private set; } = string.Empty; + + public ComputeSystemOperations SupportedOperations { get; private set; } + + public string SupplementalDisplayName { get; private set; } = string.Empty; + + public IDeveloperId AssociatedDeveloperId { get; private set; } + + public string? AssociatedProviderId { get; private set; } = string.Empty; + + public ComputeSystem(IComputeSystem computeSystem) + { + _computeSystem = computeSystem; + Id = new string(computeSystem.Id); + DisplayName = new string(computeSystem.DisplayName); + SupportedOperations = computeSystem.SupportedOperations; + SupplementalDisplayName = new string(computeSystem.SupplementalDisplayName); + AssociatedDeveloperId = computeSystem.AssociatedDeveloperId; + AssociatedProviderId = new string(computeSystem.AssociatedProviderId); + _computeSystem.StateChanged += OnComputeSystemStateChanged; + errorString = StringResourceHelper.GetResource("ComputeSystemUnexpectedError", DisplayName); + } + + public event TypedEventHandler StateChanged = (sender, state) => { }; + + public void OnComputeSystemStateChanged(object? sender, ComputeSystemState state) + { + try + { + Log.Logger()?.ReportInfo(_componentName, $"Compute System State Changed for: {Id} to {state}"); + StateChanged(this, state); + } + catch (Exception ex) + { + Log.Logger()?.ReportError(_componentName, $"OnComputeSystemStateChanged for: {this} failed due to exception", ex); + } + } + + public async Task GetStateAsync() + { + try + { + return await _computeSystem.GetStateAsync(); + } + catch (Exception ex) + { + Log.Logger()?.ReportError(_componentName, $"GetStateAsync for: {this} failed due to exception", ex); + return new ComputeSystemStateResult(ex, errorString, ex.Message); + } + } + + public async Task StartAsync(string options) + { + try + { + return await _computeSystem.StartAsync(options); + } + catch (Exception ex) + { + Log.Logger()?.ReportError(_componentName, $"StartAsync for: {this} failed due to exception", ex); + return new ComputeSystemOperationResult(ex, errorString, ex.Message); + } + } + + public async Task ShutDownAsync(string options) + { + try + { + return await _computeSystem.ShutDownAsync(options); + } + catch (Exception ex) + { + Log.Logger()?.ReportError(_componentName, $"ShutDownAsync for: {this} failed due to exception", ex); + return new ComputeSystemOperationResult(ex, errorString, ex.Message); + } + } + + public async Task RestartAsync(string options) + { + try + { + return await _computeSystem.RestartAsync(options); + } + catch (Exception ex) + { + Log.Logger()?.ReportError(_componentName, $"RestartAsync for: {this} failed due to exception", ex); + return new ComputeSystemOperationResult(ex, errorString, ex.Message); + } + } + + public async Task TerminateAsync(string options) + { + try + { + return await _computeSystem.TerminateAsync(options); + } + catch (Exception ex) + { + Log.Logger()?.ReportError(_componentName, $"TerminateAsync for: {this} failed due to exception", ex); + return new ComputeSystemOperationResult(ex, errorString, ex.Message); + } + } + + public async Task DeleteAsync(string options) + { + try + { + return await _computeSystem.DeleteAsync(options); + } + catch (Exception ex) + { + Log.Logger()?.ReportError(_componentName, $"DeleteAsync for: {this} failed due to exception", ex); + return new ComputeSystemOperationResult(ex, errorString, ex.Message); + } + } + + public async Task SaveAsync(string options) + { + try + { + return await _computeSystem.SaveAsync(options); + } + catch (Exception ex) + { + Log.Logger()?.ReportError(_componentName, $"SaveAsync for: {this} failed due to exception", ex); + return new ComputeSystemOperationResult(ex, errorString, ex.Message); + } + } + + public async Task PauseAsync(string options) + { + try + { + return await _computeSystem.PauseAsync(options); + } + catch (Exception ex) + { + Log.Logger()?.ReportError(_componentName, $"PauseAsync for: {this} failed due to exception", ex); + return new ComputeSystemOperationResult(ex, errorString, ex.Message); + } + } + + public async Task ResumeAsync(string options) + { + try + { + return await _computeSystem.ResumeAsync(options); + } + catch (Exception ex) + { + Log.Logger()?.ReportError(_componentName, $"ResumeAsync for: {this} failed due to exception", ex); + return new ComputeSystemOperationResult(ex, errorString, ex.Message); + } + } + + public async Task CreateSnapshotAsync(string options) + { + try + { + return await _computeSystem.CreateSnapshotAsync(options); + } + catch (Exception ex) + { + Log.Logger()?.ReportError(_componentName, $"CreateSnapshotAsync for: {this} failed due to exception", ex); + return new ComputeSystemOperationResult(ex, errorString, ex.Message); + } + } + + public async Task RevertSnapshotAsync(string options) + { + try + { + return await _computeSystem.RevertSnapshotAsync(options); + } + catch (Exception ex) + { + Log.Logger()?.ReportError(_componentName, $"RevertSnapshotAsync for: {this} failed due to exception", ex); + return new ComputeSystemOperationResult(ex, errorString, ex.Message); + } + } + + public async Task DeleteSnapshotAsync(string options) + { + try + { + return await _computeSystem.DeleteSnapshotAsync(options); + } + catch (Exception ex) + { + Log.Logger()?.ReportError(_componentName, $"DeleteSnapshotAsync for: {this} failed due to exception", ex); + return new ComputeSystemOperationResult(ex, errorString, ex.Message); + } + } + + public async Task ModifyPropertiesAsync(string options) + { + try + { + return await _computeSystem.ModifyPropertiesAsync(options); + } + catch (Exception ex) + { + Log.Logger()?.ReportError(_componentName, $"ModifyPropertiesAsync for: {this} failed due to exception", ex); + return new ComputeSystemOperationResult(ex, errorString, ex.Message); + } + } + + public async Task GetComputeSystemThumbnailAsync(string options) + { + try + { + return await _computeSystem.GetComputeSystemThumbnailAsync(options); + } + catch (Exception ex) + { + Log.Logger()?.ReportError(_componentName, $"GetComputeSystemThumbnailAsync for: {this} failed due to exception", ex); + return new ComputeSystemThumbnailResult(ex, errorString, ex.Message); + } + } + + public async Task> GetComputeSystemPropertiesAsync(string options) + { + try + { + return await _computeSystem.GetComputeSystemPropertiesAsync(options); + } + catch (Exception ex) + { + Log.Logger()?.ReportError(_componentName, $"GetComputeSystemPropertiesAsync for: {this} failed due to exception", ex); + return new List(); + } + } + + public async Task ConnectAsync(string options) + { + try + { + return await _computeSystem.ConnectAsync(options); + } + catch (Exception ex) + { + Log.Logger()?.ReportError(_componentName, $"ConnectAsync for: {this} failed due to exception", ex); + return new ComputeSystemOperationResult(ex, errorString, ex.Message); + } + } + + public IApplyConfigurationOperation ApplyConfiguration(string configuration) + { + try + { + return _computeSystem.CreateApplyConfigurationOperation(configuration); + } + catch (Exception ex) + { + Log.Logger()?.ReportError(_componentName, $"ApplyConfiguration for: {this} failed due to exception", ex); + throw; + } + } + + public override string ToString() + { + StringBuilder builder = new(); + builder.AppendLine(CultureInfo.InvariantCulture, $"ComputeSystem ID: {Id} "); + builder.AppendLine(CultureInfo.InvariantCulture, $"ComputeSystem name: {DisplayName} "); + builder.AppendLine(CultureInfo.InvariantCulture, $"ComputeSystem SupplementalDisplayName: {SupplementalDisplayName} "); + builder.AppendLine(CultureInfo.InvariantCulture, $"ComputeSystem associated Provider Id : {AssociatedProviderId} "); + builder.AppendLine(CultureInfo.InvariantCulture, $"ComputeSystem associated developerId LoginId: {AssociatedDeveloperId?.LoginId} "); + builder.AppendLine(CultureInfo.InvariantCulture, $"ComputeSystem associated developerId Url: {AssociatedDeveloperId?.Url} "); + + var supportedOperations = EnumHelper.SupportedOperationsToString(SupportedOperations); + builder.AppendLine(CultureInfo.InvariantCulture, $"ComputeSystem supported operations : {string.Join(",", supportedOperations)} "); + + return builder.ToString(); + } +} diff --git a/common/Environments/Models/ComputeSystemProvider.cs b/common/Environments/Models/ComputeSystemProvider.cs new file mode 100644 index 0000000000..ee62d0c8ee --- /dev/null +++ b/common/Environments/Models/ComputeSystemProvider.cs @@ -0,0 +1,100 @@ +// Copyright (c) Microsoft Corporation. +// Licensed under the MIT License. + +using System; +using System.Collections.Generic; +using System.Globalization; +using System.Linq; +using System.Text; +using System.Threading.Tasks; +using System.Xml.Linq; +using DevHome.Common.Environments.Helpers; +using DevHome.Common.Helpers; +using DevHome.Common.Services; +using Microsoft.Windows.DevHome.SDK; +using Windows.Foundation; +using Windows.Foundation.Metadata; + +namespace DevHome.Common.Environments.Models; + +/// +/// Wrapper class for the IComputeSystemProvider interface that can be used throughout the application. +/// Note: Additional methods added to this class should be wrapped in try/catch blocks to ensure that +/// exceptions don't bubble up to the caller as the methods are cross proc COM calls. +/// +public class ComputeSystemProvider +{ + private readonly string errorString; + + private readonly string _componentName = "ComputeSystemProvider"; + + private readonly IComputeSystemProvider _computeSystemProvider; + + public string Id { get; private set; } = string.Empty; + + public string DisplayName { get; private set; } = string.Empty; + + public ComputeSystemProviderOperations SupportedOperations { get; private set; } + + public Uri Icon { get; } + + public ComputeSystemProvider(IComputeSystemProvider computeSystemProvider) + { + _computeSystemProvider = computeSystemProvider; + Id = computeSystemProvider.Id; + DisplayName = computeSystemProvider.DisplayName; + SupportedOperations = computeSystemProvider.SupportedOperations; + Icon = computeSystemProvider.Icon; + errorString = StringResourceHelper.GetResource("ComputeSystemUnexpectedError", DisplayName); + } + + public ComputeSystemAdaptiveCardResult CreateAdaptiveCardSessionForDeveloperId(IDeveloperId developerId, ComputeSystemAdaptiveCardKind sessionKind) + { + try + { + return _computeSystemProvider.CreateAdaptiveCardSessionForDeveloperId(developerId, sessionKind); + } + catch (Exception ex) + { + Log.Logger()?.ReportError(_componentName, $"CreateAdaptiveCardSessionWithDeveloperId for: {this} failed due to exception", ex); + return new ComputeSystemAdaptiveCardResult(ex, errorString, ex.Message); + } + } + + public ComputeSystemAdaptiveCardResult CreateAdaptiveCardSession(IComputeSystem computeSystem, ComputeSystemAdaptiveCardKind sessionKind) + { + try + { + return _computeSystemProvider.CreateAdaptiveCardSessionForComputeSystem(computeSystem, sessionKind); + } + catch (Exception ex) + { + Log.Logger()?.ReportError(_componentName, $"CreateAdaptiveCardSessionWithComputeSystem for: {this} failed due to exception", ex); + return new ComputeSystemAdaptiveCardResult(ex, errorString, ex.Message); + } + } + + public async Task GetComputeSystemsAsync(IDeveloperId developerId) + { + try + { + return await _computeSystemProvider.GetComputeSystemsAsync(developerId); + } + catch (Exception ex) + { + Log.Logger()?.ReportError(_componentName, $"GetComputeSystemsAsync for: {this} failed due to exception", ex); + return new ComputeSystemsResult(ex, errorString, ex.Message); + } + } + + public override string ToString() + { + StringBuilder builder = new(); + builder.AppendLine(CultureInfo.InvariantCulture, $"ComputeSystem provider ID: {Id} "); + builder.AppendLine(CultureInfo.InvariantCulture, $"ComputeSystem provider display name: {DisplayName} "); + + var supportedOperations = EnumHelper.SupportedOperationsToString(SupportedOperations); + builder.AppendLine(CultureInfo.InvariantCulture, $"ComputeSystem provider supported operations : {string.Join(", ", supportedOperations)} "); + return builder.ToString(); + } +} diff --git a/common/Environments/Models/ComputeSystemProviderDetails.cs b/common/Environments/Models/ComputeSystemProviderDetails.cs new file mode 100644 index 0000000000..dbbf70be5e --- /dev/null +++ b/common/Environments/Models/ComputeSystemProviderDetails.cs @@ -0,0 +1,29 @@ +// Copyright (c) Microsoft Corporation. +// Licensed under the MIT License. + +using System; +using System.Collections.Generic; +using System.Linq; +using System.Text; +using System.Threading.Tasks; +using DevHome.Common.Models; +using DevHome.Common.Services; +using Microsoft.Windows.DevHome.SDK; + +namespace DevHome.Common.Environments.Models; + +public class ComputeSystemProviderDetails +{ + public IExtensionWrapper ExtensionWrapper { get; set; } + + public List DeveloperIds { get; set; } + + public ComputeSystemProvider ComputeSystemProvider { get; set; } + + public ComputeSystemProviderDetails(IExtensionWrapper extension, ComputeSystemProvider provider, List developerId) + { + ExtensionWrapper = extension; + ComputeSystemProvider = provider; + DeveloperIds = developerId; + } +} diff --git a/common/Environments/Models/ComputeSystemReviewItem.cs b/common/Environments/Models/ComputeSystemReviewItem.cs new file mode 100644 index 0000000000..36fe3f4f4e --- /dev/null +++ b/common/Environments/Models/ComputeSystemReviewItem.cs @@ -0,0 +1,21 @@ +// Copyright (c) Microsoft Corporation. +// Licensed under the MIT License. + +namespace DevHome.Common.Environments.Models; + +/// +/// Class used to hold information related to a compute system that needs to be setup on the +/// machine configuration page. +/// +public class ComputeSystemReviewItem +{ + public ComputeSystem ComputeSystemToSetup { get; set; } + + public ComputeSystemProvider AssociatedProvider { get; set; } + + public ComputeSystemReviewItem(ComputeSystem computeSystemToSetup, ComputeSystemProvider associatedProvider) + { + ComputeSystemToSetup = computeSystemToSetup; + AssociatedProvider = associatedProvider; + } +} diff --git a/common/Environments/Models/ComputeSystemsLoadedData.cs b/common/Environments/Models/ComputeSystemsLoadedData.cs new file mode 100644 index 0000000000..9b8d445c22 --- /dev/null +++ b/common/Environments/Models/ComputeSystemsLoadedData.cs @@ -0,0 +1,25 @@ +// Copyright (c) Microsoft Corporation. +// Licensed under the MIT License. + +using System.Collections.Generic; +using DevHome.Common.Models; +using DevHome.Common.Services; +using Microsoft.Windows.DevHome.SDK; + +namespace DevHome.Common.Environments.Models; + +/// +/// Class used to return the results of a ComputeSystem load operation. +/// +public class ComputeSystemsLoadedData +{ + public ComputeSystemProviderDetails ProviderDetails { get; set; } + + public Dictionary DevIdToComputeSystemMap { get; set; } + + public ComputeSystemsLoadedData(ComputeSystemProviderDetails providerDetails, Dictionary devIdToComputeSystemMap) + { + ProviderDetails = providerDetails; + DevIdToComputeSystemMap = devIdToComputeSystemMap; + } +} diff --git a/common/Environments/Services/ComputeSystemManager.cs b/common/Environments/Services/ComputeSystemManager.cs new file mode 100644 index 0000000000..53f017225b --- /dev/null +++ b/common/Environments/Services/ComputeSystemManager.cs @@ -0,0 +1,93 @@ +// Copyright (c) Microsoft Corporation. +// Licensed under the MIT License. + +using System; +using System.Collections.Generic; +using System.Linq; +using System.Threading; +using System.Threading.Tasks; +using DevHome.Common.Contracts.Services; +using DevHome.Common.Environments.Models; +using DevHome.Common.Helpers; +using DevHome.Common.Models; +using DevHome.Common.Services; +using Microsoft.Windows.DevHome.SDK; +using Windows.Foundation; + +namespace DevHome.Common.Environments.Services; + +/// +/// Service thats used to get the ComputeSystems from the providers so they can be loaded into the UI. +/// This class is also used to keep track of the ComputeSystem that a configuration file will be applied to. +/// +public class ComputeSystemManager : IComputeSystemManager +{ + private readonly IComputeSystemService _computeSystemService; + + public event TypedEventHandler ComputeSystemStateChanged = (sender, state) => { }; + + // Used in the setup flow to store the ComputeSystem needed to configure. + public ComputeSystemReviewItem? ComputeSystemSetupItem { get; set; } + + public ComputeSystemManager(IComputeSystemService computeSystemService) + { + _computeSystemService = computeSystemService; + } + + /// + /// This method gets the ComputeSystems from the providers in parallel. + /// + public async Task GetComputeSystemsAsync(Func callback) + { + // Create a cancellation token that will cancel the task after 2 minute. + var cancellationTokenSource = new CancellationTokenSource(); + cancellationTokenSource.CancelAfter(TimeSpan.FromMinutes(2)); + var token = cancellationTokenSource.Token; + var computeSystemsProviderDetails = await _computeSystemService.GetComputeSystemProvidersAsync(); + + try + { + // get compute systems from providers in parallel. + await Parallel.ForEachAsync(computeSystemsProviderDetails, async (providerDetails, token) => + { + var provider = providerDetails.ComputeSystemProvider; + var devIdWrappers = new List(); + var results = new List(); + var wrapperDictionary = new Dictionary(); + + foreach (var devIdWrapper in providerDetails.DeveloperIds) + { + var result = await providerDetails.ComputeSystemProvider.GetComputeSystemsAsync(devIdWrapper.DeveloperId); + wrapperDictionary.Add(devIdWrapper, result); + results.Add(result); + } + + var loadedData = new ComputeSystemsLoadedData(providerDetails, wrapperDictionary); + await callback(loadedData); + }); + } + catch (AggregateException aggregateEx) + { + foreach (var innerEx in aggregateEx.InnerExceptions) + { + if (innerEx is TaskCanceledException) + { + Log.Logger()?.ReportError($"Failed to get retrieve all compute systems from all compute system providers due to cancellation", innerEx); + } + else + { + Log.Logger()?.ReportError($"Failed to get retrieve all compute systems from all compute system providers ", innerEx); + } + } + } + catch (Exception ex) + { + Log.Logger()?.ReportError($"Failed to get retrieve all compute systems from all compute system providers ", ex); + } + } + + public void OnComputeSystemStateChanged(ComputeSystem sender, ComputeSystemState state) + { + ComputeSystemStateChanged(sender, state); + } +} diff --git a/common/Environments/Services/IComputeSystemManager.cs b/common/Environments/Services/IComputeSystemManager.cs new file mode 100644 index 0000000000..1592d4724e --- /dev/null +++ b/common/Environments/Services/IComputeSystemManager.cs @@ -0,0 +1,26 @@ +// Copyright (c) Microsoft Corporation. +// Licensed under the MIT License. + +using System; +using System.Collections.Generic; +using System.Linq; +using System.Threading.Tasks; +using DevHome.Common.Environments.Models; +using Microsoft.Windows.DevHome.SDK; +using Windows.Foundation; + +namespace DevHome.Common.Environments.Services; + +public interface IComputeSystemManager +{ + /// + /// Gets or sets the compute system that a configuration file will be applied to. + /// + public ComputeSystemReviewItem? ComputeSystemSetupItem { get; set; } + + public Task GetComputeSystemsAsync(Func callback); + + public event TypedEventHandler ComputeSystemStateChanged; + + public void OnComputeSystemStateChanged(ComputeSystem sender, ComputeSystemState state); +} diff --git a/common/Environments/Styles/HorizontalCardStyles.xaml b/common/Environments/Styles/HorizontalCardStyles.xaml new file mode 100644 index 0000000000..7b9123729e --- /dev/null +++ b/common/Environments/Styles/HorizontalCardStyles.xaml @@ -0,0 +1,238 @@ + + + + 10 + 5 + 12 + 64 + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/common/Environments/Templates/EnvironmentsTemplates.cs b/common/Environments/Templates/EnvironmentsTemplates.cs new file mode 100644 index 0000000000..444a572ab6 --- /dev/null +++ b/common/Environments/Templates/EnvironmentsTemplates.cs @@ -0,0 +1,14 @@ +// Copyright (c) Microsoft Corporation. +// Licensed under the MIT License. + +using Microsoft.UI.Xaml; + +namespace DevHome.Common.Environments.Templates; + +public partial class EnvironmentsTemplates : ResourceDictionary +{ + public EnvironmentsTemplates() + { + this.InitializeComponent(); + } +} diff --git a/common/Environments/Templates/EnvironmentsTemplates.xaml b/common/Environments/Templates/EnvironmentsTemplates.xaml new file mode 100644 index 0000000000..4538248f20 --- /dev/null +++ b/common/Environments/Templates/EnvironmentsTemplates.xaml @@ -0,0 +1,215 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + \ No newline at end of file diff --git a/common/Helpers/EnumHelper.cs b/common/Helpers/EnumHelper.cs new file mode 100644 index 0000000000..a8fbfcc28a --- /dev/null +++ b/common/Helpers/EnumHelper.cs @@ -0,0 +1,33 @@ +// Copyright (c) Microsoft Corporation. +// Licensed under the MIT License. + +using System; +using System.Collections.Generic; +using System.Linq; +using System.Text; +using System.Threading.Tasks; +using Newtonsoft.Json.Linq; + +namespace DevHome.Common.Helpers; + +/// +/// Gets a list of supported operations for a given flag based enum variable. +/// +public static class EnumHelper +{ + public static List SupportedOperationsToString(T operations) + where T : Enum + { + var supportedOperations = new List(); + + foreach (T operation in Enum.GetValues(operations.GetType())) + { + if (operations.HasFlag(operation)) + { + supportedOperations.Add(operation.ToString()); + } + } + + return supportedOperations; + } +} diff --git a/common/Models/DeveloperIdWrapper.cs b/common/Models/DeveloperIdWrapper.cs new file mode 100644 index 0000000000..6992c78640 --- /dev/null +++ b/common/Models/DeveloperIdWrapper.cs @@ -0,0 +1,27 @@ +// Copyright (c) Microsoft Corporation. +// Licensed under the MIT License. + +using Microsoft.Windows.DevHome.SDK; + +namespace DevHome.Common.Models; + +/// +/// Wrapper class for a IDeveloperId. It cache the LoginId and Url. +/// +public class DeveloperIdWrapper +{ + public DeveloperIdWrapper(IDeveloperId developerId) + { + LoginId = developerId.LoginId; + Url = developerId.Url; + DeveloperId = developerId; + } + + public string LoginId { get; private set; } = string.Empty; + + public string Url { get; private set; } = string.Empty; + + // use this directly with caution. Only use when needing to access the + // the original IDeveloperID object. E.g to use with calls to an extensions method. + public IDeveloperId DeveloperId { get; } +} diff --git a/common/Models/EmptyDeveloperId.cs b/common/Models/EmptyDeveloperId.cs new file mode 100644 index 0000000000..cc1e2b4162 --- /dev/null +++ b/common/Models/EmptyDeveloperId.cs @@ -0,0 +1,16 @@ +// Copyright (c) Microsoft Corporation. +// Licensed under the MIT License. + +using Microsoft.Windows.DevHome.SDK; + +namespace DevHome.Common.Models; + +/// +/// Empty implementation of IDeveloperId. +/// +public class EmptyDeveloperId : IDeveloperId +{ + public string LoginId => string.Empty; + + public string Url => string.Empty; +} diff --git a/common/Models/ExperimentalFeature.cs b/common/Models/ExperimentalFeature.cs index ea44fb706e..da6b9cf36f 100644 --- a/common/Models/ExperimentalFeature.cs +++ b/common/Models/ExperimentalFeature.cs @@ -1,6 +1,6 @@ -// Copyright (c) Microsoft Corporation. -// Licensed under the MIT License. - +// Copyright (c) Microsoft Corporation. +// Licensed under the MIT License. + using System.Threading.Tasks; using CommunityToolkit.Mvvm.ComponentModel; using CommunityToolkit.Mvvm.Input; @@ -12,14 +12,14 @@ namespace DevHome.Common.Models; public partial class ExperimentalFeature : ObservableObject -{ - private readonly bool _isEnabledByDefault; - - [ObservableProperty] - private bool _isEnabled; - - public string Id { get; init; } - +{ + private readonly bool _isEnabledByDefault; + + [ObservableProperty] + private bool _isEnabled; + + public string Id { get; init; } + public bool IsVisible { get; init; } public static ILocalSettingsService? LocalSettingsService { get; set; } @@ -27,22 +27,22 @@ public partial class ExperimentalFeature : ObservableObject public ExperimentalFeature(string id, bool enabledByDefault, bool visible = true) { Id = id; - _isEnabledByDefault = enabledByDefault; - IsVisible = visible; - + _isEnabledByDefault = enabledByDefault; + IsVisible = visible; + IsEnabled = CalculateEnabled(); - } - - public bool CalculateEnabled() - { - if (LocalSettingsService!.HasSettingAsync($"ExperimentalFeature_{Id}").Result) - { - return LocalSettingsService.ReadSettingAsync($"ExperimentalFeature_{Id}").Result; - } - - return _isEnabledByDefault; - } - + } + + public bool CalculateEnabled() + { + if (LocalSettingsService!.HasSettingAsync($"ExperimentalFeature_{Id}").Result) + { + return LocalSettingsService.ReadSettingAsync($"ExperimentalFeature_{Id}").Result; + } + + return _isEnabledByDefault; + } + public string Name { get @@ -59,15 +59,17 @@ public string Description var stringResource = new StringResource("DevHome.Settings/Resources"); return stringResource.GetLocalized(Id + "_Description"); } - } - - [RelayCommand] - public async Task OnToggledAsync() - { - IsEnabled = !IsEnabled; - + } + + [RelayCommand] + public async Task OnToggledAsync() + { + IsEnabled = !IsEnabled; + await LocalSettingsService!.SaveSettingAsync($"ExperimentalFeature_{Id}", IsEnabled); - TelemetryFactory.Get().Log("RepoTool_SearchForExtensions_Event", LogLevel.Critical, new ExperimentalFeatureEvent(Id, IsEnabled)); + await LocalSettingsService!.SaveSettingAsync($"IsSeeker", true); + + TelemetryFactory.Get().Log("ExperimentalFeature_Toggled_Event", LogLevel.Critical, new ExperimentalFeatureEvent(Id, IsEnabled)); } } diff --git a/common/Models/ProviderOperationResultWrapper.cs b/common/Models/ProviderOperationResultWrapper.cs new file mode 100644 index 0000000000..c490e33a2b --- /dev/null +++ b/common/Models/ProviderOperationResultWrapper.cs @@ -0,0 +1,34 @@ +// Copyright (c) Microsoft Corporation. +// Licensed under the MIT License. + +using System; +using System.Collections.Generic; +using System.Linq; +using System.Text; +using System.Threading.Tasks; +using Microsoft.Windows.DevHome.SDK; + +namespace DevHome.Common.Models; + +/// +/// Wrapper class for the ProviderOperationResult class. This class is used to +/// when passing ProviderOperationResult objects between processes. +/// +public class ProviderOperationResultWrapper +{ + public ProviderOperationStatus Status { get; set; } + + public Exception ExtendedError { get; set; } + + public string DisplayMessage { get; set; } + + public string DiagnosticText { get; set; } + + public ProviderOperationResultWrapper(ProviderOperationResult result) + { + Status = result.Status; + ExtendedError = result.ExtendedError; + DisplayMessage = result.DisplayMessage; + DiagnosticText = result.DiagnosticText; + } +} diff --git a/common/NativeMethods.txt b/common/NativeMethods.txt index e63bd90c8a..199a3aeac6 100644 --- a/common/NativeMethods.txt +++ b/common/NativeMethods.txt @@ -7,3 +7,7 @@ GetCurrentPackageFullName SetWindowLong GetWindowLong WINDOW_EX_STYLE +SHLoadIndirectString +StrFormatByteSizeEx +SFBS_FLAGS +MAX_PATH \ No newline at end of file diff --git a/common/Renderers/AccessibleChoiceSet.cs b/common/Renderers/AccessibleChoiceSet.cs new file mode 100644 index 0000000000..0bc9102f41 --- /dev/null +++ b/common/Renderers/AccessibleChoiceSet.cs @@ -0,0 +1,44 @@ +// Copyright (c) Microsoft Corporation. +// Licensed under the MIT License. + +using System.Linq; +using AdaptiveCards.ObjectModel.WinUI3; +using AdaptiveCards.Rendering.WinUI3; +using Microsoft.UI.Xaml; +using Microsoft.UI.Xaml.Automation; +using Microsoft.UI.Xaml.Controls; + +namespace DevHome.Common.Renderers; + +public class AccessibleChoiceSet : IAdaptiveElementRenderer +{ + public UIElement Render(IAdaptiveCardElement element, AdaptiveRenderContext context, AdaptiveRenderArgs renderArgs) + { + var renderer = new AdaptiveChoiceSetInputRenderer(); + + if (element is AdaptiveChoiceSetInput choiceSet) + { + // Label property corresponds to the Header dependency property on the ComboBox. + var header = choiceSet.Label; + var placeholderText = choiceSet.Placeholder; + + // If there is no Header, there will not be an accessible Name. + // Use the Placeholder text as the accessible Name if possible. + if (string.IsNullOrEmpty(header) && !string.IsNullOrEmpty(placeholderText)) + { + var result = renderer.Render(choiceSet, context, renderArgs); + if (result is StackPanel stackPanel) + { + var comboBox = stackPanel.Children.First() as ComboBox; + if (comboBox != null) + { + AutomationProperties.SetName(comboBox, placeholderText); + return stackPanel; + } + } + } + } + + return renderer.Render(element, context, renderArgs); + } +} diff --git a/common/Services/ComputeSystemService.cs b/common/Services/ComputeSystemService.cs new file mode 100644 index 0000000000..196c7eaec4 --- /dev/null +++ b/common/Services/ComputeSystemService.cs @@ -0,0 +1,69 @@ +// Copyright (c) Microsoft Corporation. +// Licensed under the MIT License. + +using System; +using System.Collections.Generic; +using System.Linq; +using System.Threading; +using System.Threading.Tasks; +using DevHome.Common.Contracts.Services; +using DevHome.Common.Environments.Models; +using DevHome.Common.Helpers; +using DevHome.Common.Models; +using DevHome.Logging; +using Microsoft.Windows.DevHome.SDK; + +namespace DevHome.Common.Services; + +public class ComputeSystemService : IComputeSystemService +{ + private readonly IExtensionService _extensionService; + + private readonly IAccountsService _accountService; + + public ComputeSystemService(IExtensionService extensionService, IAccountsService accountService) + { + _extensionService = extensionService; + _accountService = accountService; + } + + public async Task> GetComputeSystemProvidersAsync() + { + var computeSystemProvidersFromAllExtensions = new List(); + var extensions = await _extensionService.GetInstalledExtensionsAsync(ProviderType.ComputeSystem); + foreach (var extension in extensions) + { + try + { + var computeSystemProviders = await extension.GetListOfProvidersAsync(); + var extensionObj = extension.GetExtensionObject(); + var devIdList = new List(); + if (extensionObj != null && computeSystemProviders.FirstOrDefault() != null) + { + devIdList.AddRange(_accountService.GetDeveloperIds(extensionObj).Select(id => new DeveloperIdWrapper(id))); + } + + if (devIdList.Count == 0) + { + // If we don't have a developer id for the extension, add an empty one so we can still get the compute systems. + devIdList.Add(new DeveloperIdWrapper(new EmptyDeveloperId())); + } + + // Only add non-null providers to the list. + for (var i = 0; i < computeSystemProviders.Count(); i++) + { + if (computeSystemProviders.ElementAt(i) != null) + { + computeSystemProvidersFromAllExtensions.Add(new(extension, new ComputeSystemProvider(computeSystemProviders.ElementAt(i)), devIdList)); + } + } + } + catch (Exception ex) + { + GlobalLog.Logger?.ReportError($"Failed to get {nameof(IComputeSystemProvider)} provider from '{extension.Name}'", ex); + } + } + + return computeSystemProvidersFromAllExtensions; + } +} diff --git a/common/Services/IExperimentationService.cs b/common/Services/IExperimentationService.cs index 74ba3bc43d..1daeca4ee3 100644 --- a/common/Services/IExperimentationService.cs +++ b/common/Services/IExperimentationService.cs @@ -8,9 +8,11 @@ namespace DevHome.Common.Services; public interface IExperimentationService { - bool IsFeatureEnabled(string key); - List ExperimentalFeatures { get; } + bool IsFeatureEnabled(string key); + void AddExperimentalFeature(ExperimentalFeature experimentalFeature); + + bool IsExperimentEnabled(string key); } diff --git a/common/Services/IExtensionWrapper.cs b/common/Services/IExtensionWrapper.cs index cf70eb2c47..026d36c1dc 100644 --- a/common/Services/IExtensionWrapper.cs +++ b/common/Services/IExtensionWrapper.cs @@ -2,6 +2,7 @@ // Licensed under the MIT License. using System; +using System.Collections.Generic; using System.Threading.Tasks; using Microsoft.Windows.DevHome.SDK; using Windows.ApplicationModel; @@ -118,4 +119,13 @@ public string ExtensionUniqueId /// Nullable instance of the provider Task GetProviderAsync() where T : class; + + /// + /// Starts the extension if not running and gets a list of providers of type T from the underlying IExtension object. + /// If no providers are found, returns an empty list. + /// + /// The type of provider + /// Nullable instance of the provider + Task> GetListOfProvidersAsync() + where T : class; } diff --git a/common/Services/IThemeSelectorService.cs b/common/Services/IThemeSelectorService.cs index 374fd0d036..b55fcb2fa2 100644 --- a/common/Services/IThemeSelectorService.cs +++ b/common/Services/IThemeSelectorService.cs @@ -11,10 +11,7 @@ public interface IThemeSelectorService { public event EventHandler ThemeChanged; - ElementTheme Theme - { - get; - } + ElementTheme Theme { get; } Task InitializeAsync(); @@ -27,4 +24,6 @@ ElementTheme Theme /// /// True if the current theme is dark bool IsDarkTheme(); + + ElementTheme GetActualTheme(); } diff --git a/common/Services/ToastNotificationService.cs b/common/Services/ToastNotificationService.cs new file mode 100644 index 0000000000..c8d15f6de2 --- /dev/null +++ b/common/Services/ToastNotificationService.cs @@ -0,0 +1,80 @@ +// Copyright (c) Microsoft Corporation. +// Licensed under the MIT License. + +using System; +using System.Collections.Generic; +using System.Collections.ObjectModel; +using System.Diagnostics; +using System.Linq; +using System.Text; +using System.Threading.Tasks; +using DevHome.Common.Contracts; +using DevHome.Common.Environments.Helpers; +using DevHome.Common.Helpers; +using Microsoft.Windows.AppLifecycle; +using Microsoft.Windows.AppNotifications; +using Microsoft.Windows.AppNotifications.Builder; +using Windows.ApplicationModel.Activation; +using Windows.Media.AppBroadcasting; +using WinUIEx.Messaging; + +namespace DevHome.Common.Services; + +public class ToastNotificationService +{ + private readonly IWindowsIdentityService _windowsIdentityService; + + private readonly string _componentName = "ToastNotificationService"; + + public bool WasHyperVAddToAdminGroupSuccessful { get; private set; } + + public ToastNotificationService(IWindowsIdentityService windowsIdentityService) + { + _windowsIdentityService = windowsIdentityService; + } + + public bool ShowHyperVAdminWarningToast() + { + // Temporary toast notification to inform the user that they are not in the Hyper-V admin group. + // In the future we'll use an admin process from Dev Home to add the user to the group. + var toast = new AppNotificationBuilder() + .AddText("Warning") + .AddText(StringResourceHelper.GetResource(StringResourceHelper.UserNotInHyperAdminGroupMessage)) + .AddButton(new AppNotificationButton(StringResourceHelper.GetResource(StringResourceHelper.UserNotInHyperAdminGroupButton)) + .AddArgument("action", "AddUserToHyperVAdminGroup")) + .BuildNotification(); + + AppNotificationManager.Default.Show(toast); + return toast.Id != 0; + } + + public void HandlerNotificationActions(AppActivationArguments args) + { + if (args.Data is ToastNotificationActivatedEventArgs toastArgs) + { + try + { + if (toastArgs.Argument.Contains("action=AddUserToHyperVAdminGroup")) + { + // Launch compmgmt.msc in powershell + var psi = new ProcessStartInfo(); + psi.FileName = "powershell"; + psi.Arguments = "Start-Process compmgmt.msc -Verb RunAs"; + Process.Start(psi); + } + } + catch (Exception ex) + { + Log.Logger()?.ReportError(_componentName, $"Unable to launch computer management due to exception", ex); + } + } + } + + public void CheckIfUserIsAHyperVAdmin() + { + if (!_windowsIdentityService.IsUserHyperVAdmin()) + { + ShowHyperVAdminWarningToast(); + } + } +} diff --git a/common/Services/WindowsIdentityService.cs b/common/Services/WindowsIdentityService.cs new file mode 100644 index 0000000000..26f5ad268a --- /dev/null +++ b/common/Services/WindowsIdentityService.cs @@ -0,0 +1,30 @@ +// Copyright (c) Microsoft Corporation. +// Licensed under the MIT License. + +using System; +using System.Collections.Generic; +using System.Linq; +using System.Security.Principal; +using System.Text; +using System.Threading.Tasks; +using DevHome.Common.Contracts; + +namespace DevHome.Common.Services; + +/// +/// From the Hyper-V extension. +/// Checks if the current user is part of the Hyper-V Admin group. +/// +public class WindowsIdentityService : IWindowsIdentityService +{ + private readonly WindowsIdentity _currentUserIdentity = WindowsIdentity.GetCurrent(); + + // From: https://learn.microsoft.com/windows-server/identity/ad-ds/manage/understand-security-identifiers + private const string HyperVAdminSid = "S-1-5-32-578"; + + public bool IsUserHyperVAdmin() + { + var wasHyperVSidFound = _currentUserIdentity?.Groups?.Any(sid => sid.Value == HyperVAdminSid); + return wasHyperVSidFound ?? false; + } +} diff --git a/common/Strings/en-us/Resources.resw b/common/Strings/en-us/Resources.resw index 680accc2eb..4605c949b2 100644 --- a/common/Strings/en-us/Resources.resw +++ b/common/Strings/en-us/Resources.resw @@ -1,5 +1,64 @@  + @@ -66,4 +125,92 @@ Close Name of the button that closes a view + + RAM: + Title for the ram property of a compute system + + + vCPU: + Title for the virtual cpu property of a compute system + + + Created + Text for state of the compute system when it is created + + + Creating + Text for the state of the compute system when it is being created + + + Deleted + Text for state of the compute system when it is deleted + + + Deleting + Text for the state of the compute system when it is being deleted + + + Paused + Text for state of the compute system when it is paused + + + Pausing + Text for the state of the compute system when it is being paused + + + Restarting + Text for the state of the compute system when it is restarting + + + Running + Text for state of the compute system when it is running + + + Saved + Text for state of the compute system when it is saved + + + Saving + Text for the state of the compute system when it is saving + + + Starting + Text for state of the compute system when it is starting + + + Stopped + Text for state of the compute system when it is stopped + + + Stopping + Text for state of the compute system when it is stopping + + + Storage: + Title for the storage property of the compute system. (The amount of disk space it has). + + + An unexpected error occurred while attempting to perform the operation on the {0} provider. Please see the logs for more information. + Locked={"{0}"} Text for when there was an error performing a compute system operation. . {0} is the name of the compute system + + + Unknown + Text for the state of the compute system when it is unknown + + + Unknown: + Title for a property of the compute system that is unknown. + + + Uptime: + Title for the uptime property of a compute system. (The amount of time its been running) + + + Launch computer management + Button text for the user to click, in order for us to add them to the Hyper-V admin group + + + The current user is not a Hyper-V administrator. Hyper-V Virtual machines will not load. Please add the user to the Hyper-V Administrators group and reboot. + Text explaining that the user is not in the hyper-v admin group and that we need to add them. + \ No newline at end of file diff --git a/common/TelemetryEvents/ExceptionEvent.cs b/common/TelemetryEvents/ExceptionEvent.cs index e45b12b265..5c6adb73fc 100644 --- a/common/TelemetryEvents/ExceptionEvent.cs +++ b/common/TelemetryEvents/ExceptionEvent.cs @@ -12,7 +12,7 @@ namespace DevHome.Common.TelemetryEvents; [EventData] public class ExceptionEvent : EventBase { - public override PartA_PrivTags PartA_PrivTags => PrivTags.ProductAndServiceUsage; + public override PartA_PrivTags PartA_PrivTags => PrivTags.ProductAndServicePerformance; public int HResult { get; } diff --git a/settings/DevHome.Settings/TelemetryEvents/NavigateToExtensionSettingsEvent.cs b/common/TelemetryEvents/SeekerEvent.cs similarity index 65% rename from settings/DevHome.Settings/TelemetryEvents/NavigateToExtensionSettingsEvent.cs rename to common/TelemetryEvents/SeekerEvent.cs index b892ec8ea1..d1a8cae5f7 100644 --- a/settings/DevHome.Settings/TelemetryEvents/NavigateToExtensionSettingsEvent.cs +++ b/common/TelemetryEvents/SeekerEvent.cs @@ -7,21 +7,22 @@ using Microsoft.Diagnostics.Telemetry; using Microsoft.Diagnostics.Telemetry.Internal; -namespace DevHome.Settings.TelemetryEvents; +namespace DevHome.Common.TelemetryEvents; +// A seeker is someone who has sought out experimental features or experiements [EventData] -public class NavigateToExtensionSettingsEvent : EventBase +public class SeekerEvent : EventBase { - public string StartingPage + public override PartA_PrivTags PartA_PrivTags => PrivTags.ProductAndServiceUsage; + + public bool IsSeeker { get; } - public override PartA_PrivTags PartA_PrivTags => PrivTags.ProductAndServiceUsage; - - public NavigateToExtensionSettingsEvent(string startingPage) + public SeekerEvent(bool isSeeker) { - StartingPage = startingPage; + IsSeeker = isSeeker; } public override void ReplaceSensitiveStrings(Func replaceSensitiveStrings) diff --git a/common/TelemetryEvents/SetupFlow/RepoTool/RepoConfigEvent.cs b/common/TelemetryEvents/SetupFlow/RepoTool/RepoConfigEvent.cs index 167d485f99..ed4ae99172 100644 --- a/common/TelemetryEvents/SetupFlow/RepoTool/RepoConfigEvent.cs +++ b/common/TelemetryEvents/SetupFlow/RepoTool/RepoConfigEvent.cs @@ -2,12 +2,14 @@ // Licensed under the MIT License. using System; +using System.Diagnostics.Tracing; using DevHome.Telemetry; using Microsoft.Diagnostics.Telemetry; using Microsoft.Diagnostics.Telemetry.Internal; namespace DevHome.Common.TelemetryEvents.SetupFlow.RepoTool; +[EventData] public class RepoConfigEvent : EventBase { public override PartA_PrivTags PartA_PrivTags => PrivTags.ProductAndServiceUsage; diff --git a/common/TelemetryEvents/SetupFlow/RepoTool/RepoInfoModificationEvent.cs b/common/TelemetryEvents/SetupFlow/RepoTool/RepoInfoModificationEvent.cs index 177f95d627..6debb86d9c 100644 --- a/common/TelemetryEvents/SetupFlow/RepoTool/RepoInfoModificationEvent.cs +++ b/common/TelemetryEvents/SetupFlow/RepoTool/RepoInfoModificationEvent.cs @@ -2,12 +2,14 @@ // Licensed under the MIT License. using System; +using System.Diagnostics.Tracing; using DevHome.Telemetry; using Microsoft.Diagnostics.Telemetry; using Microsoft.Diagnostics.Telemetry.Internal; namespace DevHome.Common.TelemetryEvents.SetupFlow.RepoTool; +[EventData] public class RepoInfoModificationEvent : EventBase { public override PartA_PrivTags PartA_PrivTags => PrivTags.ProductAndServiceUsage; diff --git a/common/Views/CloseButton.xaml b/common/Views/CloseButton.xaml index fc304e0e75..ffb3a11ed3 100644 --- a/common/Views/CloseButton.xaml +++ b/common/Views/CloseButton.xaml @@ -1,10 +1,7 @@ + + + + + + + + + + + + + + + + + + + + + Empty value for list since it doesn't need to use any bindings. + Empty value for list since it doesn't need to use any bindings. + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/tools/Environments/DevHome.Environments/Views/LandingPage.xaml.cs b/tools/Environments/DevHome.Environments/Views/LandingPage.xaml.cs new file mode 100644 index 0000000000..2583b386de --- /dev/null +++ b/tools/Environments/DevHome.Environments/Views/LandingPage.xaml.cs @@ -0,0 +1,89 @@ +// Copyright (c) Microsoft Corporation. +// Licensed under the MIT License. + +using System.Linq; +using System.Management.Automation.Runspaces; +using System.Threading.Tasks; +using CommunityToolkit.Mvvm.Input; +using DevHome.Common; +using DevHome.Common.Contracts; +using DevHome.Common.Extensions; +using DevHome.Common.Services; +using DevHome.Environments.Helpers; +using DevHome.Environments.ViewModels; +using Microsoft.UI.Xaml; +using Microsoft.UI.Xaml.Automation; +using Microsoft.UI.Xaml.Controls; +using Microsoft.Windows.AppNotifications; +using Microsoft.Windows.AppNotifications.Builder; +using Microsoft.Windows.DevHome.SDK; + +namespace DevHome.Environments.Views; + +public sealed partial class LandingPage : ToolPage +{ + public override string ShortName => "Environments"; + + public LandingPageViewModel ViewModel { get; } + + public LandingPage() + { + ViewModel = Application.Current.GetService(); + InitializeComponent(); + +#if DEBUG + Loaded += AddDebugButtons; +#endif + } + +#if DEBUG + private void AddDebugButtons(object sender, RoutedEventArgs e) + { + var onlyLocalButton = new Button + { + Content = "Load local testing values", + HorizontalAlignment = HorizontalAlignment.Right, + Margin = new Thickness(3, 0, 0, 0), + Command = LocalLoadButtonCommand, + }; + + SyncButtonGrid.Children.Add(onlyLocalButton); + + var onlyRemoteButton = new Button + { + Content = "Load real extension values", + HorizontalAlignment = HorizontalAlignment.Right, + Margin = new Thickness(3, 0, 0, 0), + Command = RemoteLoadButtonCommand, + }; + + SyncButtonGrid.Children.Add(onlyRemoteButton); + + var column = Grid.GetColumn(Titlebar); + Grid.SetColumn(onlyLocalButton, column + 1); + Grid.SetColumn(onlyRemoteButton, column + 2); + } + + [RelayCommand] + private async Task LocalLoadButton() + { + await ViewModel.LoadModelAsync(true); + } + + [RelayCommand] + private async Task RemoteLoadButton() + { + await ViewModel.LoadModelAsync(false); + } +#endif + + private void OnLoaded(object sender, RoutedEventArgs e) + { + if (ViewModel.HasPageLoadedForTheFirstTime) + { + return; + } + + _ = ViewModel.LoadModelAsync(false); + } +} diff --git a/tools/ExtensionLibrary/DevHome.ExtensionLibrary/Assets/extensionResult.json b/tools/ExtensionLibrary/DevHome.ExtensionLibrary/Assets/extensionResult.json index b10bb02bd6..a8a7e85bda 100644 --- a/tools/ExtensionLibrary/DevHome.ExtensionLibrary/Assets/extensionResult.json +++ b/tools/ExtensionLibrary/DevHome.ExtensionLibrary/Assets/extensionResult.json @@ -1,5 +1,5 @@ { - "ProductIds": [ "9NB9M5KZ8SLX", "9MV8F79FGXTR", "9NZCC27PR6N6" ], + "ProductIds": [ "9NB9M5KZ8SLX", "9NZ845RW19RW", "9MV8F79FGXTR", "9NZCC27PR6N6" ], "Products": [ { "LocalizedProperties": [ @@ -13,6 +13,18 @@ "PackageFamilyName": "9932MartCliment.WingetUIWidgets_g91dtg5srk15g" } }, + { + "LocalizedProperties": [ + { + "PublisherName": "Microsoft Corporation", + "ProductTitle": "Microsoft Game Dev Extension (Preview)" + } + ], + "ProductId": "9NZ845RW19RW", + "Properties": { + "PackageFamilyName": "Microsoft.DevHomeMicrosoftGameDevExtension_8wekyb3d8bbwe" + } + }, { "LocalizedProperties": [ { @@ -38,5 +50,5 @@ } } ], - "TotalResultCount": 3 + "TotalResultCount": 4 } \ No newline at end of file diff --git a/tools/ExtensionLibrary/DevHome.ExtensionLibrary/DevHome.ExtensionLibrary.csproj b/tools/ExtensionLibrary/DevHome.ExtensionLibrary/DevHome.ExtensionLibrary.csproj index 1e605ae57c..ace6f4a2dd 100644 --- a/tools/ExtensionLibrary/DevHome.ExtensionLibrary/DevHome.ExtensionLibrary.csproj +++ b/tools/ExtensionLibrary/DevHome.ExtensionLibrary/DevHome.ExtensionLibrary.csproj @@ -1,5 +1,5 @@  - + DevHome.ExtensionLibrary x86;x64;arm64 @@ -10,6 +10,7 @@ + @@ -18,7 +19,6 @@ - @@ -26,6 +26,9 @@ MSBuild:Compile + + $(DefaultXamlRuntime) + diff --git a/tools/ExtensionLibrary/DevHome.ExtensionLibrary/Extensions/ServiceExtensions.cs b/tools/ExtensionLibrary/DevHome.ExtensionLibrary/Extensions/ServiceExtensions.cs index 312f296358..7338394c05 100644 --- a/tools/ExtensionLibrary/DevHome.ExtensionLibrary/Extensions/ServiceExtensions.cs +++ b/tools/ExtensionLibrary/DevHome.ExtensionLibrary/Extensions/ServiceExtensions.cs @@ -2,6 +2,7 @@ // Licensed under the MIT License. using DevHome.ExtensionLibrary.ViewModels; +using DevHome.ExtensionLibrary.Views; using Microsoft.Extensions.DependencyInjection; using Microsoft.Extensions.Hosting; @@ -11,10 +12,12 @@ public static class ServiceExtensions { public static IServiceCollection AddExtensionLibrary(this IServiceCollection services, HostBuilderContext context) { - // View-models services.AddTransient(); services.AddTransient(); + services.AddTransient(); + services.AddTransient(); + return services; } } diff --git a/settings/DevHome.Settings/ViewModels/ExtensionSettingsViewModel.cs b/tools/ExtensionLibrary/DevHome.ExtensionLibrary/ViewModels/ExtensionSettingsViewModel.cs similarity index 67% rename from settings/DevHome.Settings/ViewModels/ExtensionSettingsViewModel.cs rename to tools/ExtensionLibrary/DevHome.ExtensionLibrary/ViewModels/ExtensionSettingsViewModel.cs index d73cabef7d..98064f92ea 100644 --- a/settings/DevHome.Settings/ViewModels/ExtensionSettingsViewModel.cs +++ b/tools/ExtensionLibrary/DevHome.ExtensionLibrary/ViewModels/ExtensionSettingsViewModel.cs @@ -2,7 +2,6 @@ // Licensed under the MIT License. using System.Collections.ObjectModel; -using System.Linq; using System.Threading.Tasks; using AdaptiveCards.Rendering.WinUI3; using CommunityToolkit.Mvvm.ComponentModel; @@ -11,28 +10,24 @@ using DevHome.Common.Views; using DevHome.Logging; using DevHome.Settings.Models; -using DevHome.Settings.Views; using Microsoft.UI.Xaml.Controls; using Microsoft.Windows.DevHome.SDK; -namespace DevHome.Settings.ViewModels; +namespace DevHome.ExtensionLibrary.ViewModels; public partial class ExtensionSettingsViewModel : ObservableObject { private readonly IExtensionService _extensionService; private readonly INavigationService _navigationService; + public ObservableCollection Breadcrumbs { get; } + public ExtensionSettingsViewModel(IExtensionService extensionService, INavigationService navigationService) { _extensionService = extensionService; _navigationService = navigationService; - Breadcrumbs = new ObservableCollection { }; - } - - public ObservableCollection Breadcrumbs - { - get; set; + Breadcrumbs = new ObservableCollection(); } [RelayCommand] @@ -73,25 +68,7 @@ private async Task OnSettingsContentLoadedAsync(ExtensionAdaptiveCardPanel exten public void FillBreadcrumbBar(string lastCrumbName) { var stringResource = new StringResource("DevHome.Settings/Resources"); - var navigationHistory = _navigationService.Frame?.BackStack; - var lastPageType = navigationHistory?.Last().SourcePageType; - - if (lastPageType == typeof(ExtensionsPage)) - { - // If the last page we came from was Settings > Extensions, add those crumbs. - Breadcrumbs.Add(new Breadcrumb(stringResource.GetLocalized("Settings_Header"), typeof(SettingsViewModel).FullName!)); - Breadcrumbs.Add(new Breadcrumb(stringResource.GetLocalized("Settings_Extensions_Header"), typeof(ExtensionsViewModel).FullName!)); - } - else - { - // If the last page we came from was the Extension page, add that crumb. - // The ViewModel name is referenced as a string because using the type directly would create a circular - // reference between Settings and ExtensionLibrary projects. - Breadcrumbs.Add(new Breadcrumb( - stringResource.GetLocalized("Settings_Extensions_Header"), - "DevHome.ExtensionLibrary.ViewModels.ExtensionLibraryViewModel")); - } - + Breadcrumbs.Add(new(stringResource.GetLocalized("Settings_Extensions_Header"), typeof(ExtensionLibraryViewModel).FullName!)); Breadcrumbs.Add(new Breadcrumb(lastCrumbName, typeof(ExtensionSettingsViewModel).FullName!)); } diff --git a/tools/ExtensionLibrary/DevHome.ExtensionLibrary/ViewModels/InstalledPackageViewModel.cs b/tools/ExtensionLibrary/DevHome.ExtensionLibrary/ViewModels/InstalledPackageViewModel.cs index 44eb578820..e0637370f6 100644 --- a/tools/ExtensionLibrary/DevHome.ExtensionLibrary/ViewModels/InstalledPackageViewModel.cs +++ b/tools/ExtensionLibrary/DevHome.ExtensionLibrary/ViewModels/InstalledPackageViewModel.cs @@ -9,8 +9,6 @@ using DevHome.Common.Contracts; using DevHome.Common.Extensions; using DevHome.Common.Services; -using DevHome.Settings.TelemetryEvents; -using DevHome.Settings.ViewModels; using DevHome.Telemetry; using Microsoft.UI.Xaml; using Windows.ApplicationModel; @@ -82,8 +80,6 @@ private bool GetIsExtensionEnabled() [RelayCommand] private void NavigateSettings() { - TelemetryFactory.Get().Log("ExtensionsSettings_Navigate_Event", LogLevel.Critical, new NavigateToExtensionSettingsEvent("InstalledExtensionViewModel")); - var navigationService = Application.Current.GetService(); navigationService.NavigateTo(typeof(ExtensionSettingsViewModel).FullName!, ExtensionUniqueId); } diff --git a/tools/ExtensionLibrary/DevHome.ExtensionLibrary/Views/ExtensionLibraryView.xaml b/tools/ExtensionLibrary/DevHome.ExtensionLibrary/Views/ExtensionLibraryView.xaml index fe34859e21..7c9d90bb48 100644 --- a/tools/ExtensionLibrary/DevHome.ExtensionLibrary/Views/ExtensionLibraryView.xaml +++ b/tools/ExtensionLibrary/DevHome.ExtensionLibrary/Views/ExtensionLibraryView.xaml @@ -39,7 +39,7 @@ - + - - - + diff --git a/settings/DevHome.Settings/Views/ExtensionSettingsPage.xaml b/tools/ExtensionLibrary/DevHome.ExtensionLibrary/Views/ExtensionSettingsPage.xaml similarity index 94% rename from settings/DevHome.Settings/Views/ExtensionSettingsPage.xaml rename to tools/ExtensionLibrary/DevHome.ExtensionLibrary/Views/ExtensionSettingsPage.xaml index a7518e689e..2058d29b62 100644 --- a/settings/DevHome.Settings/Views/ExtensionSettingsPage.xaml +++ b/tools/ExtensionLibrary/DevHome.ExtensionLibrary/Views/ExtensionSettingsPage.xaml @@ -2,7 +2,7 @@ /// Gets or sets the package id @@ -30,6 +31,14 @@ public string PackageId public string CatalogName { get; set; + } + + /// + /// Gets or sets the package version + /// + public string Version + { + get; set; } /// @@ -43,17 +52,19 @@ public static bool TryReadArguments(IList argumentList, ref int index, o { result = null; - // --package-id --package-catalog - // [ index ] [index + 1] [ index + 2 ] [index + 3] - const int TaskArgListCount = 4; + // --package-id --package-catalog --package-version + // [ index ] [index + 1] [ index + 2 ] [index + 3][ index + 4 ] [index + 5] + const int TaskArgListCount = 6; if (index + TaskArgListCount <= argumentList.Count && argumentList[index] == PackageIdArg && - argumentList[index + 2] == PackageCatalogArg) + argumentList[index + 2] == PackageCatalogArg && + argumentList[index + 4] == PackageVersionArg) { result = new InstallPackageTaskArguments { PackageId = argumentList[index + 1], - CatalogName = argumentList[index + 3], + CatalogName = argumentList[index + 3], + Version = argumentList[index + 5], }; index += TaskArgListCount; return true; @@ -68,7 +79,8 @@ public IList ToArgumentList() return new List() { PackageIdArg, PackageId, // --package-id - PackageCatalogArg, CatalogName, // --package-catalog + PackageCatalogArg, CatalogName, // --package-catalog + PackageVersionArg, Version, // --package-version }; } diff --git a/tools/SetupFlow/DevHome.SetupFlow.Common/Helpers/DscHelpers.cs b/tools/SetupFlow/DevHome.SetupFlow.Common/Helpers/DscHelpers.cs new file mode 100644 index 0000000000..4519578d23 --- /dev/null +++ b/tools/SetupFlow/DevHome.SetupFlow.Common/Helpers/DscHelpers.cs @@ -0,0 +1,28 @@ +// Copyright (c) Microsoft Corporation. +// Licensed under the MIT License. + +namespace DevHome.SetupFlow.Common.Helpers; + +/// +/// Helper class for DSC related constants. +/// +public static class DscHelpers +{ + public const string GitCloneDscResource = "GitDsc/GitClone"; + + public const string GitWinGetPackageId = "Git.Git"; + + public const string GitName = "Git"; + + public const string DscSourceNameForWinGet = "winget"; + + public const string WinGetDscResource = "Microsoft.WinGet.DSC/WinGetPackage"; + + public const string WinGetConfigureVersion = "0.2.0"; + + // Banner to be shown on top of the generated winget config file. + public const string DevHomeHeaderBanner = +@"# yaml-language-server: $schema=https://aka.ms/configuration-dsc-schema/0.2 +# Reference: https://github.com/microsoft/winget-create#building-the-client +# WinGet Configure file Generated By Dev Home."; +} diff --git a/tools/SetupFlow/DevHome.SetupFlow.Common/Helpers/Log.cs b/tools/SetupFlow/DevHome.SetupFlow.Common/Helpers/Log.cs index 07e958c412..e9f55c185d 100644 --- a/tools/SetupFlow/DevHome.SetupFlow.Common/Helpers/Log.cs +++ b/tools/SetupFlow/DevHome.SetupFlow.Common/Helpers/Log.cs @@ -41,5 +41,12 @@ public static class Component public static readonly string IPCClient = nameof(IPCClient); public static readonly string IPCServer = nameof(IPCServer); public static readonly string Elevated = nameof(Elevated); + + public static readonly string SetupTarget = nameof(SetupTarget); + public static readonly string ComputeSystemsListViewModel = nameof(ComputeSystemsListViewModel); + public static readonly string ComputeSystemCardViewModel = nameof(ComputeSystemCardViewModel); + public static readonly string ComputeSystemViewModelFactory = nameof(ComputeSystemViewModelFactory); + public static readonly string ConfigurationTarget = nameof(ConfigurationTarget); + public static readonly string SDKOpenConfigurationSetResult = nameof(SDKOpenConfigurationSetResult); } } diff --git a/tools/SetupFlow/DevHome.SetupFlow.ElevatedComponent/DevHome.SetupFlow.ElevatedComponent.csproj b/tools/SetupFlow/DevHome.SetupFlow.ElevatedComponent/DevHome.SetupFlow.ElevatedComponent.csproj index aeb301d6a4..8b9840972a 100644 --- a/tools/SetupFlow/DevHome.SetupFlow.ElevatedComponent/DevHome.SetupFlow.ElevatedComponent.csproj +++ b/tools/SetupFlow/DevHome.SetupFlow.ElevatedComponent/DevHome.SetupFlow.ElevatedComponent.csproj @@ -33,7 +33,6 @@ - diff --git a/tools/SetupFlow/DevHome.SetupFlow.ElevatedComponent/ElevatedComponentOperation.cs b/tools/SetupFlow/DevHome.SetupFlow.ElevatedComponent/ElevatedComponentOperation.cs index f8013c2016..bf21a5470a 100644 --- a/tools/SetupFlow/DevHome.SetupFlow.ElevatedComponent/ElevatedComponentOperation.cs +++ b/tools/SetupFlow/DevHome.SetupFlow.ElevatedComponent/ElevatedComponentOperation.cs @@ -51,16 +51,16 @@ public void WriteToStdOut(string value) Console.WriteLine(value); } - public IAsyncOperation InstallPackageAsync(string packageId, string catalogName) + public IAsyncOperation InstallPackageAsync(string packageId, string catalogName, string version) { - var taskArguments = GetInstallPackageTaskArguments(packageId, catalogName); + var taskArguments = GetInstallPackageTaskArguments(packageId, catalogName, version); return ValidateAndExecuteAsync( taskArguments, async () => { Log.Logger?.ReportInfo(Log.Component.Elevated, $"Installing package elevated: '{packageId}' from '{catalogName}'"); var task = new ElevatedInstallTask(); - return await task.InstallPackage(taskArguments.PackageId, taskArguments.CatalogName); + return await task.InstallPackage(taskArguments.PackageId, taskArguments.CatalogName, version); }, result => result.TaskSucceeded).AsAsyncOperation(); } @@ -120,14 +120,14 @@ public void Terminate() } } - private InstallPackageTaskArguments GetInstallPackageTaskArguments(string packageId, string catalogName) + private InstallPackageTaskArguments GetInstallPackageTaskArguments(string packageId, string catalogName, string version) { // Ensure the package to install has been pre-approved by checking against the process tasks arguments - var taskArguments = _tasksArguments.InstallPackages?.FirstOrDefault(def => def.PackageId == packageId && def.CatalogName == catalogName); + var taskArguments = _tasksArguments.InstallPackages?.FirstOrDefault(def => def.PackageId == packageId && def.CatalogName == catalogName && def.Version == version); if (taskArguments == null) { - Log.Logger?.ReportError(Log.Component.Elevated, $"No match found for PackageId={packageId} and CatalogId={catalogName} in the process tasks arguments."); - throw new ArgumentException($"Failed to install '{packageId}' from '{catalogName}' because it was not in the pre-approved tasks arguments"); + Log.Logger?.ReportError(Log.Component.Elevated, $"No match found for PackageId={packageId}, CatalogId={catalogName} and Version={version} in the process tasks arguments."); + throw new ArgumentException($"Failed to install '{packageId}' ({version}) from '{catalogName}' because it was not in the pre-approved tasks arguments"); } return taskArguments; diff --git a/tools/SetupFlow/DevHome.SetupFlow.ElevatedComponent/IElevatedComponentOperation.cs b/tools/SetupFlow/DevHome.SetupFlow.ElevatedComponent/IElevatedComponentOperation.cs index 66c954ef44..8bcd7cbba8 100644 --- a/tools/SetupFlow/DevHome.SetupFlow.ElevatedComponent/IElevatedComponentOperation.cs +++ b/tools/SetupFlow/DevHome.SetupFlow.ElevatedComponent/IElevatedComponentOperation.cs @@ -33,9 +33,10 @@ public interface IElevatedComponentOperation /// Install a package /// /// Package id - /// Package catalog name + /// Package catalog name + /// Package version /// Install package operation result - public IAsyncOperation InstallPackageAsync(string packageId, string catalogName); + public IAsyncOperation InstallPackageAsync(string packageId, string catalogName, string version); /// /// Create a dev drive diff --git a/tools/SetupFlow/DevHome.SetupFlow.ElevatedComponent/Tasks/ElevatedInstallTask.cs b/tools/SetupFlow/DevHome.SetupFlow.ElevatedComponent/Tasks/ElevatedInstallTask.cs index 5f50c1e8c4..3ee73240c4 100644 --- a/tools/SetupFlow/DevHome.SetupFlow.ElevatedComponent/Tasks/ElevatedInstallTask.cs +++ b/tools/SetupFlow/DevHome.SetupFlow.ElevatedComponent/Tasks/ElevatedInstallTask.cs @@ -35,7 +35,7 @@ public sealed class ElevatedInstallTask /// /// Installs a package given its ID and the ID of the catalog it comes from. /// - public IAsyncOperation InstallPackage(string packageId, string catalogName) + public IAsyncOperation InstallPackage(string packageId, string catalogName, string version) { return Task.Run(async () => { @@ -72,6 +72,14 @@ public IAsyncOperation InstallPackage(string packageI var installOptions = _wingetFactory.CreateInstallOptions(); installOptions.PackageInstallMode = PackageInstallMode.Silent; + if (!string.IsNullOrWhiteSpace(version)) + { + installOptions.PackageVersionId = FindVersionOrThrow(result, packageToInstall, version); + } + else + { + Log.Logger?.ReportInfo(Log.Component.AppManagement, $"Install version not specified. Falling back to default install version {packageToInstall.DefaultInstallVersion.Version}"); + } Log.Logger?.ReportInfo(Log.Component.AppManagement, $"Initiating install of package {packageId}"); var installResult = await packageManager.InstallPackageAsync(packageToInstall, installOptions); @@ -117,4 +125,30 @@ private FindPackagesOptions CreateFindOptionsForPackageId(string packageId) return findOptions; } + + /// + /// Find a specific version in the list of available versions for a package. + /// + /// Target package + /// Version to find + /// Specified version + /// Exception thrown if the specified version was not found + private PackageVersionId FindVersionOrThrow(ElevatedInstallTaskResult result, CatalogPackage package, string version) + { + // Find the version in the list of available versions + for (var i = 0; i < package.AvailableVersions.Count; i++) + { + if (package.AvailableVersions[i].Version == version) + { + return package.AvailableVersions[i]; + } + } + + var installErrorInvalidParameter = unchecked((int)0x8A150112); + result.Status = (int)InstallResultStatus.InvalidOptions; + result.ExtendedErrorCode = installErrorInvalidParameter; + var message = $"Specified install version was not found {version}."; + Log.Logger?.ReportError(Log.Component.AppManagement, message); + throw new ArgumentException(message); + } } diff --git a/tools/SetupFlow/DevHome.SetupFlow.ElevatedServer/DevHome.SetupFlow.ElevatedServer.csproj b/tools/SetupFlow/DevHome.SetupFlow.ElevatedServer/DevHome.SetupFlow.ElevatedServer.csproj index 42a6b9057b..e300be2788 100644 --- a/tools/SetupFlow/DevHome.SetupFlow.ElevatedServer/DevHome.SetupFlow.ElevatedServer.csproj +++ b/tools/SetupFlow/DevHome.SetupFlow.ElevatedServer/DevHome.SetupFlow.ElevatedServer.csproj @@ -5,7 +5,10 @@ Exe - $(SolutionDir)\src\Assets\DevHome.ico + Dev + $(SolutionDir)\src\Assets\Dev\DevHome_Dev.ico + $(SolutionDir)\src\Assets\Canary\DevHome_Canary.ico + $(SolutionDir)\src\Assets\Preview\DevHome_Preview.ico x86;x64;arm64 win10-x86;win10-x64;win10-arm64 $(SolutionDir)\src\Properties\PublishProfiles\win10-$(Platform).pubxml diff --git a/tools/SetupFlow/DevHome.SetupFlow.UnitTest/AddRepoDialogTests.cs b/tools/SetupFlow/DevHome.SetupFlow.UnitTest/AddRepoDialogTests.cs index 88d3caadae..7b2314374b 100644 --- a/tools/SetupFlow/DevHome.SetupFlow.UnitTest/AddRepoDialogTests.cs +++ b/tools/SetupFlow/DevHome.SetupFlow.UnitTest/AddRepoDialogTests.cs @@ -2,6 +2,7 @@ // Licensed under the MIT License. using DevHome.Common.Extensions; +using DevHome.Common.Services; using DevHome.SetupFlow.Models; using DevHome.SetupFlow.Services; using DevHome.SetupFlow.ViewModels; @@ -25,27 +26,35 @@ public void HideRetryBannerTest() Assert.IsTrue(addRepoViewModel.ShouldEnablePrimaryButton); } - [TestMethod] + [UITestMethod] + [Ignore("Making a new frame throws a COM exception. Running this as UITestMethod does not help")] public void SwitchToUrlScreenTest() { - var addRepoViewModel = new AddRepoViewModel(TestHost.GetService(), new List(), TestHost, Guid.NewGuid(), string.Empty, null); + var orchestrator = TestHost.GetService(); + var stringResource = TestHost.GetService(); + var devDriveManager = TestHost.GetService(); + var addRepoViewModel = new AddRepoViewModel(orchestrator, stringResource, new List(), TestHost, Guid.NewGuid(), null, devDriveManager); addRepoViewModel.ChangeToUrlPage(); - Assert.AreEqual(Visibility.Visible, addRepoViewModel.ShowUrlPage); - Assert.AreEqual(Visibility.Collapsed, addRepoViewModel.ShowAccountPage); - Assert.AreEqual(Visibility.Collapsed, addRepoViewModel.ShowRepoPage); + Assert.AreEqual(true, addRepoViewModel.ShowUrlPage); + Assert.AreEqual(false, addRepoViewModel.ShowAccountPage); + Assert.AreEqual(false, addRepoViewModel.ShowRepoPage); Assert.IsTrue(addRepoViewModel.IsUrlAccountButtonChecked); Assert.IsFalse(addRepoViewModel.IsAccountToggleButtonChecked); Assert.IsFalse(addRepoViewModel.ShouldShowLoginUi); } [TestMethod] - public void SwitchToRepoScreenTest() + [Ignore("IextensionService uses Application.Current and tests break when Application.Current is used. Ignore until fixed.")] + public async Task SwitchToAccountScreenTest() { - var addRepoViewModel = new AddRepoViewModel(TestHost.GetService(), new List(), TestHost, Guid.NewGuid(), string.Empty, null); - addRepoViewModel.ChangeToRepoPage(); - Assert.AreEqual(Visibility.Collapsed, addRepoViewModel.ShowUrlPage); - Assert.AreEqual(Visibility.Collapsed, addRepoViewModel.ShowAccountPage); - Assert.AreEqual(Visibility.Visible, addRepoViewModel.ShowRepoPage); + var orchestrator = TestHost.GetService(); + var stringResource = TestHost.GetService(); + var devDriveManager = TestHost.GetService(); + var addRepoViewModel = new AddRepoViewModel(orchestrator, stringResource, new List(), TestHost, Guid.NewGuid(), null, devDriveManager); + await addRepoViewModel.ChangeToAccountPageAsync(); + Assert.AreEqual(false, addRepoViewModel.ShowUrlPage); + Assert.AreEqual(true, addRepoViewModel.ShowAccountPage); + Assert.AreEqual(false, addRepoViewModel.ShowRepoPage); Assert.IsFalse(addRepoViewModel.ShouldShowLoginUi); } } diff --git a/tools/SetupFlow/DevHome.SetupFlow.UnitTest/BaseSetupFlowTest.cs b/tools/SetupFlow/DevHome.SetupFlow.UnitTest/BaseSetupFlowTest.cs index 717be7e0b8..6cfcab344d 100644 --- a/tools/SetupFlow/DevHome.SetupFlow.UnitTest/BaseSetupFlowTest.cs +++ b/tools/SetupFlow/DevHome.SetupFlow.UnitTest/BaseSetupFlowTest.cs @@ -3,6 +3,7 @@ using DevHome.Common.Services; using DevHome.Contracts.Services; +using DevHome.Services; using DevHome.SetupFlow.Common.WindowsPackageManager; using DevHome.SetupFlow.Services; using DevHome.SetupFlow.ViewModels; @@ -58,12 +59,14 @@ private IHost CreateTestHost() services.AddSingleton(ThemeSelectorService!.Object); services.AddSingleton(StringResource.Object); services.AddSingleton(new SetupFlowOrchestrator()); + services.AddSingleton(new ExtensionService()); // App-management view models services.AddTransient(); services.AddTransient(); services.AddTransient(); services.AddTransient(); + services.AddTransient(); // App-management services services.AddSingleton(WindowsPackageManager.Object); diff --git a/tools/SetupFlow/DevHome.SetupFlow.UnitTest/Helpers/PackageHelper.cs b/tools/SetupFlow/DevHome.SetupFlow.UnitTest/Helpers/PackageHelper.cs index 35c3b68334..e96b01eb01 100644 --- a/tools/SetupFlow/DevHome.SetupFlow.UnitTest/Helpers/PackageHelper.cs +++ b/tools/SetupFlow/DevHome.SetupFlow.UnitTest/Helpers/PackageHelper.cs @@ -17,7 +17,7 @@ public static Mock CreatePackage(string id, string catalogId = " package.Setup(p => p.Name).Returns("Mock Package Name"); package.Setup(p => p.PackageUrl).Returns(new Uri("https://packageUrl")); package.Setup(p => p.PublisherUrl).Returns(new Uri("https://publisherUrl")); - package.Setup(p => p.Version).Returns("Mock Version"); + package.Setup(p => p.InstalledVersion).Returns("Mock Version"); // Allow icon properties to be set and get like regular properties package.SetupProperty(p => p.LightThemeIcon); diff --git a/tools/SetupFlow/DevHome.SetupFlow.UnitTest/ViewModels/PackageViewModelTest.cs b/tools/SetupFlow/DevHome.SetupFlow.UnitTest/ViewModels/PackageViewModelTest.cs index 98c94cd400..072614cd26 100644 --- a/tools/SetupFlow/DevHome.SetupFlow.UnitTest/ViewModels/PackageViewModelTest.cs +++ b/tools/SetupFlow/DevHome.SetupFlow.UnitTest/ViewModels/PackageViewModelTest.cs @@ -29,7 +29,7 @@ public void CreatePackageViewModel_Success() for (var i = 0; i < expectedPackages.Count; ++i) { Assert.AreEqual(expectedPackages[i].Name, packages[i].Name); - Assert.AreEqual(expectedPackages[i].Version, packages[i].Version); + Assert.AreEqual(expectedPackages[i].InstalledVersion, packages[i].InstalledVersion); } } @@ -88,7 +88,8 @@ public void PackageDescription_VersionAndPublisherAreOptional_ReturnsExpectedDes package.Setup(p => p.CatalogId).Returns(source); package.Setup(p => p.CatalogName).Returns(source); package.Setup(p => p.PublisherName).Returns(publisher); - package.Setup(p => p.Version).Returns(version); + package.Setup(p => p.IsInstalled).Returns(true); + package.Setup(p => p.InstalledVersion).Returns(version); StringResource .Setup(sr => sr.GetLocalized(StringResourceKey.PackageDescriptionThreeParts, It.IsAny())) .Returns((string key, object[] args) => $"{args[0]} | {args[1]} | {args[2]}"); @@ -100,6 +101,6 @@ public void PackageDescription_VersionAndPublisherAreOptional_ReturnsExpectedDes var packageViewModel = TestHost.CreateInstance(package.Object); // Assert - Assert.AreEqual(expectedDescription, packageViewModel.PackageDescription); + Assert.AreEqual(expectedDescription, packageViewModel.PackageFullDescription); } } diff --git a/tools/SetupFlow/DevHome.SetupFlow.UnitTest/ViewModels/WinGetPackageJsonDataSourceTest.cs b/tools/SetupFlow/DevHome.SetupFlow.UnitTest/ViewModels/WinGetPackageJsonDataSourceTest.cs index 963773315e..22ba42511f 100644 --- a/tools/SetupFlow/DevHome.SetupFlow.UnitTest/ViewModels/WinGetPackageJsonDataSourceTest.cs +++ b/tools/SetupFlow/DevHome.SetupFlow.UnitTest/ViewModels/WinGetPackageJsonDataSourceTest.cs @@ -20,10 +20,11 @@ public void LoadCatalogs_Success_ReturnsWinGetCatalogs() // Prepare expected package var expectedPackages = new List { - PackageHelper.CreatePackage("mock1").Object, - PackageHelper.CreatePackage("mock2").Object, + PackageHelper.CreatePackage("mock1", "winget").Object, + PackageHelper.CreatePackage("mock2", "winget").Object, }; - WindowsPackageManager!.Setup(wpm => wpm.GetPackagesAsync(It.IsAny>())).ReturnsAsync(expectedPackages); + WindowsPackageManager.Setup(wpm => wpm.GetPackagesAsync(It.IsAny>())).ReturnsAsync(expectedPackages); + WindowsPackageManager.Setup(wpm => wpm.CreatePackageUri(It.IsAny())).Returns(p => new WinGetPackageUri(p.CatalogName, p.Id)); // Act var loadedPackages = LoadCatalogsFromJsonDataSource("AppManagementPackages_Success.json"); @@ -35,6 +36,8 @@ public void LoadCatalogs_Success_ReturnsWinGetCatalogs() Assert.AreEqual(expectedPackages.Count, loadedPackages[0].Packages.Count); Assert.AreEqual(expectedPackages[0].Id, loadedPackages[0].Packages.ElementAt(0).Id); Assert.AreEqual(expectedPackages[1].Id, loadedPackages[0].Packages.ElementAt(1).Id); + Assert.AreEqual(expectedPackages[0].CatalogName, loadedPackages[0].Packages.ElementAt(0).CatalogName); + Assert.AreEqual(expectedPackages[1].CatalogName, loadedPackages[0].Packages.ElementAt(1).CatalogName); } [TestMethod] @@ -42,21 +45,21 @@ public void LoadCatalogs_EmptyPackages_ReturnsNoCatalogs() { // Prepare expected package var expectedPackages = new List(); - WindowsPackageManager.Setup(wpm => wpm.GetPackagesAsync(It.IsAny>())).ReturnsAsync(expectedPackages); + WindowsPackageManager.Setup(wpm => wpm.GetPackagesAsync(It.IsAny>())).ReturnsAsync(expectedPackages); // Act var loadedPackages = LoadCatalogsFromJsonDataSource("AppManagementPackages_Empty.json"); // Assert Assert.AreEqual(0, loadedPackages.Count); - WindowsPackageManager.Verify(c => c.GetPackagesAsync(It.IsAny>()), Times.Never()); + WindowsPackageManager.Verify(c => c.GetPackagesAsync(It.IsAny>()), Times.Never()); } [TestMethod] public void LoadCatalogs_ExceptionThrownWhenGettingPackages_ReturnsNoCatalogs() { // Configure package manager - WindowsPackageManager.Setup(wpm => wpm.GetPackagesAsync(It.IsAny>())).ThrowsAsync(new FindPackagesException(FindPackagesResultStatus.CatalogError)); + WindowsPackageManager.Setup(wpm => wpm.GetPackagesAsync(It.IsAny>())).ThrowsAsync(new FindPackagesException(FindPackagesResultStatus.CatalogError)); // Act var loadedPackages = LoadCatalogsFromJsonDataSource("AppManagementPackages_Success.json"); @@ -70,7 +73,7 @@ public void LoadCatalogs_ExceptionThrownWhenOpeningFile_ThrowsException() { // Prepare expected package var expectedPackages = new List(); - WindowsPackageManager.Setup(wpm => wpm.GetPackagesAsync(It.IsAny>())).ReturnsAsync(expectedPackages); + WindowsPackageManager.Setup(wpm => wpm.GetPackagesAsync(It.IsAny>())).ReturnsAsync(expectedPackages); // Act/Assert var fileName = TestHelpers.GetTestFilePath("file_not_found"); diff --git a/tools/SetupFlow/DevHome.SetupFlow.UnitTest/ViewModels/WinGetPackageRestoreDataSourceTest.cs b/tools/SetupFlow/DevHome.SetupFlow.UnitTest/ViewModels/WinGetPackageRestoreDataSourceTest.cs index cf0b75245d..3a353bfbb2 100644 --- a/tools/SetupFlow/DevHome.SetupFlow.UnitTest/ViewModels/WinGetPackageRestoreDataSourceTest.cs +++ b/tools/SetupFlow/DevHome.SetupFlow.UnitTest/ViewModels/WinGetPackageRestoreDataSourceTest.cs @@ -26,7 +26,7 @@ public void LoadCatalogs_EmptyPackages_ReturnsNoCatalogs() // Arrange var expectedPackages = new List(); var restoreApplicationInfoList = expectedPackages.Select(p => CreateRestoreApplicationInfo(p.Id).Object).ToList(); - WindowsPackageManager.Setup(wpm => wpm.GetPackagesAsync(It.IsAny>())).ReturnsAsync(expectedPackages); + WindowsPackageManager.Setup(wpm => wpm.GetPackagesAsync(It.IsAny>())).ReturnsAsync(expectedPackages); ConfigureRestoreDeviceInfo(RestoreDeviceInfoStatus.Ok, restoreApplicationInfoList); // Act @@ -34,14 +34,14 @@ public void LoadCatalogs_EmptyPackages_ReturnsNoCatalogs() // Assert Assert.AreEqual(0, loadedPackages.Count); - WindowsPackageManager.Verify(wpm => wpm.GetPackagesAsync(It.IsAny>()), Times.Never()); + WindowsPackageManager.Verify(wpm => wpm.GetPackagesAsync(It.IsAny>()), Times.Never()); } [TestMethod] public void LoadCatalogs_ExceptionThrownWhenGettingPackages_ReturnsNoCatalogs() { // Arrange - WindowsPackageManager!.Setup(wpm => wpm.GetPackagesAsync(It.IsAny>())).ThrowsAsync(new FindPackagesException(FindPackagesResultStatus.CatalogError)); + WindowsPackageManager!.Setup(wpm => wpm.GetPackagesAsync(It.IsAny>())).ThrowsAsync(new FindPackagesException(FindPackagesResultStatus.CatalogError)); ConfigureRestoreDeviceInfo(RestoreDeviceInfoStatus.Ok, new List()); // Act @@ -78,7 +78,7 @@ public void LoadCatalogs_OrderedPackages_ReturnsWinGetCatalogWithMatchingInputOr PackageHelper.CreatePackage(packageId2).Object, }; var restoreApplicationInfoList = expectedPackages.Select(p => CreateRestoreApplicationInfo(p.Id).Object).ToList(); - WindowsPackageManager.Setup(wpm => wpm.GetPackagesAsync(It.IsAny>())).ReturnsAsync(expectedPackages); + WindowsPackageManager.Setup(wpm => wpm.GetPackagesAsync(It.IsAny>())).ReturnsAsync(expectedPackages); ConfigureRestoreDeviceInfo(RestoreDeviceInfoStatus.Ok, restoreApplicationInfoList); // Act @@ -105,7 +105,7 @@ public void LoadCatalogs_Success_ReturnsWinGetCatalogs() PackageHelper.CreatePackage("mock2").Object, }; var restoreApplicationInfoList = expectedPackages.Select(p => CreateRestoreApplicationInfo(p.Id).Object).ToList(); - WindowsPackageManager.Setup(wpm => wpm.GetPackagesAsync(It.IsAny>())).ReturnsAsync(expectedPackages); + WindowsPackageManager.Setup(wpm => wpm.GetPackagesAsync(It.IsAny>())).ReturnsAsync(expectedPackages); ConfigureRestoreDeviceInfo(RestoreDeviceInfoStatus.Ok, restoreApplicationInfoList); // Act @@ -140,7 +140,7 @@ public void LoadCatalogs_ExceptionThrownWhenGettingRestoreApplicationIcon_Return return restoreAppInfo.Object; }).ToList(); - WindowsPackageManager.Setup(wpm => wpm.GetPackagesAsync(It.IsAny>())).ReturnsAsync(expectedPackages); + WindowsPackageManager.Setup(wpm => wpm.GetPackagesAsync(It.IsAny>())).ReturnsAsync(expectedPackages); ConfigureRestoreDeviceInfo(RestoreDeviceInfoStatus.Ok, restoreApplicationInfoList); // Act @@ -162,8 +162,8 @@ public void LoadCatalogs_GettingRestoreApplicationIconWithEmptyStream_ReturnsNul PackageHelper.CreatePackage("mock").Object, }; var restoreApplicationInfoList = expectedPackages.Select(p => CreateRestoreApplicationInfo(p.Id, EmptyIconStreamSize).Object).ToList(); - WindowsPackageManager.Setup(wpm => wpm.GetPackagesAsync(It.IsAny>())).ReturnsAsync(expectedPackages); - WindowsPackageManager.Setup(wpm => wpm.CreateWinGetCatalogPackageUri(It.IsAny())).Returns(new Uri("http://mock")); + WindowsPackageManager.Setup(wpm => wpm.GetPackagesAsync(It.IsAny>())).ReturnsAsync(expectedPackages); + WindowsPackageManager.Setup(wpm => wpm.CreateWinGetCatalogPackageUri(It.IsAny())).Returns(new WinGetPackageUri("x-ms-winget://mock/mock")); ConfigureRestoreDeviceInfo(RestoreDeviceInfoStatus.Ok, restoreApplicationInfoList); // Act diff --git a/tools/SetupFlow/DevHome.SetupFlow.UnitTest/WinGetPackageUriTests.cs b/tools/SetupFlow/DevHome.SetupFlow.UnitTest/WinGetPackageUriTests.cs new file mode 100644 index 0000000000..7c00b8732d --- /dev/null +++ b/tools/SetupFlow/DevHome.SetupFlow.UnitTest/WinGetPackageUriTests.cs @@ -0,0 +1,198 @@ +// Copyright (c) Microsoft Corporation. +// Licensed under the MIT License. + +using DevHome.SetupFlow.Models; + +namespace DevHome.SetupFlow.UnitTest; + +[TestClass] +public class WinGetPackageUriTests +{ + [TestMethod] + [DataRow("x-ms-winget://catalog/package")] + [DataRow("x-ms-winget://catalog/package?version=1")] + [DataRow("x-ms-winget://catalog/package?not_supported=1")] + public void TryCreate_ValidUri_ReturnsTrue(string packageStringUri) + { + // Arrange + var uri = new Uri(packageStringUri); + + // Act + var result = WinGetPackageUri.TryCreate(uri, out var packageUri); + + // Assert + Assert.IsTrue(result); + Assert.AreEqual("catalog", packageUri.CatalogName); + Assert.AreEqual("package", packageUri.PackageId); + } + + [TestMethod] + [DataRow("x-ms-winget://catalog/package")] + [DataRow("x-ms-winget://catalog/package?version=1")] + [DataRow("x-ms-winget://catalog/package?not_supported=1")] + public void TryCreate_ValidStringUri_ReturnsTrue(string packageStringUri) + { + // Act + var result = WinGetPackageUri.TryCreate(packageStringUri, out var packageUri); + + // Assert + Assert.IsTrue(result); + Assert.AreEqual("catalog", packageUri.CatalogName); + Assert.AreEqual("package", packageUri.PackageId); + } + + [TestMethod] + public void TryCreate_NullStringUri_ReturnsFalse() + { + // Act + var result = WinGetPackageUri.TryCreate(null as string, out var packageUri); + + // Assert + Assert.IsFalse(result); + Assert.IsNull(packageUri); + } + + [TestMethod] + public void TryCreate_NullUri_ReturnsFalse() + { + // Act + var result = WinGetPackageUri.TryCreate(null as Uri, out var packageUri); + + // Assert + Assert.IsFalse(result); + Assert.IsNull(packageUri); + } + + [TestMethod] + public void TryCreate_InvalidUri_ReturnsFalse() + { + // Arrange + var uri = new Uri("https://www.microsoft.com"); + + // Act + var result = WinGetPackageUri.TryCreate(uri, out var packageUri); + + // Assert + Assert.IsFalse(result); + Assert.IsNull(packageUri); + } + + [TestMethod] + public void TryCreate_InvalidStringUri_ReturnsFalse() + { + // Act + var result = WinGetPackageUri.TryCreate("https://www.microsoft.com", out var packageUri); + + // Assert + Assert.IsFalse(result); + Assert.IsNull(packageUri); + } + + [TestMethod] + [DataRow("x-ms-winget://catalog/package?version=1", WinGetPackageUriParameters.All, "x-ms-winget://catalog/package?version=1")] + [DataRow("x-ms-winget://catalog/package?version=1", WinGetPackageUriParameters.Version, "x-ms-winget://catalog/package?version=1")] + [DataRow("x-ms-winget://catalog/package?version=1", WinGetPackageUriParameters.None, "x-ms-winget://catalog/package")] + public void ToString_IncludeParameters_ReturnsUriString( + string packageStringUri, + WinGetPackageUriParameters includeParameters, + string toString) + { + // Arrange + WinGetPackageUri.TryCreate(packageStringUri, out var packageUri); + + // Act + var result = packageUri.ToString(includeParameters); + + // Assert + Assert.AreEqual(toString, result); + } + + [TestMethod] + [DataRow("x-ms-winget://catalog/package?version=1", "x-ms-winget://catalog/package?version=1", WinGetPackageUriParameters.All)] + [DataRow("x-ms-winget://catalog/package?version=1", "x-ms-winget://catalog/package?version=1", WinGetPackageUriParameters.Version)] + [DataRow("x-ms-winget://catalog/package?version=1", "x-ms-winget://catalog/package?version=1", WinGetPackageUriParameters.None)] + [DataRow("x-ms-winget://catalog/package?version=1", "x-ms-winget://catalog/package?version=2", WinGetPackageUriParameters.None)] + public void Equals_Uri_ReturnsTrue(string packageStringUri1, string packageStringUri2, WinGetPackageUriParameters includeParameters) + { + // Arrange + WinGetPackageUri.TryCreate(packageStringUri1, out var packageUri1); + WinGetPackageUri.TryCreate(packageStringUri2, out var packageUri2); + + // Act + var result = packageUri1.Equals(packageUri2, includeParameters); + + // Assert + Assert.IsTrue(result); + } + + [TestMethod] + [DataRow("x-ms-winget://catalog1/package1?version=1", "x-ms-winget://catalog2/package1?version=1", WinGetPackageUriParameters.All)] + [DataRow("x-ms-winget://catalog1/package1?version=1", "x-ms-winget://catalog1/package2?version=1", WinGetPackageUriParameters.All)] + [DataRow("x-ms-winget://catalog1/package1?version=1", "x-ms-winget://catalog1/package1?version=2", WinGetPackageUriParameters.All)] + [DataRow("x-ms-winget://catalog1/package1?version=1", "x-ms-winget://catalog1/package1?version=2", WinGetPackageUriParameters.Version)] + [DataRow("x-ms-winget://catalog1/package1?version=1", "x-ms-winget://catalog2/package1?version=1", WinGetPackageUriParameters.None)] + [DataRow("x-ms-winget://catalog1/package1?version=1", "x-ms-winget://catalog1/package2?version=1", WinGetPackageUriParameters.None)] + public void Equals_Uri_ReturnsFalse(string packageStringUri1, string packageStringUri2, WinGetPackageUriParameters includeParameters) + { + // Arrange + WinGetPackageUri.TryCreate(packageStringUri1, out var packageUri1); + WinGetPackageUri.TryCreate(packageStringUri2, out var packageUri2); + + // Act + var result = packageUri1.Equals(packageUri2, includeParameters); + + // Assert + Assert.IsFalse(result); + } + + [TestMethod] + public void Equals_NullUri_ReturnsFalse() + { + // Arrange + WinGetPackageUri.TryCreate("x-ms-winget://catalog/package", out var packageUri); + + // Act + var result = packageUri.Equals(null as WinGetPackageUri, WinGetPackageUriParameters.All); + + // Assert + Assert.IsFalse(result); + } + + [TestMethod] + public void Equals_UriAndStringUri_ReturnsTrue() + { + // Arrange + WinGetPackageUri.TryCreate("x-ms-winget://catalog/package?version=1", out var packageUri); + + // Act + var result = packageUri.Equals("x-ms-winget://catalog/package?version=1", WinGetPackageUriParameters.All); + + // Assert + Assert.IsTrue(result); + } + + [TestMethod] + [DataRow("x-ms-winget://catalog/package")] + [DataRow("x-ms-winget://catalog/package?version=1")] + public void Constructor_ValidUri_InitializesProperties(string uri) + { + // Act + var packageUri = new WinGetPackageUri(uri); + + // Assert + Assert.AreEqual("catalog", packageUri.CatalogName); + Assert.AreEqual("package", packageUri.PackageId); + Assert.IsNotNull(packageUri.Options); + } + + [TestMethod] + [DataRow("https://catalog/package")] + [DataRow("x-ms-winget://catalog?version=1")] + [DataRow("x-ms-winget://")] + [DataRow("x-ms-winget://?version=1")] + public void Constructor_InvalidUri_ThrowsException(string uri) + { + // Act/Assert + Assert.ThrowsException(() => new WinGetPackageUri(uri)); + } +} diff --git a/tools/SetupFlow/DevHome.SetupFlow/Controls/PackageDetailsTooltip.xaml b/tools/SetupFlow/DevHome.SetupFlow/Controls/PackageDetailsTooltip.xaml index d60cbfc508..64827b88d0 100644 --- a/tools/SetupFlow/DevHome.SetupFlow/Controls/PackageDetailsTooltip.xaml +++ b/tools/SetupFlow/DevHome.SetupFlow/Controls/PackageDetailsTooltip.xaml @@ -22,7 +22,7 @@ Style="{ThemeResource BodyStrongTextBlockStyle}" Visibility="{x:Bind Package.IsInstalled}"/> - + diff --git a/tools/SetupFlow/DevHome.SetupFlow/DevHome.SetupFlow.csproj b/tools/SetupFlow/DevHome.SetupFlow/DevHome.SetupFlow.csproj index bdc73d242f..a94b1854ac 100644 --- a/tools/SetupFlow/DevHome.SetupFlow/DevHome.SetupFlow.csproj +++ b/tools/SetupFlow/DevHome.SetupFlow/DevHome.SetupFlow.csproj @@ -10,7 +10,9 @@ + + @@ -19,11 +21,11 @@ all runtime; build; native; contentfiles; analyzers; buildtransitive + - @@ -39,6 +41,12 @@ Always + + MSBuild:Compile + + + MSBuild:Compile + $(DefaultXamlRuntime) Designer @@ -83,6 +91,8 @@ + + diff --git a/tools/SetupFlow/DevHome.SetupFlow/Exceptions/SDKApplyConfigurationSetResultException.cs b/tools/SetupFlow/DevHome.SetupFlow/Exceptions/SDKApplyConfigurationSetResultException.cs new file mode 100644 index 0000000000..616135b669 --- /dev/null +++ b/tools/SetupFlow/DevHome.SetupFlow/Exceptions/SDKApplyConfigurationSetResultException.cs @@ -0,0 +1,23 @@ +// Copyright (c) Microsoft Corporation. +// Licensed under the MIT License. + +using System; +using System.Collections.Generic; +using System.Linq; +using System.Text; +using System.Threading.Tasks; + +namespace DevHome.SetupFlow.Exceptions; + +public class SDKApplyConfigurationSetResultException : Exception +{ + public SDKApplyConfigurationSetResultException(string message, Exception innerException) + : base(message, innerException) + { + } + + public SDKApplyConfigurationSetResultException(string message) + : base(message) + { + } +} diff --git a/tools/SetupFlow/DevHome.SetupFlow/Exceptions/SDKOpenConfigurationSetResultException.cs b/tools/SetupFlow/DevHome.SetupFlow/Exceptions/SDKOpenConfigurationSetResultException.cs new file mode 100644 index 0000000000..6e35371fbc --- /dev/null +++ b/tools/SetupFlow/DevHome.SetupFlow/Exceptions/SDKOpenConfigurationSetResultException.cs @@ -0,0 +1,23 @@ +// Copyright (c) Microsoft Corporation. +// Licensed under the MIT License. + +using System; +using System.Collections.Generic; +using System.Linq; +using System.Text; +using System.Threading.Tasks; + +namespace DevHome.SetupFlow.Exceptions; + +public class SDKOpenConfigurationSetResultException : Exception +{ + public SDKOpenConfigurationSetResultException(string message, Exception innerException) + : base(message, innerException) + { + } + + public SDKOpenConfigurationSetResultException(string message) + : base(message) + { + } +} diff --git a/tools/SetupFlow/DevHome.SetupFlow/Extensions/ServiceExtensions.cs b/tools/SetupFlow/DevHome.SetupFlow/Extensions/ServiceExtensions.cs index 23416750f1..b8c8c647f8 100644 --- a/tools/SetupFlow/DevHome.SetupFlow/Extensions/ServiceExtensions.cs +++ b/tools/SetupFlow/DevHome.SetupFlow/Extensions/ServiceExtensions.cs @@ -5,11 +5,13 @@ using System.IO; using DevHome.Common.Services; using DevHome.SetupFlow.Common.WindowsPackageManager; +using DevHome.SetupFlow.Models.Environments; using DevHome.SetupFlow.Services; using DevHome.SetupFlow.Services.WinGet; using DevHome.SetupFlow.Services.WinGet.Operations; using DevHome.SetupFlow.TaskGroups; using DevHome.SetupFlow.ViewModels; +using DevHome.SetupFlow.ViewModels.Environments; using Microsoft.Extensions.DependencyInjection; using Microsoft.Extensions.Hosting; using Microsoft.Extensions.Options; @@ -23,6 +25,7 @@ public static class ServiceExtensions public static IServiceCollection AddSetupFlow(this IServiceCollection services, HostBuilderContext context) { // Project services + services.AddSetupTarget(); services.AddAppManagement(); services.AddConfigurationFile(); services.AddDevDrive(); @@ -107,6 +110,9 @@ private static IServiceCollection AddConfigurationFile(this IServiceCollection s // Services services.AddTransient(); + // Builder + services.AddSingleton(); + return services; } @@ -169,4 +175,15 @@ private static IServiceCollection AddSummary(this IServiceCollection services) return services; } + + private static IServiceCollection AddSetupTarget(this IServiceCollection services) + { + // View models + services.AddSingleton(); + services.AddTransient(); + services.AddTransient(); + services.AddTransient(); + + return services; + } } diff --git a/tools/SetupFlow/DevHome.SetupFlow/Models/ActionCenterMessages.cs b/tools/SetupFlow/DevHome.SetupFlow/Models/ActionCenterMessages.cs index d49d5a9cc7..080559f96a 100644 --- a/tools/SetupFlow/DevHome.SetupFlow/Models/ActionCenterMessages.cs +++ b/tools/SetupFlow/DevHome.SetupFlow/Models/ActionCenterMessages.cs @@ -1,6 +1,9 @@ // Copyright (c) Microsoft Corporation. // Licensed under the MIT License. +using DevHome.Common.Views; +using Microsoft.Windows.DevHome.SDK; + namespace DevHome.SetupFlow.Models; /// @@ -15,4 +18,16 @@ public string PrimaryMessage { get; set; } + + public ExtensionAdaptiveCardPanel ExtensionAdaptiveCardPanel { get; set; } = new(); + + public ActionCenterMessages(ExtensionAdaptiveCardPanel panel, string primaryMessage) + { + ExtensionAdaptiveCardPanel = panel; + PrimaryMessage = primaryMessage; + } + + public ActionCenterMessages() + { + } } diff --git a/tools/SetupFlow/DevHome.SetupFlow/Models/CloneRepoTask.cs b/tools/SetupFlow/DevHome.SetupFlow/Models/CloneRepoTask.cs index 098024691e..71eec40e4c 100644 --- a/tools/SetupFlow/DevHome.SetupFlow/Models/CloneRepoTask.cs +++ b/tools/SetupFlow/DevHome.SetupFlow/Models/CloneRepoTask.cs @@ -12,6 +12,7 @@ using DevHome.Common.Services; using DevHome.Common.TelemetryEvents; using DevHome.Common.TelemetryEvents.SetupFlow; +using DevHome.Common.Views; using DevHome.SetupFlow.Common.Helpers; using DevHome.SetupFlow.Services; using DevHome.Telemetry; @@ -120,6 +121,8 @@ public void ClonePathTrimmed() // When this task needs to insert messages into the loading screen this pragma can be removed. #pragma warning disable 67 public event ISetupTask.ChangeMessageHandler AddMessage; + + public event ISetupTask.ChangeActionCenterMessageHandler UpdateActionCenterMessage; #pragma warning restore 67 public bool DependsOnDevDriveToBeInstalled diff --git a/tools/SetupFlow/DevHome.SetupFlow/Models/Configuration.cs b/tools/SetupFlow/DevHome.SetupFlow/Models/Configuration.cs index 7f2d77a3d1..5ddcee115c 100644 --- a/tools/SetupFlow/DevHome.SetupFlow/Models/Configuration.cs +++ b/tools/SetupFlow/DevHome.SetupFlow/Models/Configuration.cs @@ -1,44 +1,44 @@ -// Copyright (c) Microsoft Corporation. -// Licensed under the MIT License. - -using System; -using System.IO; +// Copyright (c) Microsoft Corporation. +// Licensed under the MIT License. + +using System; +using System.IO; using DevHome.SetupFlow.Common.Helpers; - + namespace DevHome.SetupFlow.Models; - -/// -/// Model class for a YAML configuration file -/// -public class Configuration -{ - private readonly FileInfo _fileInfo; - private readonly Lazy _lazyContent; - - public Configuration(string filePath) - { - _fileInfo = new FileInfo(filePath); - _lazyContent = new(LoadContent); - } - - /// - /// Gets the configuration file name - /// - public string Name => _fileInfo.Name; - - /// - /// Gets the file content - /// - public string Content => _lazyContent.Value; - - /// - /// Load configuration file content - /// - /// Configuration file content - private string LoadContent() - { + +/// +/// Model class for a YAML configuration file +/// +public class Configuration +{ + private readonly FileInfo _fileInfo; + private readonly Lazy _lazyContent; + + public Configuration(string filePath) + { + _fileInfo = new FileInfo(filePath); + _lazyContent = new(LoadContent); + } + + /// + /// Gets the configuration file name + /// + public string Name => _fileInfo.Name; + + /// + /// Gets the file content + /// + public string Content => _lazyContent.Value; + + /// + /// Load configuration file content + /// + /// Configuration file content + private string LoadContent() + { Log.Logger?.ReportInfo(Log.Component.Configuration, $"Loading configuration file content from {_fileInfo.FullName}"); - using var text = _fileInfo.OpenText(); - return text.ReadToEnd(); - } -} + using var text = _fileInfo.OpenText(); + return text.ReadToEnd(); + } +} diff --git a/tools/SetupFlow/DevHome.SetupFlow/Models/ConfigurationUnitResult.cs b/tools/SetupFlow/DevHome.SetupFlow/Models/ConfigurationUnitResult.cs index a47f20aaa8..147208fadb 100644 --- a/tools/SetupFlow/DevHome.SetupFlow/Models/ConfigurationUnitResult.cs +++ b/tools/SetupFlow/DevHome.SetupFlow/Models/ConfigurationUnitResult.cs @@ -3,10 +3,13 @@ extern alias Projection; +using Microsoft.ApplicationInsights.Extensibility; using Microsoft.Management.Configuration; using Projection::DevHome.SetupFlow.ElevatedComponent.Helpers; using Windows.Win32.Foundation; +using SDK = Microsoft.Windows.DevHome.SDK; + namespace DevHome.SetupFlow.Models; public class ConfigurationUnitResult @@ -38,6 +41,23 @@ public ConfigurationUnitResult(ElevatedConfigureUnitTaskResult result) ErrorDescription = result.ErrorDescription; } + public ConfigurationUnitResult(SDK.ApplyConfigurationUnitResult result) + { + Type = result.Unit.Type; + Id = result.Unit.Identifier; + if (result.Unit.Settings?.TryGetValue("description", out var descriptionObj) == true) + { + UnitDescription = descriptionObj?.ToString() ?? string.Empty; + } + + Intent = result.Unit.Intent.ToString(); + IsSkipped = result.State == SDK.ConfigurationUnitState.Skipped; + HResult = result.ResultInformation?.ResultCode?.HResult ?? HRESULT.S_OK; + SdkResultSource = result.ResultInformation?.ResultSource ?? SDK.ConfigurationUnitResultSource.None; + Details = result.ResultInformation?.Details; + ErrorDescription = result.ResultInformation?.Description; + } + public string Type { get; } public string Id { get; } @@ -54,5 +74,7 @@ public ConfigurationUnitResult(ElevatedConfigureUnitTaskResult result) public ConfigurationUnitResultSource ResultSource { get; } + public SDK.ConfigurationUnitResultSource SdkResultSource { get; } + public string Details { get; } } diff --git a/tools/SetupFlow/DevHome.SetupFlow/Models/ConfigureTargetTask.cs b/tools/SetupFlow/DevHome.SetupFlow/Models/ConfigureTargetTask.cs new file mode 100644 index 0000000000..25cb50a494 --- /dev/null +++ b/tools/SetupFlow/DevHome.SetupFlow/Models/ConfigureTargetTask.cs @@ -0,0 +1,530 @@ +// Copyright (c) Microsoft Corporation. +// Licensed under the MIT License. + +extern alias Projection; + +using System; +using System.Collections.Generic; +using System.Runtime.InteropServices; +using System.Security.Cryptography; +using System.Text; +using System.Threading; +using System.Threading.Tasks; +using AdaptiveCards.Rendering.WinUI3; +using CommunityToolkit.WinUI; +using DevHome.Common.Environments.Models; +using DevHome.Common.Environments.Services; +using DevHome.Common.Renderers; +using DevHome.Common.Views; +using DevHome.Contracts.Services; +using DevHome.Logging; +using DevHome.SetupFlow.Common.Exceptions; +using DevHome.SetupFlow.Common.Helpers; +using DevHome.SetupFlow.Exceptions; +using DevHome.SetupFlow.Models.WingetConfigure; +using DevHome.SetupFlow.Services; +using LibGit2Sharp; +using Microsoft.UI.Xaml; +using Microsoft.Windows.DevHome.SDK; +using Projection::DevHome.SetupFlow.ElevatedComponent; +using Windows.Foundation; +using Windows.Storage; +using Windows.Win32; +using WinUIEx; +using SDK = Microsoft.Windows.DevHome.SDK; + +namespace DevHome.SetupFlow.Models; + +public class ConfigureTargetTask : ISetupTask +{ + private readonly Microsoft.UI.Dispatching.DispatcherQueue _dispatcherQueue; + + private readonly ISetupFlowStringResource _stringResource; + + private readonly IComputeSystemManager _computeSystemManager; + + private readonly SetupFlowOrchestrator _setupFlowOrchestrator; + + private readonly ConfigurationFileBuilder _configurationFileBuilder; + + private readonly IThemeSelectorService _themeSelectorService; + + public AdaptiveCardRenderer Renderer { get; private set; } + + // Inherited via ISetupTask but unused + public bool RequiresAdmin => false; + + // Inherited via ISetupTask but unused + public bool RequiresReboot => false; + + // Inherited via ISetupTask but unused + public bool DependsOnDevDriveToBeInstalled => false; + + // Inherited via ISetupTask + public event ISetupTask.ChangeMessageHandler AddMessage; + + // Inherited via ISetupTask + public event ISetupTask.ChangeActionCenterMessageHandler UpdateActionCenterMessage; + + public Dictionary AdaptiveCardHostConfigs { get; set; } = new(); + + private readonly Dictionary _hostConfigFileNames = new() + { + { ElementTheme.Dark, "DarkHostConfig.json" }, + { ElementTheme.Light, "LightHostConfig.json" }, + }; + + public ActionCenterMessages ActionCenterMessages { get; set; } = new(); + + public string ComputeSystemName { get; private set; } = string.Empty; + + public SDK.IExtensionAdaptiveCardSession2 ExtensionAdaptiveCardSession { get; private set; } + + public string WingetConfigFileString { get; set; } + + /// + /// Gets the results of the configuration units that were applied to the target machine. These results are + /// what we will display to the user in the summary page, assuming the extension was able to start Winget + /// configure and send back the results to us. + /// + public List ConfigurationResults { get; private set; } = new(); + + public uint UserNumberOfAttempts { get; private set; } = 1; + + public uint UserMaxNumberOfAttempts { get; private set; } = 3; + + /// + /// Gets the result of the apply configuration operation. + /// + public SDKApplyConfigurationResult Result { get; private set; } + + public IAsyncOperation ApplyConfigurationAsyncOperation { get; private set; } + + public ConfigureTargetTask( + ISetupFlowStringResource stringResource, + IComputeSystemManager computeSystemManager, + ConfigurationFileBuilder configurationFileBuilder, + SetupFlowOrchestrator setupFlowOrchestrator, + IThemeSelectorService themeSelectorService) + { + _stringResource = stringResource; + _computeSystemManager = computeSystemManager; + _configurationFileBuilder = configurationFileBuilder; + _themeSelectorService = themeSelectorService; + _setupFlowOrchestrator = setupFlowOrchestrator; + _dispatcherQueue = Microsoft.UI.Dispatching.DispatcherQueue.GetForCurrentThread(); + _themeSelectorService.ThemeChanged += OnThemeChanged; + } + + public void OnAdaptiveCardSessionStopped(IExtensionAdaptiveCardSession2 cardSession, SDK.ExtensionAdaptiveCardSessionStoppedEventArgs data) + { + Log.Logger?.ReportInfo(Log.Component.ConfigurationTarget, "Extension ending adaptive card session"); + + // Now that the session has ended, we can remove the adaptive card panel from the UI. + cardSession.Stopped -= OnAdaptiveCardSessionStopped; + RemoveAdaptiveCardPanelFromLoadingUI(); + ExtensionAdaptiveCardSession = null; + + // if the session ended successfully we should relay this to the user. + if (data.Result.Status == SDK.ProviderOperationStatus.Success) + { + AddMessage(_stringResource.GetLocalized(StringResourceKey.ConfigureTargetApplyConfigurationActionSuccess), MessageSeverityKind.Success); + } + else + { + if (UserNumberOfAttempts <= UserMaxNumberOfAttempts) + { + AddMessage(_stringResource.GetLocalized(StringResourceKey.ConfigureTargetApplyConfigurationActionFailureRetry), MessageSeverityKind.Warning); + return; + } + + AddMessage(_stringResource.GetLocalized(StringResourceKey.ConfigureTargetApplyConfigurationActionFailureEnd), MessageSeverityKind.Error); + Log.Logger?.ReportError(Log.Component.ConfigurationTarget, "Error no more attempts to correct action"); + } + } + + public void OnActionRequired(IApplyConfigurationOperation operation, SDK.ApplyConfigurationActionRequiredEventArgs actionRequiredEventArgs) + { + Log.Logger?.ReportInfo(Log.Component.ConfigurationTarget, $"adaptive card receieved from extension"); + var correctiveCard = actionRequiredEventArgs?.CorrectiveActionCardSession; + + if (correctiveCard != null) + { + // If the extension sends a new adaptive card session, we need to update the session and the UI. + if (ExtensionAdaptiveCardSession != null) + { + RemoveAdaptiveCardPanelFromLoadingUI(); + ExtensionAdaptiveCardSession.Stopped -= OnAdaptiveCardSessionStopped; + } + + ExtensionAdaptiveCardSession = correctiveCard; + ExtensionAdaptiveCardSession.Stopped += OnAdaptiveCardSessionStopped; + + CreateCorrectiveActionPanel(ExtensionAdaptiveCardSession).GetAwaiter().GetResult(); + + AddMessage(_stringResource.GetLocalized(StringResourceKey.ConfigureTargetApplyConfigurationActionNeeded, UserNumberOfAttempts++, UserMaxNumberOfAttempts), MessageSeverityKind.Warning); + } + else + { + Log.Logger?.ReportInfo(Log.Component.ConfigurationTarget, "A corrective action was sent from the extension but the adaptive card session was null."); + } + } + + public void OnApplyConfigurationOperationChanged(object sender, SDK.ConfigurationSetStateChangedEventArgs changeEventArgs) + { + try + { + var progressData = changeEventArgs.ConfigurationSetChangeData; + + if (progressData == null) + { + Log.Logger?.ReportError(Log.Component.ConfigurationTarget, "Unable to get progress of the configuration as the progress data was null"); + return; + } + + var severity = MessageSeverityKind.Info; + + // Adaptive card session was not sent, so we check if there are any errors or due to applying a configuration unit/set. + var wrapper = new SDKConfigurationSetChangeWrapper(progressData, _stringResource); + var potentialErrorMsg = wrapper.GetErrorMessagesForDisplay(); + var stringBuilder = new StringBuilder(); + stringBuilder.AppendLine("---- " + _stringResource.GetLocalized(StringResourceKey.SetupTargetConfigurationProgressUpdate) + " ----"); + var startingLineNumber = 0u; + + if (wrapper.Change == SDK.ConfigurationSetChangeEventType.SetStateChanged) + { + // Configuration set changed + stringBuilder.AppendLine(GetSpacingForProgressMessage(startingLineNumber++) + wrapper.ConfigurationSetState); + } + + if (wrapper.IsErrorMessagePresent) + { + Log.Logger?.ReportError(Log.Component.ConfigurationTarget, $"Target experienced an error while applying the configuration: {wrapper.GetErrorMessageForLogging()}"); + severity = MessageSeverityKind.Error; + stringBuilder.AppendLine(GetSpacingForProgressMessage(startingLineNumber++) + wrapper.GetErrorMessagesForDisplay()); + } + + // In the future we need to add more messaging to the UI for the user to understand what is happening. It is on the extension to provide + // us with this messaging. Right now we only get error information and information about which configuration units are/were applied. However + // there is no way for us to know what the extension is doing, it may not have started configuration yet but may simply be installing prerequisites. + if (wrapper.Unit != null) + { + // We may need to change the formatting of the message in the future. + var description = BuildConfigurationUnitDescription(wrapper.Unit); + stringBuilder.AppendLine(GetSpacingForProgressMessage(startingLineNumber++) + description); + stringBuilder.AppendLine(GetSpacingForProgressMessage(startingLineNumber++) + wrapper.ConfigurationUnitState); + } + else + { + Log.Logger?.ReportInfo(Log.Component.ConfigurationTarget, "Extension sent progress but there was no configuration unit data sent."); + } + + // Example of a message that will be displayed in the UI: + // ---- Configuration progress recieved! ---- + // There was an issue applying part of the configuration using DSC resource: 'GitClone'.Check the extension's logs + // - Assert : GitClone[Clone: wil - C:\Users\Public\Documents\source\repos\wil] + // - This part of the configuration is now complete + AddMessage(stringBuilder.ToString(), severity); + } + catch (Exception ex) + { + Log.Logger?.ReportError(Log.Component.ConfigurationTarget, $"Failed to process configuration progress data on target machine.'{ComputeSystemName}'", ex); + } + } + + private string GetSpacingForProgressMessage(uint lineNumber) + { + if (lineNumber == 0) + { + return string.Empty; + } + + var spacing = string.Empty; + + // Add 6 spaces for each line number. + for (var i = 0; i < lineNumber; ++i) + { + spacing += " "; + } + + // now add a dash to the end of the spacing to make it look like a bullet point. + spacing += "- "; + return spacing; + } + + public void HandleCompletedOperation(SDK.ApplyConfigurationResult applyConfigurationResult) + { + // apply configuration set result is used to check if the configuration set was applied successfully, while open configuration + // set result is used to check if WinGet was able to open the configuration file successfully. + var applyConfigSetResult = applyConfigurationResult.ApplyConfigurationSetResult; + var openConfigResult = applyConfigurationResult.OpenConfigurationSetResult; + var resultStatus = applyConfigurationResult.Result.Status; + var result = applyConfigurationResult.Result; + var resultInformation = new string(result.DisplayMessage); + + try + { + Result = new SDKApplyConfigurationResult(result, new SDKApplyConfigurationSetResult(applyConfigSetResult), new SDKOpenConfigurationSetResult(openConfigResult, _stringResource)); + + if (resultStatus == ProviderOperationStatus.Failure) + { + Log.Logger?.ReportError(Log.Component.ConfigurationTarget, $"Extension failed to configure config file with exception. Diagnostic text: {result.DiagnosticText}", result.ExtendedError); + throw new SDKApplyConfigurationSetResultException(applyConfigurationResult.Result.DiagnosticText); + } + + // Check if there were errors while opening the configuration set. + if (!Result.OpenConfigSucceeded) + { + AddMessage(Result.OpenResult.GetErrorMessage(), MessageSeverityKind.Error); + throw new OpenConfigurationSetException(Result.OpenResult.ResultCode, Result.OpenResult.Field, Result.OpenResult.Value); + } + + // Check if the WinGet apply operation was failed. + if (!Result.ApplyConfigSucceeded) + { + throw new SDKApplyConfigurationSetResultException("Unable to get the result of the apply configuration set as it was null."); + } + + // Gather the configuration results. We'll display these to the user in the summary page if they are available. + if (Result.ApplyResult.AreConfigUnitsAvailable) + { + for (var i = 0; i < Result.ApplyResult.Result.UnitResults.Count; ++i) + { + ConfigurationResults.Add(new ConfigurationUnitResult(Result.ApplyResult.Result.UnitResults[i])); + } + + Log.Logger?.ReportInfo(Log.Component.ConfigurationTarget, "Configuration stopped"); + } + else + { + throw new SDKApplyConfigurationSetResultException("No configuration units were found. This is likely due to an error within the extension."); + } + } + catch (Exception ex) + { + Log.Logger?.ReportError(Log.Component.ConfigurationTarget, $"Failed to apply configuration on target machine. '{ComputeSystemName}'", ex); + } + + var tempResultInfo = !string.IsNullOrEmpty(resultInformation) ? resultInformation : string.Empty; + var severity = Result.ApplyConfigSucceeded ? MessageSeverityKind.Info : MessageSeverityKind.Error; + + if (string.IsNullOrEmpty(tempResultInfo)) + { + AddMessage(_stringResource.GetLocalized(StringResourceKey.ConfigureTargetApplyConfigurationStoppedWithNoEndingMessage), severity); + return; + } + + AddMessage(_stringResource.GetLocalized(StringResourceKey.ConfigureTargetApplyConfigurationStopped, $"{tempResultInfo}"), severity); + } + + /// + /// Signals to the loading page view model that the adaptive card panel should be removed from the UI. + /// + public void RemoveAdaptiveCardPanelFromLoadingUI() + { + _dispatcherQueue.TryEnqueue(() => + { + if (ActionCenterMessages.ExtensionAdaptiveCardPanel != null) + { + ActionCenterMessages.ExtensionAdaptiveCardPanel = null; + UpdateActionCenterMessage(ActionCenterMessages, ActionMessageRequestKind.Remove); + } + }); + } + + public IAsyncOperation Execute() + { + return Task.Run(async () => + { + try + { + UserNumberOfAttempts = 1; + var computeSystem = _computeSystemManager.ComputeSystemSetupItem.ComputeSystemToSetup; + ComputeSystemName = computeSystem.DisplayName; + AddMessage(_stringResource.GetLocalized(StringResourceKey.SetupTargetExtensionApplyingConfiguration, ComputeSystemName), MessageSeverityKind.Info); + WingetConfigFileString = _configurationFileBuilder.BuildConfigFileStringFromTaskGroups(_setupFlowOrchestrator.TaskGroups, ConfigurationFileKind.SetupTarget); + var applyConfigurationOperation = computeSystem.ApplyConfiguration(WingetConfigFileString); + + applyConfigurationOperation.ConfigurationSetStateChanged += OnApplyConfigurationOperationChanged; + applyConfigurationOperation.ActionRequired += OnActionRequired; + + // We'll cancell the operation after 10 minutes. This is arbitrary for now and will need to be adjusted in the future. + // but we'll need to give the user the ability to cancel the operation in the UI as well. This is just a safety net. + // More work is needed to give the user the ability to cancel the operation as the capability is not currently available. + // in the UI of Dev Home's Loading page. + var tokenSource = new CancellationTokenSource(); + tokenSource.CancelAfter(TimeSpan.FromMinutes(10)); + + ApplyConfigurationAsyncOperation = applyConfigurationOperation.StartAsync(); + var result = await ApplyConfigurationAsyncOperation.AsTask().WaitAsync(tokenSource.Token); + + applyConfigurationOperation.ConfigurationSetStateChanged -= OnApplyConfigurationOperationChanged; + applyConfigurationOperation.ActionRequired -= OnActionRequired; + + HandleCompletedOperation(result); + + var openConFigException = Result.OpenResult.ResultCode; + var applyConfigException = Result.ApplyResult.ResultException; + + if (openConFigException != null) + { + throw openConFigException; + } + + if (applyConfigException != null) + { + throw applyConfigException; + } + + if (Result.ProviderResult.Status != ProviderOperationStatus.Success) + { + throw Result.ProviderResult.ExtendedError ?? throw new SDKApplyConfigurationSetResultException("Applying the configuration failed but we weren't able to check the ProviderOperation results extended error."); + } + + return TaskFinishedState.Success; + } + catch (Exception e) + { + Log.Logger?.ReportError(Log.Component.ConfigurationTarget, $"Failed to apply configuration on target machine.", e); + return TaskFinishedState.Failure; + } + }).AsAsyncOperation(); + } + + IAsyncOperation ISetupTask.ExecuteAsAdmin(IElevatedComponentOperation elevatedComponentOperation) => throw new NotImplementedException(); + + TaskMessages ISetupTask.GetLoadingMessages() + { + var localizedTargetName = _stringResource.GetLocalized(StringResourceKey.SetupTargetMachineName); + var nameToUseInDisplay = string.IsNullOrEmpty(ComputeSystemName) ? localizedTargetName : ComputeSystemName; + return new() + { + Executing = _stringResource.GetLocalized(StringResourceKey.SetupTargetExtensionApplyingConfiguration, nameToUseInDisplay), + Error = _stringResource.GetLocalized(StringResourceKey.SetupTargetExtensionApplyConfigurationError, nameToUseInDisplay), + Finished = _stringResource.GetLocalized(StringResourceKey.SetupTargetExtensionApplyConfigurationSuccess, nameToUseInDisplay), + NeedsReboot = _stringResource.GetLocalized(StringResourceKey.SetupTargetExtensionApplyConfigurationRebootRequired, nameToUseInDisplay), + }; + } + + public ActionCenterMessages GetErrorMessages() + { + return new() + { + PrimaryMessage = _stringResource.GetLocalized(StringResourceKey.SetupTargetExtensionApplyConfigurationError, ComputeSystemName), + }; + } + + public ActionCenterMessages GetRebootMessage() + { + return new() + { + PrimaryMessage = _stringResource.GetLocalized(StringResourceKey.SetupTargetExtensionApplyConfigurationRebootRequired, ComputeSystemName), + }; + } + + /// + /// Gets the host config files for the light and dark themes and sets them in the AdaptiveCardHostConfigs dictionary. + /// + public async Task SetupHostConfigFiles() + { + try + { + foreach (var elementPairing in _hostConfigFileNames) + { + var uri = new Uri($"ms-appx:///DevHome.Settings//Assets/{_hostConfigFileNames[elementPairing.Key]}"); + var file = await StorageFile.GetFileFromApplicationUriAsync(uri); + AdaptiveCardHostConfigs.Add(elementPairing.Key, await FileIO.ReadTextAsync(file)); + } + } + catch (Exception ex) + { + GlobalLog.Logger?.ReportError($"Failure occurred while retrieving the HostConfig file", ex); + } + } + + /// + /// Creates the adaptive card that will appear in the action center of the loading page. This + /// was adapted from the LoginUI adaptive card code for the account page in Dev Home settings. + /// The theming for the adaptive card isn't dynamic but in the future we can make it so. + /// + /// Adaptive card session sent by the entension when it needs a user to perform an action + public async Task CreateCorrectiveActionPanel(IExtensionAdaptiveCardSession2 session) + { + await _dispatcherQueue.EnqueueAsync(async () => + { + await SetupHostConfigFiles(); + var correctiveAction = session; + Renderer ??= new AdaptiveCardRenderer(); + var elementTheme = _themeSelectorService.IsDarkTheme() ? ElementTheme.Dark : ElementTheme.Light; + UpdateHostConfig(); + + Renderer.HostConfig.ContainerStyles.Default.BackgroundColor = Microsoft.UI.Colors.Transparent; + + var extensionAdaptiveCardPanel = new ExtensionAdaptiveCardPanel(); + extensionAdaptiveCardPanel.Bind(correctiveAction, Renderer); + extensionAdaptiveCardPanel.RequestedTheme = elementTheme; + + if (ActionCenterMessages.ExtensionAdaptiveCardPanel != null) + { + ActionCenterMessages.ExtensionAdaptiveCardPanel = null; + UpdateActionCenterMessage(ActionCenterMessages, ActionMessageRequestKind.Remove); + } + + ActionCenterMessages.ExtensionAdaptiveCardPanel = extensionAdaptiveCardPanel; + UpdateActionCenterMessage(ActionCenterMessages, ActionMessageRequestKind.Add); + }); + } + + private string BuildConfigurationUnitDescription(ConfigurationUnit unit) + { + var unitDescription = string.Empty; + + if (unit.Settings?.TryGetValue("description", out var descriptionObj) == true) + { + unitDescription = descriptionObj?.ToString() ?? string.Empty; + } + + if (string.IsNullOrEmpty(unit.Identifier) && string.IsNullOrEmpty(unitDescription)) + { + return _stringResource.GetLocalized(StringResourceKey.ConfigurationUnitSummaryMinimal, unit.Intent, unit.Type); + } + + if (string.IsNullOrEmpty(unit.Identifier)) + { + return _stringResource.GetLocalized(StringResourceKey.ConfigurationUnitSummaryNoId, unit.Intent, unit.Type, unitDescription); + } + + if (string.IsNullOrEmpty(unitDescription)) + { + return _stringResource.GetLocalized(StringResourceKey.ConfigurationUnitSummaryNoDescription, unit.Intent, unit.Type, unit.Identifier); + } + + return _stringResource.GetLocalized(StringResourceKey.ConfigurationUnitSummaryFull, unit.Intent, unit.Type, unit.Identifier, unitDescription); + } + + public void UpdateHostConfig() + { + if (Renderer != null) + { + _dispatcherQueue.TryEnqueue(() => + { + var elementTheme = _themeSelectorService.IsDarkTheme() ? ElementTheme.Dark : ElementTheme.Light; + + // Add host config for current theme to renderer + if (AdaptiveCardHostConfigs.TryGetValue(elementTheme, out var hostConfigContents)) + { + Renderer.HostConfig = AdaptiveHostConfig.FromJsonString(hostConfigContents).HostConfig; + + // Remove margins from selectAction. + Renderer.AddSelectActionMargin = false; + } + else + { + GlobalLog.Logger?.ReportInfo($"HostConfig file contents are null or empty - HostConfigFileContents: {hostConfigContents}"); + } + }); + } + } + + private void OnThemeChanged(object sender, ElementTheme e) => UpdateHostConfig(); +} diff --git a/tools/SetupFlow/DevHome.SetupFlow/Models/ConfigureTask.cs b/tools/SetupFlow/DevHome.SetupFlow/Models/ConfigureTask.cs index 316c9af07a..c0b3c481ce 100644 --- a/tools/SetupFlow/DevHome.SetupFlow/Models/ConfigureTask.cs +++ b/tools/SetupFlow/DevHome.SetupFlow/Models/ConfigureTask.cs @@ -8,6 +8,7 @@ using System.IO; using System.Linq; using System.Threading.Tasks; +using DevHome.Common.Views; using DevHome.SetupFlow.Common.Contracts; using DevHome.SetupFlow.Common.Helpers; using DevHome.SetupFlow.Services; @@ -26,6 +27,10 @@ public class ConfigureTask : ISetupTask public event ISetupTask.ChangeMessageHandler AddMessage; +#pragma warning disable 67 + public event ISetupTask.ChangeActionCenterMessageHandler UpdateActionCenterMessage; +#pragma warning restore 67 + // Configuration files can run as either admin or as a regular user // depending on the user, make this settable. public bool RequiresAdmin { get; set; } @@ -84,7 +89,7 @@ IAsyncOperation ISetupTask.Execute() { try { - AddMessage(_stringResource.GetLocalized(StringResourceKey.ApplyingConfigurationMessage)); + AddMessage(_stringResource.GetLocalized(StringResourceKey.ApplyingConfigurationMessage), MessageSeverityKind.Info); var result = await _dsc.ApplyConfigurationAsync(_file.Path, _activityId); RequiresReboot = result.RequiresReboot; UnitResults = result.Result.UnitResults.Select(unitResult => new ConfigurationUnitResult(unitResult)).ToList(); diff --git a/tools/SetupFlow/DevHome.SetupFlow/Models/CreateDevDriveTask.cs b/tools/SetupFlow/DevHome.SetupFlow/Models/CreateDevDriveTask.cs index f16ff2b1f0..36e2b27722 100644 --- a/tools/SetupFlow/DevHome.SetupFlow/Models/CreateDevDriveTask.cs +++ b/tools/SetupFlow/DevHome.SetupFlow/Models/CreateDevDriveTask.cs @@ -12,6 +12,7 @@ using DevHome.Common.Models; using DevHome.Common.ResultHelper; using DevHome.Common.Services; +using DevHome.Common.Views; using DevHome.SetupFlow.Common.Contracts; using DevHome.SetupFlow.Common.Helpers; using DevHome.SetupFlow.Common.TelemetryEvents; @@ -33,6 +34,10 @@ internal sealed class CreateDevDriveTask : ISetupTask public event ISetupTask.ChangeMessageHandler AddMessage; +#pragma warning disable 67 + public event ISetupTask.ChangeActionCenterMessageHandler UpdateActionCenterMessage; +#pragma warning restore 67 + public bool RequiresAdmin => true; public bool RequiresReboot => false; @@ -87,7 +92,7 @@ IAsyncOperation ISetupTask.Execute() { return Task.Run(() => { - AddMessage(_stringResource.GetLocalized(StringResourceKey.DevDriveNotAdminError)); + AddMessage(_stringResource.GetLocalized(StringResourceKey.DevDriveNotAdminError), MessageSeverityKind.Error); return TaskFinishedState.Failure; }).AsAsyncOperation(); } diff --git a/tools/SetupFlow/DevHome.SetupFlow/Models/ISetupTask.cs b/tools/SetupFlow/DevHome.SetupFlow/Models/ISetupTask.cs index 8304c9c96a..b306d683b3 100644 --- a/tools/SetupFlow/DevHome.SetupFlow/Models/ISetupTask.cs +++ b/tools/SetupFlow/DevHome.SetupFlow/Models/ISetupTask.cs @@ -3,11 +3,29 @@ extern alias Projection; +using System; +using DevHome.Common.Views; +using DevHome.SetupFlow.ViewModels; +using Microsoft.Windows.DevHome.SDK; using Projection::DevHome.SetupFlow.ElevatedComponent; using Windows.Foundation; namespace DevHome.SetupFlow.Models; +public enum ActionMessageRequestKind +{ + Add, + Remove, +} + +public enum MessageSeverityKind +{ + Info, + Success, + Warning, + Error, +} + /// /// A single atomic task to perform during the setup flow. /// @@ -92,10 +110,17 @@ public abstract bool DependsOnDevDriveToBeInstalled get; } - public delegate void ChangeMessageHandler(string message); + public delegate void ChangeMessageHandler(string message, MessageSeverityKind severityKind); /// /// Use this event to insert a message into the loading screen. /// public event ChangeMessageHandler AddMessage; + + public delegate void ChangeActionCenterMessageHandler(ActionCenterMessages message, ActionMessageRequestKind requestKind); + + /// + /// Use this event to insert a message into the action center of the loading screen. + /// + public event ChangeActionCenterMessageHandler UpdateActionCenterMessage; } diff --git a/tools/SetupFlow/DevHome.SetupFlow/Models/IWinGetPackage.cs b/tools/SetupFlow/DevHome.SetupFlow/Models/IWinGetPackage.cs index eaf61e0a76..2d96f8e737 100644 --- a/tools/SetupFlow/DevHome.SetupFlow/Models/IWinGetPackage.cs +++ b/tools/SetupFlow/DevHome.SetupFlow/Models/IWinGetPackage.cs @@ -2,6 +2,7 @@ // Licensed under the MIT License. using System; +using System.Collections.Generic; using DevHome.SetupFlow.Common.WindowsPackageManager; using DevHome.SetupFlow.Services; using Windows.Storage.Streams; @@ -57,11 +58,25 @@ public string Name } /// - /// Gets the version of the package which could be of any format supported - /// by WinGet package manager (e.g. alpha-numeric, 'Unknown', '1-preview, etc...). - /// + /// Gets the installed version of the package or null if the package is not installed /// - public string Version + public string InstalledVersion + { + get; + } + + /// + /// Gets the default version to install + /// + public string DefaultInstallVersion + { + get; + } + + /// + /// Gets the list of available versions for the package + /// + public IReadOnlyList AvailableVersions { get; } @@ -127,12 +142,13 @@ public string InstallationNotes /// /// Windows package manager service /// String resource service - /// WinGet factory + /// Version to install + /// Activity id /// Task object for installing this package InstallPackageTask CreateInstallTask( IWindowsPackageManager wpm, ISetupFlowStringResource stringResource, - WindowsPackageManagerFactory wingetFactory, + string installVersion, Guid activityId); } diff --git a/tools/SetupFlow/DevHome.SetupFlow/Models/InstallPackageTask.cs b/tools/SetupFlow/DevHome.SetupFlow/Models/InstallPackageTask.cs index 5f64b501c0..2f85b4acd1 100644 --- a/tools/SetupFlow/DevHome.SetupFlow/Models/InstallPackageTask.cs +++ b/tools/SetupFlow/DevHome.SetupFlow/Models/InstallPackageTask.cs @@ -6,6 +6,7 @@ using System; using System.Threading.Tasks; using DevHome.Common.TelemetryEvents.SetupFlow; +using DevHome.Common.Views; using DevHome.SetupFlow.Common.Contracts; using DevHome.SetupFlow.Common.Helpers; using DevHome.SetupFlow.Exceptions; @@ -26,6 +27,7 @@ public class InstallPackageTask : ISetupTask private readonly WinGetPackage _package; private readonly ISetupFlowStringResource _stringResource; private readonly Guid _activityId; + private readonly string _installVersion; private InstallResultStatus _installResultStatus; private uint _installerErrorCode; @@ -52,16 +54,24 @@ public bool DependsOnDevDriveToBeInstalled get; } + public string PackageName => _package.Name; + +#pragma warning disable 67 + public event ISetupTask.ChangeActionCenterMessageHandler UpdateActionCenterMessage; +#pragma warning restore 67 + public InstallPackageTask( IWindowsPackageManager wpm, ISetupFlowStringResource stringResource, WinGetPackage package, + string installVersion, Guid activityId) { _wpm = wpm; _stringResource = stringResource; _package = package; _activityId = activityId; + _installVersion = installVersion; } public TaskMessages GetLoadingMessages() @@ -103,6 +113,7 @@ public InstallPackageTaskArguments GetArguments() { PackageId = _package.Id, CatalogName = _package.CatalogName, + Version = _installVersion, }; } @@ -114,8 +125,9 @@ IAsyncOperation ISetupTask.Execute() try { Log.Logger?.ReportInfo(Log.Component.AppManagement, $"Starting installation of package {_package.Id}"); - AddMessage(_stringResource.GetLocalized(StringResourceKey.StartingInstallPackageMessage, _package.Id)); - var installResult = await _wpm.InstallPackageAsync(_package); + AddMessage(_stringResource.GetLocalized(StringResourceKey.StartingInstallPackageMessage, _package.Id), MessageSeverityKind.Info); + var packageUri = _package.GetUri(_installVersion); + var installResult = await _wpm.InstallPackageAsync(packageUri); RequiresReboot = installResult.RebootRequired; WasInstallSuccessful = true; @@ -151,8 +163,8 @@ IAsyncOperation ISetupTask.ExecuteAsAdmin(IElevatedComponentO try { Log.Logger?.ReportInfo(Log.Component.AppManagement, $"Starting installation with elevation of package {_package.Id}"); - AddMessage(_stringResource.GetLocalized(StringResourceKey.StartingInstallPackageMessage, _package.Id)); - var elevatedResult = await elevatedComponentOperation.InstallPackageAsync(_package.Id, _package.CatalogName); + AddMessage(_stringResource.GetLocalized(StringResourceKey.StartingInstallPackageMessage, _package.Id), MessageSeverityKind.Info); + var elevatedResult = await elevatedComponentOperation.InstallPackageAsync(_package.Id, _package.CatalogName, _installVersion); WasInstallSuccessful = elevatedResult.TaskSucceeded; RequiresReboot = elevatedResult.RebootRequired; _installResultStatus = (InstallResultStatus)elevatedResult.Status; diff --git a/tools/SetupFlow/DevHome.SetupFlow/Models/RepositoryProvider.cs b/tools/SetupFlow/DevHome.SetupFlow/Models/RepositoryProvider.cs index 87137cd410..c7b9dc3d94 100644 --- a/tools/SetupFlow/DevHome.SetupFlow/Models/RepositoryProvider.cs +++ b/tools/SetupFlow/DevHome.SetupFlow/Models/RepositoryProvider.cs @@ -38,7 +38,7 @@ internal sealed class RepositoryProvider /// /// Dictionary with all the repositories per account. /// - private Dictionary> _repositories = new(); + private readonly Dictionary> _repositories = new(); /// /// The DeveloperId provider used to log a user into an account. @@ -69,8 +69,15 @@ public void StartIfNotRunning() // The task.run inside GetProvider makes a deadlock when .Result is called. // https://stackoverflow.com/a/17248813. Solution is to wrap in Task.Run(). Log.Logger?.ReportInfo(Log.Component.RepoConfig, "Starting DevId and Repository provider extensions"); - _devIdProvider = Task.Run(() => _extensionWrapper.GetProviderAsync()).Result; - _repositoryProvider = Task.Run(() => _extensionWrapper.GetProviderAsync()).Result; + try + { + _devIdProvider = Task.Run(() => _extensionWrapper.GetProviderAsync()).Result; + _repositoryProvider = Task.Run(() => _extensionWrapper.GetProviderAsync()).Result; + } + catch (Exception ex) + { + Log.Logger?.ReportError(Log.Component.RepoConfig, $"Could not get repository provider from extension.", ex); + } } public IRepositoryProvider GetProvider() @@ -183,6 +190,7 @@ private async Task ConfigureLoginUIRenderer(AdaptiveCardRenderer renderer, Eleme // Add custom Adaptive Card renderer for LoginUI as done for Widgets. renderer.ElementRenderers.Set(LabelGroup.CustomTypeString, new LabelGroupRenderer()); + renderer.ElementRenderers.Set("Input.ChoiceSet", new AccessibleChoiceSet()); var hostConfigContents = string.Empty; var hostConfigFileName = (elementTheme == ElementTheme.Light) ? "LightHostConfig.json" : "DarkHostConfig.json"; @@ -203,6 +211,9 @@ private async Task ConfigureLoginUIRenderer(AdaptiveCardRenderer renderer, Eleme if (!string.IsNullOrEmpty(hostConfigContents)) { renderer.HostConfig = AdaptiveHostConfig.FromJsonString(hostConfigContents).HostConfig; + + // Remove margins from selectAction. + renderer.AddSelectActionMargin = false; } else { diff --git a/tools/SetupFlow/DevHome.SetupFlow/Models/TaskMessages.cs b/tools/SetupFlow/DevHome.SetupFlow/Models/TaskMessages.cs index 41f193b984..869842e0f4 100644 --- a/tools/SetupFlow/DevHome.SetupFlow/Models/TaskMessages.cs +++ b/tools/SetupFlow/DevHome.SetupFlow/Models/TaskMessages.cs @@ -40,6 +40,16 @@ public string NeedsReboot get; set; } + /// + /// Gets or sets the message that is displayed when an extension provides Dev Home with an adaptive card + /// while we're executing tasks. These adaptive cards can be used by the extensions to allow users to + /// perform a corrective action. + /// + public string ActionRequired + { + get; set; + } + public TaskMessages() { } diff --git a/tools/SetupFlow/DevHome.SetupFlow/Models/WinGetPackage.cs b/tools/SetupFlow/DevHome.SetupFlow/Models/WinGetPackage.cs index 97ff5ca0d7..e78d31fbbc 100644 --- a/tools/SetupFlow/DevHome.SetupFlow/Models/WinGetPackage.cs +++ b/tools/SetupFlow/DevHome.SetupFlow/Models/WinGetPackage.cs @@ -2,9 +2,10 @@ // Licensed under the MIT License. using System; +using System.Collections.Generic; +using System.Linq; using System.Threading; using DevHome.SetupFlow.Common.Helpers; -using DevHome.SetupFlow.Common.WindowsPackageManager; using DevHome.SetupFlow.Services; using Microsoft.Management.Deployment; using Windows.Storage.Streams; @@ -28,8 +29,10 @@ public WinGetPackage(CatalogPackage package, bool requiresElevated) CatalogName = package.DefaultInstallVersion.PackageCatalog.Info.Name; UniqueKey = new(Id, CatalogId); Name = package.Name; - Version = package.DefaultInstallVersion.Version; - IsInstalled = package.InstalledVersion != null; + AvailableVersions = package.AvailableVersions.Select(v => v.Version).ToList(); + InstalledVersion = FindVersion(package.AvailableVersions, package.InstalledVersion); + DefaultInstallVersion = FindVersion(package.AvailableVersions, package.DefaultInstallVersion); + IsInstalled = InstalledVersion != null; IsElevationRequired = requiresElevated; PackageUrl = GetMetadataValue(package, metadata => new Uri(metadata.PackageUrl), nameof(CatalogPackageMetadata.PackageUrl), null); PublisherUrl = GetMetadataValue(package, metadata => new Uri(metadata.PublisherUrl), nameof(CatalogPackageMetadata.PublisherUrl), null); @@ -47,7 +50,11 @@ public WinGetPackage(CatalogPackage package, bool requiresElevated) public string Name { get; } - public string Version { get; } + public string InstalledVersion { get; } + + public string DefaultInstallVersion { get; } + + public IReadOnlyList AvailableVersions { get; } public bool IsInstalled { get; } @@ -68,8 +75,10 @@ public WinGetPackage(CatalogPackage package, bool requiresElevated) public InstallPackageTask CreateInstallTask( IWindowsPackageManager wpm, ISetupFlowStringResource stringResource, - WindowsPackageManagerFactory wingetFactory, - Guid activityId) => new(wpm, stringResource, this, activityId); + string installVersion, + Guid activityId) => new(wpm, stringResource, this, installVersion, activityId); + + public WinGetPackageUri GetUri(string installVersion) => new(CatalogName, Id, new(installVersion)); /// /// Gets the package metadata from the current culture name (e.g. 'en-US') @@ -93,4 +102,37 @@ private T GetMetadataValue(CatalogPackage package, Func + /// Find the provided version in the list of available versions + /// + /// List of available versions + /// Version to find + /// Package version + private string FindVersion(IReadOnlyList availableVersions, PackageVersionInfo versionInfo) + { + if (versionInfo == null) + { + return null; + } + + // Best effort to find the version in the list of available versions + // If CompareToVersion throws an exception, we default to the version provided + try + { + for (var i = 0; i < availableVersions.Count; i++) + { + if (versionInfo.CompareToVersion(availableVersions[i].Version) == CompareResult.Equal) + { + return availableVersions[i].Version; + } + } + } + catch (Exception e) + { + Log.Logger?.ReportError(Log.Component.AppManagement, $"Unable to validate if the version {versionInfo.Version} is in the list of available versions", e); + } + + return versionInfo.Version; + } } diff --git a/tools/SetupFlow/DevHome.SetupFlow/Models/WinGetPackageUri.cs b/tools/SetupFlow/DevHome.SetupFlow/Models/WinGetPackageUri.cs new file mode 100644 index 0000000000..5e6eb40cf1 --- /dev/null +++ b/tools/SetupFlow/DevHome.SetupFlow/Models/WinGetPackageUri.cs @@ -0,0 +1,190 @@ +// Copyright (c) Microsoft Corporation. +// Licensed under the MIT License. + +using System; +using System.Diagnostics; + +namespace DevHome.SetupFlow.Models; + +/// +/// Windows package manager (winget) package Uri +/// +public class WinGetPackageUri +{ + /// + /// Windows package manager custom protocol scheme + /// + private const string Scheme = "x-ms-winget"; + + /// + /// Gets the catalog name + /// + public string CatalogName { get; private set; } + + /// + /// Gets the package id + /// + public string PackageId { get; private set; } + + /// + /// Gets the package options + /// + public WinGetPackageUriOptions Options { get; private set; } + + public WinGetPackageUri(string packageStringUri) + { + if (!ValidUriStructure(packageStringUri, out var packageUri)) + { + throw new UriFormatException($"Invalid winget package string uri {packageStringUri}"); + } + + // Create instance from Uri + InitializeFromUri(packageUri); + } + + public WinGetPackageUri(string catalogName, string packageId, WinGetPackageUriOptions options = null) + { + // Create intermediate Uri + var uriString = CreateValidWinGetPackageUriString(catalogName, packageId, options ?? new(), WinGetPackageUriParameters.All); + var uri = new Uri(uriString); + + // Create instance from Uri + InitializeFromUri(uri); + } + + private WinGetPackageUri(Uri packageUri) + { + // Private constructor expects a valid Uri + Debug.Assert(ValidUriStructure(packageUri), $"Expected a valid winget package Uri {packageUri}"); + InitializeFromUri(packageUri); + } + + /// + /// Create a package Uri from a Uri + /// + /// Uri + /// Output package Uri + /// True if the Uri is a valid winget package Uri + public static bool TryCreate(Uri uri, out WinGetPackageUri packageUri) + { + // Ensure the Uri is a WinGet Uri + if (ValidUriStructure(uri)) + { + packageUri = new(uri); + return true; + } + + packageUri = null; + return false; + } + + /// + /// Create a package Uri from a string + /// + /// String Uri + /// Output package Uri + /// True if the string Uri is a valid winget package Uri + public static bool TryCreate(string stringUri, out WinGetPackageUri packageUri) + { + // Ensure the Uri is a WinGet Uri + if (ValidUriStructure(stringUri, out var uri)) + { + packageUri = new(uri); + return true; + } + + packageUri = null; + return false; + } + + /// + /// Generate a string Uri from the package Uri + /// + /// Include parameters + /// Uri string + public string ToString(WinGetPackageUriParameters includeParameters) + { + return CreateValidWinGetPackageUriString(CatalogName, PackageId, Options, includeParameters); + } + + /// + /// Check if the package Uri is equal to the provided string Uri + /// + /// String Uri + /// Include parameters + /// True if the package Uri is equal to the string Uri + public bool Equals(string stringUri, WinGetPackageUriParameters includeParameters) + { + return TryCreate(stringUri, out var packageUri) && Equals(packageUri, includeParameters); + } + + /// + /// Check if the package Uri is equal to the provided package Uri + /// + /// Package Uri + /// Include parameters + /// True if the package Uri is equal to the Uri + public bool Equals(WinGetPackageUri packageUri, WinGetPackageUriParameters includeParameters) + { + if (packageUri == null) + { + return false; + } + + return CatalogName == packageUri.CatalogName && + PackageId == packageUri.PackageId && + Options.Equals(packageUri.Options, includeParameters); + } + + /// + public override bool Equals(object obj) => Equals(obj as WinGetPackageUri, WinGetPackageUriParameters.All); + + /// + public override string ToString() => ToString(WinGetPackageUriParameters.All); + + /// + public override int GetHashCode() => ToString().GetHashCode(); + + /// + /// Validate the string Uri and create a Uri + /// + /// String Uri + /// Output Uri + /// True if the string Uri is a valid winget package Uri + private static bool ValidUriStructure(string stringUri, out Uri uri) => Uri.TryCreate(stringUri, UriKind.Absolute, out uri) && ValidUriStructure(uri); + + /// + /// Validate the Uri structure + /// + /// Uri + /// True if the Uri is a valid winget package Uri + private static bool ValidUriStructure(Uri uri) => uri != null && uri.Scheme == Scheme && uri.Segments.Length == 2; + + /// + /// Initialize the package Uri from a valid Uri + /// + /// Valid package Uri + private void InitializeFromUri(Uri validUri) + { + Debug.Assert(ValidUriStructure(validUri), $"Expected a valid winget package Uri {validUri}"); + CatalogName = validUri.Host; + PackageId = validUri.Segments[1]; + Options = new(validUri); + } + + /// + /// Create a valid Uri string + /// + /// Catalog name + /// Package id + /// Options + /// Include parameters + /// Valid Uri string + private static string CreateValidWinGetPackageUriString(string catalogName, string packageId, WinGetPackageUriOptions options, WinGetPackageUriParameters includeParameters) + { + var queryString = options.ToString(includeParameters); + var uriString = $"{Scheme}://{catalogName}/{packageId}{queryString}"; + Debug.Assert(ValidUriStructure(uriString, out var _), $"Expected to generate a valid winget package Uri {uriString}"); + return uriString; + } +} diff --git a/tools/SetupFlow/DevHome.SetupFlow/Models/WinGetPackageUriOptions.cs b/tools/SetupFlow/DevHome.SetupFlow/Models/WinGetPackageUriOptions.cs new file mode 100644 index 0000000000..dceee49b63 --- /dev/null +++ b/tools/SetupFlow/DevHome.SetupFlow/Models/WinGetPackageUriOptions.cs @@ -0,0 +1,86 @@ +// Copyright (c) Microsoft Corporation. +// Licensed under the MIT License. + +using System; +using System.Web; + +namespace DevHome.SetupFlow.Models; + +/// +/// Windows package manager (winget) package Uri options +/// +public sealed class WinGetPackageUriOptions +{ + // Query parameter names + private const string VersionQueryParameter = "version"; + + public WinGetPackageUriOptions(string version = null) + { + Version = version; + } + + internal WinGetPackageUriOptions(Uri packageUri) + { + var queryParams = HttpUtility.ParseQueryString(packageUri.Query); + Version = queryParams.Get(VersionQueryParameter); + } + + /// + /// Gets the version of the package + /// + public string Version { get; } + + /// + /// Gets a value indicating whether the is specified + /// + public bool VersionSpecified => !string.IsNullOrWhiteSpace(Version); + + /// + /// Returns the string representation of the options + /// + /// The parameters to include in the string + /// Options as a string + public string ToString(WinGetPackageUriParameters includeParameters) + { + var queryParams = HttpUtility.ParseQueryString(string.Empty); + + // Add version + if (includeParameters.HasFlag(WinGetPackageUriParameters.Version) && VersionSpecified) + { + queryParams.Add(VersionQueryParameter, Version); + } + + return queryParams.Count > 0 ? $"?{queryParams}" : string.Empty; + } + + /// + /// Compares the options with another options + /// + /// Target options to compare + /// The parameters to include in the comparison + /// True if the options are equal; otherwise, false + public bool Equals(WinGetPackageUriOptions options, WinGetPackageUriParameters includeParameters) + { + if (options == null) + { + return false; + } + + // Check version + if (includeParameters.HasFlag(WinGetPackageUriParameters.Version) && Version != options.Version) + { + return false; + } + + return true; + } + + /// + public override string ToString() => ToString(WinGetPackageUriParameters.All); + + /// + public override bool Equals(object obj) => Equals(obj as WinGetPackageUriOptions, WinGetPackageUriParameters.All); + + /// + public override int GetHashCode() => ToString().GetHashCode(); +} diff --git a/tools/SetupFlow/DevHome.SetupFlow/Models/WinGetPackageUriParameters.cs b/tools/SetupFlow/DevHome.SetupFlow/Models/WinGetPackageUriParameters.cs new file mode 100644 index 0000000000..806c00abb4 --- /dev/null +++ b/tools/SetupFlow/DevHome.SetupFlow/Models/WinGetPackageUriParameters.cs @@ -0,0 +1,16 @@ +// Copyright (c) Microsoft Corporation. +// Licensed under the MIT License. + +using System; + +namespace DevHome.SetupFlow.Models; + +[Flags] +public enum WinGetPackageUriParameters +{ + None = 0, + Version = 1 << 0, + + // Add all parameters here + All = Version, +} diff --git a/tools/SetupFlow/DevHome.SetupFlow/Models/WingetConfigure/GitDscSettings.cs b/tools/SetupFlow/DevHome.SetupFlow/Models/WingetConfigure/GitDscSettings.cs new file mode 100644 index 0000000000..83155fbf80 --- /dev/null +++ b/tools/SetupFlow/DevHome.SetupFlow/Models/WingetConfigure/GitDscSettings.cs @@ -0,0 +1,18 @@ +// Copyright (c) Microsoft Corporation. +// Licensed under the MIT License. + +using Newtonsoft.Json; + +namespace DevHome.SetupFlow.Models.WingetConfigure; + +/// +/// Represents settings for a GitDsc resource. +/// See: https://learn.microsoft.com/windows/package-manager/configuration/create +/// See: https://github.com/microsoft/devhome/blob/main/sampleConfigurations/DscResources/GitDsc/CloneWingetRepository.yaml +/// +public class GitDscSettings : WinGetConfigSettingsBase +{ + public string HttpsUrl { get; set; } + + public string RootDirectory { get; set; } +} diff --git a/tools/SetupFlow/DevHome.SetupFlow/Models/WingetConfigure/SDKApplyConfigurationResult.cs b/tools/SetupFlow/DevHome.SetupFlow/Models/WingetConfigure/SDKApplyConfigurationResult.cs new file mode 100644 index 0000000000..3c7ad3e84e --- /dev/null +++ b/tools/SetupFlow/DevHome.SetupFlow/Models/WingetConfigure/SDKApplyConfigurationResult.cs @@ -0,0 +1,30 @@ +// Copyright (c) Microsoft Corporation. +// Licensed under the MIT License. + +using System; +using Microsoft.Windows.DevHome.SDK; + +namespace DevHome.SetupFlow.Models.WingetConfigure; + +public class SDKApplyConfigurationResult +{ + public SDKApplyConfigurationSetResult ApplyResult { get; private set; } + + public SDKOpenConfigurationSetResult OpenResult { get; private set; } + + public ProviderOperationResult ProviderResult { get; private set; } + + public string ResultDescription { get; private set; } + + public SDKApplyConfigurationResult(ProviderOperationResult providerResult, SDKApplyConfigurationSetResult applyResult, SDKOpenConfigurationSetResult openResult) + { + ApplyResult = applyResult; + OpenResult = openResult; + ProviderResult = providerResult; + ResultDescription = providerResult.DisplayMessage; + } + + public bool ApplyConfigSucceeded => ApplyResult.Succeeded; + + public bool OpenConfigSucceeded => OpenResult.Succeeded; +} diff --git a/tools/SetupFlow/DevHome.SetupFlow/Models/WingetConfigure/SDKApplyConfigurationSetResult.cs b/tools/SetupFlow/DevHome.SetupFlow/Models/WingetConfigure/SDKApplyConfigurationSetResult.cs new file mode 100644 index 0000000000..fbe722f25f --- /dev/null +++ b/tools/SetupFlow/DevHome.SetupFlow/Models/WingetConfigure/SDKApplyConfigurationSetResult.cs @@ -0,0 +1,44 @@ +// Copyright (c) Microsoft Corporation. +// Licensed under the MIT License. + +using System; +using System.Collections.Generic; +using System.Linq; +using System.Text; +using System.Threading.Tasks; +using DevHome.SetupFlow.Exceptions; +using SDK = Microsoft.Windows.DevHome.SDK; + +namespace DevHome.SetupFlow.Models.WingetConfigure; + +public class SDKApplyConfigurationSetResult +{ + public SDK.ApplyConfigurationSetResult Result + { + get; + } + + public bool Succeeded { get; private set; } + + public bool RequiresReboot => Result?.UnitResults?.Any(result => result.RebootRequired) ?? false; + + public Exception ResultException { get; private set; } + + public SDKApplyConfigurationSetResult(SDK.ApplyConfigurationSetResult result) + { + Result = result; + var isResultSetNull = Result == null; + Succeeded = !isResultSetNull && (Result.ResultCode == null || Result.ResultCode?.HResult == 0); + + if (isResultSetNull) + { + ResultException = new SDKApplyConfigurationSetResultException("Unable to get the result of the applied configuration as it was null."); + } + else + { + ResultException = Result.ResultCode; + } + } + + public bool AreConfigUnitsAvailable => Result?.UnitResults != null && Result.UnitResults.Count > 0; +} diff --git a/tools/SetupFlow/DevHome.SetupFlow/Models/WingetConfigure/SDKConfigurationSetChangeWrapper.cs b/tools/SetupFlow/DevHome.SetupFlow/Models/WingetConfigure/SDKConfigurationSetChangeWrapper.cs new file mode 100644 index 0000000000..8ee9fee759 --- /dev/null +++ b/tools/SetupFlow/DevHome.SetupFlow/Models/WingetConfigure/SDKConfigurationSetChangeWrapper.cs @@ -0,0 +1,140 @@ +// Copyright (c) Microsoft Corporation. +// Licensed under the MIT License. + +using System; +using System.Collections.Generic; +using System.Globalization; +using System.Linq; +using System.Text; +using System.Threading.Tasks; +using DevHome.SetupFlow.Services; +using SDK = Microsoft.Windows.DevHome.SDK; + +namespace DevHome.SetupFlow.Models.WingetConfigure; + +public class SDKConfigurationSetChangeWrapper +{ + private readonly ISetupFlowStringResource _stringResource; + + private readonly SDK.ConfigurationSetChangeData _configurationSetChangeData; + + private string Id { get; set; } + + public SDKConfigurationSetChangeWrapper(SDK.ConfigurationSetChangeData configurationUnitState, ISetupFlowStringResource setupFlowStringResource) + { + _configurationSetChangeData = configurationUnitState; + _stringResource = setupFlowStringResource; + + ShortFailureDescription = ResultInformation?.Description; + DetailedFailureDescription = ResultInformation?.Details; + } + + // The change event type that occurred. + public SDK.ConfigurationSetChangeEventType Change => _configurationSetChangeData.Change; + + // The state of the configuration set for this event (the ConfigurationSet can be used to get the current state, which may be different). + public SDK.ConfigurationSetState SetState => _configurationSetChangeData.SetState; + + // The state of the configuration unit for this event (the ConfigurationUnit can be used to get the current state, which may be different). + public SDK.ConfigurationUnitState UnitState => _configurationSetChangeData.UnitState; + + // Contains information on the result of the attempt to apply the configuration unit. + public SDK.ConfigurationUnitResultInformation ResultInformation => _configurationSetChangeData.ResultInformation; + + // The result from opening the set. + public string DetailedFailureDescription { get; private set; } + + public string ShortFailureDescription { get; private set; } + + // The configuration unit whose state changed. + public SDK.ConfigurationUnit Unit => _configurationSetChangeData.Unit; + + public bool IsErrorMessagePresent => ResultInformation != null && (!string.IsNullOrEmpty(DetailedFailureDescription) || !string.IsNullOrEmpty(ShortFailureDescription)); + + public string ConfigurationSetState + { + get + { + switch (SetState) + { + case SDK.ConfigurationSetState.Unknown: + return _stringResource.GetLocalized(StringResourceKey.SetupTargetConfigurationUnknown); + case SDK.ConfigurationSetState.Pending: + return _stringResource.GetLocalized(StringResourceKey.SetupTargetConfigurationPending); + case SDK.ConfigurationSetState.InProgress: + return _stringResource.GetLocalized(StringResourceKey.SetupTargetConfigurationInProgress); + case SDK.ConfigurationSetState.Completed: + return _stringResource.GetLocalized(StringResourceKey.SetupTargetConfigurationCompleted); + case SDK.ConfigurationSetState.ShuttingDownDevice: + return _stringResource.GetLocalized(StringResourceKey.SetupTargetConfigurationShuttingDownDevice); + case SDK.ConfigurationSetState.StartingDevice: + return _stringResource.GetLocalized(StringResourceKey.SetupTargetConfigurationStartingDevice); + case SDK.ConfigurationSetState.RestartingDevice: + return _stringResource.GetLocalized(StringResourceKey.SetupTargetConfigurationRestartingDevice); + case SDK.ConfigurationSetState.ProvisioningDevice: + return _stringResource.GetLocalized(StringResourceKey.SetupTargetConfigurationProvisioningDevice); + case SDK.ConfigurationSetState.WaitingForAdminUserLogon: + return _stringResource.GetLocalized(StringResourceKey.SetupTargetConfigurationWaitingForAdminUserLogon); + case SDK.ConfigurationSetState.WaitingForUserLogon: + return _stringResource.GetLocalized(StringResourceKey.SetupTargetConfigurationWaitingForUserLogon); + default: + return _stringResource.GetLocalized(StringResourceKey.SetupTargetConfigurationUnknown); + } + } + } + + public string ConfigurationUnitState + { + get + { + switch (UnitState) + { + case SDK.ConfigurationUnitState.Unknown: + return _stringResource.GetLocalized(StringResourceKey.SetupTargetConfigurationUnitUnknown); + case SDK.ConfigurationUnitState.Pending: + return _stringResource.GetLocalized(StringResourceKey.SetupTargetConfigurationUnitPending); + case SDK.ConfigurationUnitState.InProgress: + return _stringResource.GetLocalized(StringResourceKey.SetupTargetConfigurationUnitInProgress); + case SDK.ConfigurationUnitState.Completed: + return _stringResource.GetLocalized(StringResourceKey.SetupTargetConfigurationUnitCompleted); + case SDK.ConfigurationUnitState.Skipped: + return _stringResource.GetLocalized(StringResourceKey.SetupTargetConfigurationUnitSkipped); + default: + return _stringResource.GetLocalized(StringResourceKey.SetupTargetConfigurationUnitUnknown); + } + } + } + + public override string ToString() + { + var configSetName = _stringResource.GetLocalized(StringResourceKey.SetupTargetConfigurationSetProgressMessage, SetState); + var configUnitName = _stringResource.GetLocalized(StringResourceKey.SetupTargetConfigurationUnitProgressMessage, ConfigurationUnitState); + + var stringBuilder = new StringBuilder(); + stringBuilder.AppendLine(CultureInfo.CurrentCulture, $"{configSetName} "); + stringBuilder.AppendLine(CultureInfo.CurrentCulture, $"{configUnitName} "); + + return stringBuilder.ToString(); + } + + public string GetErrorMessagesForDisplay() + { + var resourceName = Unit?.Type ?? _stringResource.GetLocalized(StringResourceKey.SetupTargetUnknownStatus); + + if (string.IsNullOrEmpty(ShortFailureDescription)) + { + return _stringResource.GetLocalized(StringResourceKey.SetupTargetConfigurationUnitProgressError, resourceName); + } + + return _stringResource.GetLocalized(StringResourceKey.SetupTargetConfigurationUnitProgressErrorWithMsg, resourceName, ShortFailureDescription); + } + + public string GetErrorMessageForLogging() + { + var stringBuilder = new StringBuilder(); + stringBuilder.AppendLine(CultureInfo.InvariantCulture, $"Short error description: {ShortFailureDescription} "); + stringBuilder.AppendLine(CultureInfo.InvariantCulture, $"Detailed error description: {DetailedFailureDescription} "); + + return stringBuilder.ToString(); + } +} diff --git a/tools/SetupFlow/DevHome.SetupFlow/Models/WingetConfigure/SDKConfigurationUnitWrapper.cs b/tools/SetupFlow/DevHome.SetupFlow/Models/WingetConfigure/SDKConfigurationUnitWrapper.cs new file mode 100644 index 0000000000..25739ef2c6 --- /dev/null +++ b/tools/SetupFlow/DevHome.SetupFlow/Models/WingetConfigure/SDKConfigurationUnitWrapper.cs @@ -0,0 +1,49 @@ +// Copyright (c) Microsoft Corporation. +// Licensed under the MIT License. + +using System; +using System.Collections.Generic; +using System.Linq; +using System.Text; +using System.Threading.Tasks; + +using SDK = Microsoft.Windows.DevHome.SDK; + +namespace DevHome.SetupFlow.Models.WingetConfigure; + +public class SDKConfigurationUnitWrapper +{ + public SDKConfigurationUnitWrapper(SDK.ConfigurationUnit configurationUnit) + { + Type = configurationUnit.Type.Clone() as string; + Identifier = configurationUnit.Identifier.Clone() as string; + State = configurationUnit.State; + IsGroup = configurationUnit.IsGroup; + + if (configurationUnit.Units != null) + { + foreach (var unit in configurationUnit.Units) + { + UnitWrappers.Add(new SDKConfigurationUnitWrapper(unit)); + } + } + } + + // The type of the unit being configured; not a name for this instance. + public string Type { get; private set; } + + // The identifier name of this instance within the set. + public string Identifier { get; private set; } + + // The current state of the configuration unit. + public SDK.ConfigurationUnitState State { get; private set; } + + // Determines if this configuration unit should be treated as a group. + // A configuration unit group treats its `Settings` as the definition of child units. + public bool IsGroup { get; private set; } + + // The configuration units that are part of this unit (if IsGroup is true). + public List Units { get; private set; } = new(); + + public List UnitWrappers { get; private set; } = new(); +} diff --git a/tools/SetupFlow/DevHome.SetupFlow/Models/WingetConfigure/SDKOpenConfigurationSetResult.cs b/tools/SetupFlow/DevHome.SetupFlow/Models/WingetConfigure/SDKOpenConfigurationSetResult.cs new file mode 100644 index 0000000000..abd04e6dce --- /dev/null +++ b/tools/SetupFlow/DevHome.SetupFlow/Models/WingetConfigure/SDKOpenConfigurationSetResult.cs @@ -0,0 +1,63 @@ +// Copyright (c) Microsoft Corporation. +// Licensed under the MIT License. + +using System; +using System.Collections.Generic; +using System.Linq; +using System.Text; +using System.Threading.Tasks; +using DevHome.SetupFlow.Common.Helpers; +using DevHome.SetupFlow.Exceptions; +using DevHome.SetupFlow.Services; +using SDK = Microsoft.Windows.DevHome.SDK; + +namespace DevHome.SetupFlow.Models.WingetConfigure; + +public class SDKOpenConfigurationSetResult +{ + private readonly ISetupFlowStringResource _setupFlowStringResource; + + public SDKOpenConfigurationSetResult(SDK.OpenConfigurationSetResult result, ISetupFlowStringResource setupFlowStringResource) + { + Result = result; + _setupFlowStringResource = setupFlowStringResource; + + if (Result != null) + { + Field = new(result.Field); + Line = result.Line; + Column = result.Column; + Value = new(result.Value); + ResultCode = Result.ResultCode; + Succeeded = ResultCode == null || ResultCode?.HResult == 0; + } + } + + public bool Succeeded { get; private set; } + + public SDK.OpenConfigurationSetResult Result { get; private set; } + + public Exception ResultCode { get; private set; } + + // The field that is missing/invalid, if appropriate for the specific ResultCode. + public string Field { get; } = string.Empty; + + // The value of the field, if appropriate for the specific ResultCode. + public string Value { get; } = string.Empty; + + // The line number for the failure reason, if determined. + public uint Line { get; } + + // The column number for the failure reason, if determined. + public uint Column { get; } + + public string GetErrorMessage() + { + Log.Logger?.ReportError( + Log.Component.SDKOpenConfigurationSetResult, + $"Extension failed to open the configuration file provided by Dev Home: Field: {Field}, Value: {Value}, Line: {Line}, Column: {Column}", + ResultCode); + + return _setupFlowStringResource.GetLocalized(StringResourceKey.SetupTargetConfigurationOpenConfigFailed); + } +} diff --git a/tools/SetupFlow/DevHome.SetupFlow/Models/WingetConfigure/WinGetConfigAssertion.cs b/tools/SetupFlow/DevHome.SetupFlow/Models/WingetConfigure/WinGetConfigAssertion.cs new file mode 100644 index 0000000000..5823843928 --- /dev/null +++ b/tools/SetupFlow/DevHome.SetupFlow/Models/WingetConfigure/WinGetConfigAssertion.cs @@ -0,0 +1,19 @@ +// Copyright (c) Microsoft Corporation. +// Licensed under the MIT License. + +using Newtonsoft.Json; + +namespace DevHome.SetupFlow.Models.WingetConfigure; + +/// +/// Represents the assertions in the WinGet config file. +/// See: https://learn.microsoft.com/windows/package-manager/configuration/create +/// +public class WinGetConfigAssertion +{ + public string Resource { get; set; } + + public WingGetConfigDirectives Directives { get; set; } + + public WinGetConfigSettingsBase Settings { get; set; } +} diff --git a/tools/SetupFlow/DevHome.SetupFlow/Models/WingetConfigure/WinGetConfigFile.cs b/tools/SetupFlow/DevHome.SetupFlow/Models/WingetConfigure/WinGetConfigFile.cs new file mode 100644 index 0000000000..111f4f61ec --- /dev/null +++ b/tools/SetupFlow/DevHome.SetupFlow/Models/WingetConfigure/WinGetConfigFile.cs @@ -0,0 +1,20 @@ +// Copyright (c) Microsoft Corporation. +// Licensed under the MIT License. + +using System; +using System.Collections.Generic; +using System.Linq; +using System.Text; +using System.Threading.Tasks; +using Newtonsoft.Json; + +namespace DevHome.SetupFlow.Models.WingetConfigure; + +/// +/// The properties of the WinGet config file. +/// See: https://learn.microsoft.com/windows/package-manager/configuration/create +/// +public class WinGetConfigFile +{ + public WinGetConfigProperties Properties { get; set; } +} diff --git a/tools/SetupFlow/DevHome.SetupFlow/Models/WingetConfigure/WinGetConfigProperties.cs b/tools/SetupFlow/DevHome.SetupFlow/Models/WingetConfigure/WinGetConfigProperties.cs new file mode 100644 index 0000000000..9c814f1de8 --- /dev/null +++ b/tools/SetupFlow/DevHome.SetupFlow/Models/WingetConfigure/WinGetConfigProperties.cs @@ -0,0 +1,19 @@ +// Copyright (c) Microsoft Corporation. +// Licensed under the MIT License. + +using Newtonsoft.Json; + +namespace DevHome.SetupFlow.Models.WingetConfigure; + +/// +/// Properties of the WinGet configuration file. +/// See: https://learn.microsoft.com/windows/package-manager/configuration/create +/// +public class WinGetConfigProperties +{ + public WinGetConfigAssertion[] Assertions { get; set; } + + public WinGetConfigResource[] Resources { get; set; } + + public string ConfigurationVersion { get; set; } +} diff --git a/tools/SetupFlow/DevHome.SetupFlow/Models/WingetConfigure/WinGetConfigResource.cs b/tools/SetupFlow/DevHome.SetupFlow/Models/WingetConfigure/WinGetConfigResource.cs new file mode 100644 index 0000000000..40e3836d8f --- /dev/null +++ b/tools/SetupFlow/DevHome.SetupFlow/Models/WingetConfigure/WinGetConfigResource.cs @@ -0,0 +1,23 @@ +// Copyright (c) Microsoft Corporation. +// Licensed under the MIT License. + +using Newtonsoft.Json; + +namespace DevHome.SetupFlow.Models.WingetConfigure; + +/// +/// Represents a resource block in the WinGet config file. +/// See: https://learn.microsoft.com/windows/package-manager/configuration/create +/// +public class WinGetConfigResource +{ + public string Resource { get; set; } + + public WingGetConfigDirectives Directives { get; set; } + + public WinGetConfigSettingsBase Settings { get; set; } + + public string Id { get; set; } + + public string[] DependsOn { get; set; } +} diff --git a/tools/SetupFlow/DevHome.SetupFlow/Models/WingetConfigure/WinGetConfigSettingsBase.cs b/tools/SetupFlow/DevHome.SetupFlow/Models/WingetConfigure/WinGetConfigSettingsBase.cs new file mode 100644 index 0000000000..7a7356d675 --- /dev/null +++ b/tools/SetupFlow/DevHome.SetupFlow/Models/WingetConfigure/WinGetConfigSettingsBase.cs @@ -0,0 +1,17 @@ +// Copyright (c) Microsoft Corporation. +// Licensed under the MIT License. + +using Newtonsoft.Json; + +namespace DevHome.SetupFlow.Models.WingetConfigure; + +/// +/// Base for the settings of a WinGet config resource. +/// Ensure is not included because it may or may not be handled by the resource +/// But is a fundamental concept of Dsc resources so it is included in the base. +/// See: https://learn.microsoft.com/windows/package-manager/configuration/create +/// +public abstract class WinGetConfigSettingsBase +{ + public string Ensure { get; set; } +} diff --git a/tools/SetupFlow/DevHome.SetupFlow/Models/WingetConfigure/WinGetDscSettings.cs b/tools/SetupFlow/DevHome.SetupFlow/Models/WingetConfigure/WinGetDscSettings.cs new file mode 100644 index 0000000000..03a295cd87 --- /dev/null +++ b/tools/SetupFlow/DevHome.SetupFlow/Models/WingetConfigure/WinGetDscSettings.cs @@ -0,0 +1,17 @@ +// Copyright (c) Microsoft Corporation. +// Licensed under the MIT License. + +using Newtonsoft.Json; + +namespace DevHome.SetupFlow.Models.WingetConfigure; + +/// +/// Represents the settings for a WinGetDsc resource. +/// See: https://learn.microsoft.com/windows/package-manager/configuration/create +/// +public class WinGetDscSettings : WinGetConfigSettingsBase +{ + public string Id { get; set; } + + public string Source { get; set; } +} diff --git a/tools/SetupFlow/DevHome.SetupFlow/Models/WingetConfigure/WingGetConfigDirectives.cs b/tools/SetupFlow/DevHome.SetupFlow/Models/WingetConfigure/WingGetConfigDirectives.cs new file mode 100644 index 0000000000..ec154cf784 --- /dev/null +++ b/tools/SetupFlow/DevHome.SetupFlow/Models/WingetConfigure/WingGetConfigDirectives.cs @@ -0,0 +1,19 @@ +// Copyright (c) Microsoft Corporation. +// Licensed under the MIT License. + +using Newtonsoft.Json; + +namespace DevHome.SetupFlow.Models.WingetConfigure; + +/// +/// Represents the directives in the WinGet config file. +/// See: https://learn.microsoft.com/windows/package-manager/configuration/create +/// +public class WingGetConfigDirectives +{ + public string Description { get; set; } + + public bool AllowPrerelease { get; set; } + + public string Module { get; set; } +} diff --git a/tools/SetupFlow/DevHome.SetupFlow/Selectors/Environments/ComputeSystemsListViewModelSelector.cs b/tools/SetupFlow/DevHome.SetupFlow/Selectors/Environments/ComputeSystemsListViewModelSelector.cs new file mode 100644 index 0000000000..d946cbbdaf --- /dev/null +++ b/tools/SetupFlow/DevHome.SetupFlow/Selectors/Environments/ComputeSystemsListViewModelSelector.cs @@ -0,0 +1,68 @@ +// Copyright (c) Microsoft Corporation. +// Licensed under the MIT License. + +using DevHome.Common.Services; +using DevHome.SetupFlow.Models.Environments; +using Microsoft.UI.Xaml; +using Microsoft.UI.Xaml.Controls; +using Microsoft.Windows.DevHome.SDK; + +namespace DevHome.SetupFlow.Selectors.Environments; + +/// +/// Data template class for selecting the list view template for the setup target page. +/// +public class ComputeSystemsListViewModelSelector : DataTemplateSelector +{ + /// + /// Gets or sets the template when compute systems are loaded into the ComputeSystemsListViewModel's ownerlist. + /// + public DataTemplate ComputeSystemsListViewModelLoadedTemplate { get; set; } + + /// + /// Gets or sets the template when there is a non-interactable error that occured when loading the ComputeSystemsListViewModel's list + /// + public DataTemplate ComputeSystemsListViewModelLoadingErrorTemplate { get; set; } + + /// + /// Gets or sets the template when there are no ComputeSystemCardViewModels available in a ComputeSystemsListViewModel's list. + /// + public DataTemplate NoComputeSystemCardViewModelsAvailableTemplate { get; set; } + + protected override DataTemplate SelectTemplateCore(object item) + { + return ResolveDataTemplate(item); + } + + protected override DataTemplate SelectTemplateCore(object item, DependencyObject container) + { + return ResolveDataTemplate(item); + } + + /// + /// Resolves the data template based on the if the ComputeSystemsListViewModel currently containers any ComputeSystemWrappers. + /// + /// The ComputeSystemsListViewModel object + private DataTemplate ResolveDataTemplate(object item) + { + if (item is ComputeSystemsListViewModel listViewModel) + { + if (listViewModel.CurrentResult.Result.Status == ProviderOperationStatus.Failure) + { + return ComputeSystemsListViewModelLoadingErrorTemplate; + } + + if (listViewModel.ComputeSystemCardAdvancedCollectionView.Count > 0) + { + return ComputeSystemsListViewModelLoadedTemplate; + } + + if (listViewModel.ComputeSystemCardAdvancedCollectionView.Count == 0) + { + return NoComputeSystemCardViewModelsAvailableTemplate; + } + } + + return ComputeSystemsListViewModelLoadingErrorTemplate; + } +} diff --git a/tools/SetupFlow/DevHome.SetupFlow/Selectors/ReviewTabViewSelector.cs b/tools/SetupFlow/DevHome.SetupFlow/Selectors/ReviewTabViewSelector.cs index 3118bfdc7b..38d8d86558 100644 --- a/tools/SetupFlow/DevHome.SetupFlow/Selectors/ReviewTabViewSelector.cs +++ b/tools/SetupFlow/DevHome.SetupFlow/Selectors/ReviewTabViewSelector.cs @@ -1,59 +1,65 @@ -// Copyright (c) Microsoft Corporation. -// Licensed under the MIT License. - -using System; -using DevHome.SetupFlow.ViewModels; -using Microsoft.UI.Xaml; -using Microsoft.UI.Xaml.Controls; - -namespace DevHome.SetupFlow.Selectors; - -/// -/// Data template selector class for rendering the current active tab in the review page. -/// For example, if the DevDriveReviewViewModel is currently bound to the -/// content control, then the DevDriveReviewView will render. -/// -public class ReviewTabViewSelector : DataTemplateSelector -{ - public DataTemplate DevDriveTabTemplate - { - get; set; - } - - public DataTemplate RepoConfigTabTemplate - { - get; set; - } - - public DataTemplate AppManagementTabTemplate - { - get; set; - } - - protected override DataTemplate SelectTemplateCore(object item) - { - return ResolveDataTemplate(item, () => base.SelectTemplateCore(item)); - } - - protected override DataTemplate SelectTemplateCore(object item, DependencyObject container) - { - return ResolveDataTemplate(item, () => base.SelectTemplateCore(item, container)); - } - - /// - /// Resolve the data template for the given object type. - /// - /// Selected item. - /// Default data template function. - /// Data template or default data template if no corresponding data template was found. - private DataTemplate ResolveDataTemplate(object item, Func defaultDataTemplate) - { - return item switch - { - DevDriveReviewViewModel => DevDriveTabTemplate, - RepoConfigReviewViewModel => RepoConfigTabTemplate, - AppManagementReviewViewModel => AppManagementTabTemplate, - _ => defaultDataTemplate(), - }; - } -} +// Copyright (c) Microsoft Corporation. +// Licensed under the MIT License. + +using System; +using DevHome.SetupFlow.ViewModels; +using Microsoft.UI.Xaml; +using Microsoft.UI.Xaml.Controls; + +namespace DevHome.SetupFlow.Selectors; + +/// +/// Data template selector class for rendering the current active tab in the review page. +/// For example, if the DevDriveReviewViewModel is currently bound to the +/// content control, then the DevDriveReviewView will render. +/// +public class ReviewTabViewSelector : DataTemplateSelector +{ + public DataTemplate DevDriveTabTemplate + { + get; set; + } + + public DataTemplate RepoConfigTabTemplate + { + get; set; + } + + public DataTemplate AppManagementTabTemplate + { + get; set; + } + + public DataTemplate SetupTargetTabTemplate + { + get; set; + } + + protected override DataTemplate SelectTemplateCore(object item) + { + return ResolveDataTemplate(item, () => base.SelectTemplateCore(item)); + } + + protected override DataTemplate SelectTemplateCore(object item, DependencyObject container) + { + return ResolveDataTemplate(item, () => base.SelectTemplateCore(item, container)); + } + + /// + /// Resolve the data template for the given object type. + /// + /// Selected item. + /// Default data template function. + /// Data template or default data template if no corresponding data template was found. + private DataTemplate ResolveDataTemplate(object item, Func defaultDataTemplate) + { + return item switch + { + DevDriveReviewViewModel => DevDriveTabTemplate, + RepoConfigReviewViewModel => RepoConfigTabTemplate, + AppManagementReviewViewModel => AppManagementTabTemplate, + SetupTargetReviewViewModel => SetupTargetTabTemplate, + _ => defaultDataTemplate(), + }; + } +} diff --git a/tools/SetupFlow/DevHome.SetupFlow/Selectors/SetupFlowViewSelector.cs b/tools/SetupFlow/DevHome.SetupFlow/Selectors/SetupFlowViewSelector.cs index 0f652a3f7a..399b5a2f6a 100644 --- a/tools/SetupFlow/DevHome.SetupFlow/Selectors/SetupFlowViewSelector.cs +++ b/tools/SetupFlow/DevHome.SetupFlow/Selectors/SetupFlowViewSelector.cs @@ -18,6 +18,8 @@ public class SetupFlowViewSelector : DataTemplateSelector public DataTemplate MainPageTemplate { get; set; } public DataTemplate RepoConfigTemplate { get; set; } + + public DataTemplate SetupTargetTemplate { get; set; } public DataTemplate AppManagementTemplate { get; set; } @@ -55,7 +57,8 @@ private DataTemplate ResolveDataTemplate(object item, Func default ReviewViewModel => ReviewTemplate, LoadingViewModel => LoadingTemplate, SummaryViewModel => SummaryTemplate, - ConfigurationFileViewModel => ConfigurationFileTemplate, + ConfigurationFileViewModel => ConfigurationFileTemplate, + SetupTargetViewModel => SetupTargetTemplate, _ => defaultDataTemplate(), }; } diff --git a/tools/SetupFlow/DevHome.SetupFlow/Services/ComputeSystemViewModelFactory.cs b/tools/SetupFlow/DevHome.SetupFlow/Services/ComputeSystemViewModelFactory.cs new file mode 100644 index 0000000000..3a68e98381 --- /dev/null +++ b/tools/SetupFlow/DevHome.SetupFlow/Services/ComputeSystemViewModelFactory.cs @@ -0,0 +1,39 @@ +// Copyright (c) Microsoft Corporation. +// Licensed under the MIT License. + +using System; +using System.Collections.Generic; +using System.Threading.Tasks; +using DevHome.Common.Environments.Helpers; +using DevHome.Common.Environments.Models; +using DevHome.Common.Environments.Services; +using DevHome.SetupFlow.Common.Helpers; +using DevHome.SetupFlow.ViewModels.Environments; + +namespace DevHome.Common.Services; + +/// +/// Factory class for creating instances asynchronously. +/// +public class ComputeSystemViewModelFactory +{ + public async Task CreateCardViewModelAsync(IComputeSystemManager manager, ComputeSystem computeSystem, ComputeSystemProvider provider, string packageFullName) + { + var cardViewModel = new ComputeSystemCardViewModel(computeSystem, manager); + + try + { + cardViewModel.CardState = await cardViewModel.GetCardStateAsync(); + cardViewModel.ComputeSystemImage = await ComputeSystemHelpers.GetBitmapImageAsync(computeSystem); + cardViewModel.ComputeSystemProviderName = provider.DisplayName; + cardViewModel.ComputeSystemProviderImage = CardProperty.ConvertMsResourceToIcon(provider.Icon, packageFullName); + cardViewModel.ComputeSystemProperties = await ComputeSystemHelpers.GetComputeSystemPropertiesAsync(computeSystem, packageFullName); + } + catch (Exception ex) + { + Log.Logger.ReportError(Log.Component.ComputeSystemViewModelFactory, $"Failed to get initial properties for compute system {computeSystem}. Error: {ex.Message}"); + } + + return cardViewModel; + } +} diff --git a/tools/SetupFlow/DevHome.SetupFlow/Services/ConfigurationFileBuilder.cs b/tools/SetupFlow/DevHome.SetupFlow/Services/ConfigurationFileBuilder.cs new file mode 100644 index 0000000000..a05552dba6 --- /dev/null +++ b/tools/SetupFlow/DevHome.SetupFlow/Services/ConfigurationFileBuilder.cs @@ -0,0 +1,232 @@ +// Copyright (c) Microsoft Corporation. +// Licensed under the MIT License. + +using System; +using System.Collections.Generic; +using System.Linq; +using System.Threading.Tasks; +using DevHome.SetupFlow.Common.Helpers; +using DevHome.SetupFlow.Models; +using DevHome.SetupFlow.Models.WingetConfigure; +using DevHome.SetupFlow.TaskGroups; +using YamlDotNet.Serialization; +using YamlDotNet.Serialization.NamingConventions; + +namespace DevHome.SetupFlow.Services; + +public enum ConfigurationFileKind +{ + Normal, + SetupTarget, +} + +public class ConfigurationFileBuilder +{ + private readonly SetupFlowOrchestrator _orchestrator; + + public ConfigurationFileBuilder(SetupFlowOrchestrator orchestrator) + { + _orchestrator = orchestrator; + } + + /// + /// Builds an object that represents a config file that can be used by WinGet Configure to install + /// apps and clone repositories.This is already formatted as valid yaml and can be written + /// directly to a file. + /// + /// The config file object representing the yaml file. + public WinGetConfigFile BuildConfigFileObjectFromTaskGroups(IList taskGroups, ConfigurationFileKind configurationFileKind) + { + var listOfResources = new List(); + + foreach (var taskGroup in taskGroups) + { + if (taskGroup is RepoConfigTaskGroup repoConfigGroup) + { + // Add the GitDSC resource blocks to yaml + listOfResources.AddRange(GetResourcesForCloneTaskGroup(repoConfigGroup, configurationFileKind)); + } + else if (taskGroup is AppManagementTaskGroup appManagementGroup) + { + // Add the WinGetDsc resource blocks to yaml + listOfResources.AddRange(GetResourcesForAppManagementTaskGroup(appManagementGroup, configurationFileKind)); + } + } + + if (listOfResources.Count == 0) + { + return new WinGetConfigFile(); + } + + var wingetConfigProperties = new WinGetConfigProperties(); + + // Remove duplicate resources with the same Id but keep ordering. This is needed because the + // Id of the resource should be unique as per winget configure requirements. + listOfResources = listOfResources + .GroupBy(resource => resource.Id) + .Select(group => group.First()) + .ToList(); + + // Merge the resources into the Resources property in the properties object + wingetConfigProperties.Resources = listOfResources.ToArray(); + wingetConfigProperties.ConfigurationVersion = DscHelpers.WinGetConfigureVersion; + + // Create the new WinGetConfigFile object and serialize it to yaml + return new WinGetConfigFile() { Properties = wingetConfigProperties }; + } + + /// + /// Builds the yaml string that is used by WinGet Configure to install the apps and clone the repositories. + /// This is already formatted as valid yaml and can be written directly to a file. + /// + /// The string representing the yaml file. This string is formatted as yaml. + public string BuildConfigFileStringFromTaskGroups(IList taskGroups, ConfigurationFileKind configurationFileKind) + { + // Create the new WinGetConfigFile object and serialize it to yaml + var wingetConfigFile = BuildConfigFileObjectFromTaskGroups(taskGroups, configurationFileKind); + return SerializeWingetFileObjectToString(wingetConfigFile); + } + + /// + /// Builds the yaml string that is used by WinGet Configure to install the apps and clone the repositories. + /// This is already formatted as valid yaml and can be written directly to a file. + /// + /// The string representing the yaml file. This string is formatted as yaml. + public string SerializeWingetFileObjectToString(WinGetConfigFile configFile) + { + // Create the new WinGetConfigFile object and serialize it to yaml + var yamlSerializer = new SerializerBuilder() + .WithNamingConvention(CamelCaseNamingConvention.Instance) + .ConfigureDefaultValuesHandling(DefaultValuesHandling.OmitNull) + .Build(); + + // Add the header banner and add two new lines after the header. + var configStringWithHeader = DscHelpers.DevHomeHeaderBanner + Environment.NewLine + Environment.NewLine; + var yaml = yamlSerializer.Serialize(configFile); + configStringWithHeader += yaml; + return configStringWithHeader; + } + + /// + /// Creates a list of WinGetConfigResource objects from the CloneRepoTask objects in the RepoConfigTaskGroup + /// + /// Clone repository task group where cloning information is located + /// List of objects that represent a WinGet configure resource block + private List GetResourcesForCloneTaskGroup(RepoConfigTaskGroup repoConfigGroup, ConfigurationFileKind configurationFileKind) + { + var listOfResources = new List(); + var repoConfigTasks = repoConfigGroup.SetupTasks + .Where(task => task is CloneRepoTask) + .Select(task => task as CloneRepoTask) + .ToList(); + + if (repoConfigTasks.Count != 0) + { + listOfResources.Add(CreateWinGetInstallForGitPreReq()); + } + + foreach (var repoConfigTask in repoConfigTasks) + { + if (repoConfigTask.RepositoryToClone is GenericRepository genericRepository) + { + listOfResources.Add(CreateResourceFromTaskForGitDsc(repoConfigTask, genericRepository.RepoUri, configurationFileKind)); + } + } + + return listOfResources; + } + + /// + /// Creates a list of WinGetConfigResource objects from the InstallPackageTask objects in the AppManagementTaskGroup + /// + /// The task group that holds information about the apps the user wants to install + /// List of objects that represent a WinGet configure resource block + private List GetResourcesForAppManagementTaskGroup(AppManagementTaskGroup appManagementGroup, ConfigurationFileKind configurationFileKind) + { + var listOfResources = new List(); + var installList = appManagementGroup.SetupTasks + .Where(task => task is InstallPackageTask) + .Select(task => task as InstallPackageTask) + .ToList(); + + foreach (var installTask in installList) + { + listOfResources.Add(CreateResourceFromTaskForWinGetDsc(installTask, configurationFileKind)); + } + + return listOfResources; + } + + /// + /// Creates a WinGetConfigResource object from an InstallPackageTask object. + /// + /// The install task with the package information for the app + /// The WinGetConfigResource object that represents the block of yaml needed by WinGetDsc to install the app. + private WinGetConfigResource CreateResourceFromTaskForWinGetDsc(InstallPackageTask task, ConfigurationFileKind configurationFileKind) + { + var arguments = task.GetArguments(); + var id = arguments.PackageId; + + if (configurationFileKind == ConfigurationFileKind.SetupTarget) + { + // WinGet configure uses the Id property to uniquely identify a resource and also to display the resource status in the UI. + // So we add a description to the Id to make it more readable in the UI. These do not need to be localized. + id = $"{arguments.PackageId} | Install: " + task.PackageName; + } + + return new WinGetConfigResource() + { + Resource = DscHelpers.WinGetDscResource, + Id = id, + Directives = new() { AllowPrerelease = true, Description = $"Installing {arguments.PackageId}" }, + Settings = new WinGetDscSettings() { Id = arguments.PackageId, Source = DscHelpers.DscSourceNameForWinGet }, + }; + } + + /// + /// Creates a WinGetConfigResource object from a CloneRepoTask object. + /// + /// The task that includes the cloning information for the repository + /// The url to the public Git repository + /// The WinGetConfigResource object that represents the block of yaml needed by GitDsc to clone the repository. + private WinGetConfigResource CreateResourceFromTaskForGitDsc(CloneRepoTask task, Uri webAddress, ConfigurationFileKind configurationFileKind) + { + // For normal cases, the Id will be null. This can be changed in the future when a use case for this Dsc File builder is needed outside the setup + // setup target flow. We can likely drop the if statement and just use whats in its body. + string id = null; + var gitDependsOnId = DscHelpers.GitWinGetPackageId; + + if (configurationFileKind == ConfigurationFileKind.SetupTarget) + { + // WinGet configure uses the Id property to uniquely identify a resource and also to display the resource status in the UI. + // So we add a description to the Id to make it more readable in the UI. These do not need to be localized. + id = $"Clone {task.RepositoryName}" + ": " + task.CloneLocation.FullName; + gitDependsOnId = $"{DscHelpers.GitWinGetPackageId} | Install: {DscHelpers.GitName}"; + } + + return new WinGetConfigResource() + { + Resource = DscHelpers.GitCloneDscResource, + Id = id, + Directives = new() { AllowPrerelease = true, Description = $"Cloning: {task.RepositoryName}" }, + DependsOn = [gitDependsOnId], + Settings = new GitDscSettings() { HttpsUrl = webAddress.AbsoluteUri, RootDirectory = task.CloneLocation.FullName }, + }; + } + + /// + /// Creates a WinGetConfigResource object for the GitDsc resource that installs Git for Windows. This is a pre-requisite for + /// the GitDsc resource that clones the repository. + /// + /// The WinGetConfigResource object that represents the block of yaml needed by WinGetDsc to install the Git app. + private WinGetConfigResource CreateWinGetInstallForGitPreReq() + { + return new WinGetConfigResource() + { + Resource = DscHelpers.WinGetDscResource, + Id = $"{DscHelpers.GitWinGetPackageId} | Install: {DscHelpers.GitName}", + Directives = new() { AllowPrerelease = true, Description = $"Installing {DscHelpers.GitName}" }, + Settings = new WinGetDscSettings() { Id = DscHelpers.GitWinGetPackageId, Source = DscHelpers.DscSourceNameForWinGet }, + }; + } +} diff --git a/tools/SetupFlow/DevHome.SetupFlow/Services/IWindowsPackageManager.cs b/tools/SetupFlow/DevHome.SetupFlow/Services/IWindowsPackageManager.cs index 23079891f7..cccbb46b85 100644 --- a/tools/SetupFlow/DevHome.SetupFlow/Services/IWindowsPackageManager.cs +++ b/tools/SetupFlow/DevHome.SetupFlow/Services/IWindowsPackageManager.cs @@ -22,10 +22,10 @@ public interface IWindowsPackageManager public Task InitializeAsync(); /// - public Task InstallPackageAsync(IWinGetPackage package); + public Task InstallPackageAsync(WinGetPackageUri packageUri); /// - public Task> GetPackagesAsync(IList packageUris); + public Task> GetPackagesAsync(IList packageUris); /// public Task> SearchAsync(string query, uint limit); @@ -46,14 +46,14 @@ public interface IWindowsPackageManager public bool IsWinGetPackage(IWinGetPackage package); /// - public Uri CreatePackageUri(IWinGetPackage package); + public WinGetPackageUri CreatePackageUri(IWinGetPackage package); /// - public Uri CreateWinGetCatalogPackageUri(string packageId); + public WinGetPackageUri CreateWinGetCatalogPackageUri(string packageId); /// - public Uri CreateMsStoreCatalogPackageUri(string packageId); + public WinGetPackageUri CreateMsStoreCatalogPackageUri(string packageId); /// - public Uri CreateCustomCatalogPackageUri(string packageId, string catalogName); + public WinGetPackageUri CreateCustomCatalogPackageUri(string packageId, string catalogName); } diff --git a/tools/SetupFlow/DevHome.SetupFlow/Services/SetupFlowOrchestrator.cs b/tools/SetupFlow/DevHome.SetupFlow/Services/SetupFlowOrchestrator.cs index bd3223dc1f..30759bb0cf 100644 --- a/tools/SetupFlow/DevHome.SetupFlow/Services/SetupFlowOrchestrator.cs +++ b/tools/SetupFlow/DevHome.SetupFlow/Services/SetupFlowOrchestrator.cs @@ -19,6 +19,12 @@ namespace DevHome.SetupFlow.Services; +public enum SetupFlowKind +{ + LocalMachine, + SetupTarget, +} + /// /// Orchestrator for the Setup Flow, in charge of functionality across multiple pages. /// @@ -60,6 +66,10 @@ public Guid ActivityId get; private set; } + public bool IsSettingUpATargetMachine => CurrentSetupFlowKind == SetupFlowKind.SetupTarget; + + public bool IsSettingUpLocalMachine => CurrentSetupFlowKind == SetupFlowKind.LocalMachine; + /// /// Occurs right before a page changes /// @@ -144,6 +154,8 @@ public void ReleaseRemoteOperationObject() RemoteElevatedOperation = null; } + public SetupFlowKind CurrentSetupFlowKind { get; set; } + /// /// Determines whether a given page is one that was shown previously on the flow. /// diff --git a/tools/SetupFlow/DevHome.SetupFlow/Services/StringResourceKey.cs b/tools/SetupFlow/DevHome.SetupFlow/Services/StringResourceKey.cs index 4929db4792..4310bdfddf 100644 --- a/tools/SetupFlow/DevHome.SetupFlow/Services/StringResourceKey.cs +++ b/tools/SetupFlow/DevHome.SetupFlow/Services/StringResourceKey.cs @@ -135,6 +135,8 @@ public static class StringResourceKey public static readonly string ClonePathNotFolder = nameof(ClonePathNotFolder); public static readonly string ClonePathDriveDoesNotExist = nameof(ClonePathDriveDoesNotExist); public static readonly string RepoToolAddAnotherAccount = nameof(RepoToolAddAnotherAccount); + public static readonly string SetupShellRepoConfigLocalMachine = nameof(SetupShellRepoConfigLocalMachine); + public static readonly string SetupShellRepoConfigTargetMachine = nameof(SetupShellRepoConfigTargetMachine); // Url Validation public static readonly string UrlValidationBadUrl = nameof(UrlValidationBadUrl); @@ -201,4 +203,51 @@ public static class StringResourceKey public static readonly string ConfigurationUnitFailedSystemState = nameof(ConfigurationUnitFailedSystemState); public static readonly string ConfigurationUnitFailedUnitProcessing = nameof(ConfigurationUnitFailedUnitProcessing); public static readonly string ConfigurationUnitNotRunDueToFailedAssert = nameof(ConfigurationUnitNotRunDueToFailedAssert); + + // Setup target flow + public static readonly string SetupTargetPageTitle = nameof(SetupTargetPageTitle); + public static readonly string SetupTargetAllComboBoxOption = nameof(SetupTargetAllComboBoxOption); + public static readonly string SetupTargetConfigurationUnknown = nameof(SetupTargetConfigurationUnknown); + public static readonly string SetupTargetConfigurationPending = nameof(SetupTargetConfigurationPending); + public static readonly string SetupTargetConfigurationInProgress = nameof(SetupTargetConfigurationInProgress); + public static readonly string SetupTargetConfigurationCompleted = nameof(SetupTargetConfigurationCompleted); + public static readonly string SetupTargetConfigurationShuttingDownDevice = nameof(SetupTargetConfigurationShuttingDownDevice); + public static readonly string SetupTargetConfigurationStartingDevice = nameof(SetupTargetConfigurationStartingDevice); + public static readonly string SetupTargetConfigurationRestartingDevice = nameof(SetupTargetConfigurationRestartingDevice); + public static readonly string SetupTargetConfigurationProvisioningDevice = nameof(SetupTargetConfigurationProvisioningDevice); + public static readonly string SetupTargetConfigurationWaitingForAdminUserLogon = nameof(SetupTargetConfigurationWaitingForAdminUserLogon); + public static readonly string SetupTargetConfigurationWaitingForUserLogon = nameof(SetupTargetConfigurationWaitingForUserLogon); + public static readonly string SetupTargetConfigurationSkipped = nameof(SetupTargetConfigurationSkipped); + public static readonly string SetupTargetConfigurationOpenConfigFailed = nameof(SetupTargetConfigurationOpenConfigFailed); + public static readonly string SetupTargetConfigurationUnitProgressMessage = nameof(SetupTargetConfigurationUnitProgressMessage); + public static readonly string SetupTargetConfigurationSetProgressMessage = nameof(SetupTargetConfigurationSetProgressMessage); + public static readonly string SetupTargetConfigurationUnitProgressError = nameof(SetupTargetConfigurationUnitProgressError); + public static readonly string ConfigureTargetApplyConfigurationStopped = nameof(ConfigureTargetApplyConfigurationStopped); + public static readonly string ConfigureTargetApplyConfigurationStoppedWithNoEndingMessage = nameof(ConfigureTargetApplyConfigurationStoppedWithNoEndingMessage); + public static readonly string ConfigureTargetApplyConfigurationActionNeeded = nameof(ConfigureTargetApplyConfigurationActionNeeded); + public static readonly string SetupTargetExtensionApplyingConfiguration = nameof(SetupTargetExtensionApplyingConfiguration); + public static readonly string SetupTargetExtensionApplyingConfigurationActionRequired = nameof(SetupTargetExtensionApplyingConfigurationActionRequired); + public static readonly string SetupTargetExtensionApplyConfigurationError = nameof(SetupTargetExtensionApplyConfigurationError); + public static readonly string SetupTargetExtensionApplyConfigurationSuccess = nameof(SetupTargetExtensionApplyConfigurationSuccess); + public static readonly string SetupTargetExtensionApplyConfigurationRebootRequired = nameof(SetupTargetExtensionApplyConfigurationRebootRequired); + public static readonly string SetupTargetMachineName = nameof(SetupTargetMachineName); + public static readonly string ConfigureTargetApplyConfigurationActionFailureRetry = nameof(ConfigureTargetApplyConfigurationActionFailureRetry); + public static readonly string ConfigureTargetApplyConfigurationActionFailureEnd = nameof(ConfigureTargetApplyConfigurationActionFailureEnd); + public static readonly string ConfigureTargetApplyConfigurationActionSuccess = nameof(ConfigureTargetApplyConfigurationActionSuccess); + public static readonly string SetupTargetReviewPageDefaultInfoBarTitle = nameof(SetupTargetReviewPageDefaultInfoBarTitle); + public static readonly string SetupTargetReviewPageDefaultInfoBarMessage = nameof(SetupTargetReviewPageDefaultInfoBarMessage); + public static readonly string SetupTargetReviewPageHyperVInfoBarMessage = nameof(SetupTargetReviewPageHyperVInfoBarMessage); + public static readonly string SetupTargetUnknownStatus = nameof(SetupTargetUnknownStatus); + public static readonly string SetupTargetSortAToZLabel = nameof(SetupTargetSortAToZLabel); + public static readonly string SetupTargetSortZToALabel = nameof(SetupTargetSortZToALabel); + public static readonly string SetupTargetPageSyncButton = nameof(SetupTargetPageSyncButton); + public static readonly string SetupTargetConfigurationUnitCompleted = nameof(SetupTargetConfigurationUnitCompleted); + public static readonly string SetupTargetConfigurationUnitInProgress = nameof(SetupTargetConfigurationUnitInProgress); + public static readonly string SetupTargetConfigurationUnitPending = nameof(SetupTargetConfigurationUnitPending); + public static readonly string SetupTargetConfigurationUnitSkipped = nameof(SetupTargetConfigurationUnitSkipped); + public static readonly string SetupTargetConfigurationSetCurrentState = nameof(SetupTargetConfigurationSetCurrentState); + public static readonly string SetupTargetConfigurationUnitCurrentState = nameof(SetupTargetConfigurationUnitCurrentState); + public static readonly string SetupTargetConfigurationProgressUpdate = nameof(SetupTargetConfigurationProgressUpdate); + public static readonly string SetupTargetConfigurationUnitProgressErrorWithMsg = nameof(SetupTargetConfigurationUnitProgressErrorWithMsg); + public static readonly string SetupTargetConfigurationUnitUnknown = nameof(SetupTargetConfigurationUnitUnknown); } diff --git a/tools/SetupFlow/DevHome.SetupFlow/Services/WinGet/IWinGetOperations.cs b/tools/SetupFlow/DevHome.SetupFlow/Services/WinGet/IWinGetOperations.cs index b2c220ec27..b2442d87d9 100644 --- a/tools/SetupFlow/DevHome.SetupFlow/Services/WinGet/IWinGetOperations.cs +++ b/tools/SetupFlow/DevHome.SetupFlow/Services/WinGet/IWinGetOperations.cs @@ -1,7 +1,6 @@ // Copyright (c) Microsoft Corporation. // Licensed under the MIT License. -using System; using System.Collections.Generic; using System.Threading.Tasks; using DevHome.SetupFlow.Models; @@ -11,10 +10,10 @@ namespace DevHome.SetupFlow.Services.WinGet.Operations; internal interface IWinGetOperations { /// " - public Task InstallPackageAsync(IWinGetPackage package); + public Task InstallPackageAsync(WinGetPackageUri packageUri); /// " - public Task> GetPackagesAsync(IList packageUris); + public Task> GetPackagesAsync(IList packageUris); /// " public Task> SearchAsync(string query, uint limit); diff --git a/tools/SetupFlow/DevHome.SetupFlow/Services/WinGet/IWinGetPackageCache.cs b/tools/SetupFlow/DevHome.SetupFlow/Services/WinGet/IWinGetPackageCache.cs index 35c0433158..402f02f3c7 100644 --- a/tools/SetupFlow/DevHome.SetupFlow/Services/WinGet/IWinGetPackageCache.cs +++ b/tools/SetupFlow/DevHome.SetupFlow/Services/WinGet/IWinGetPackageCache.cs @@ -1,7 +1,6 @@ // Copyright (c) Microsoft Corporation. // Licensed under the MIT License. -using System; using System.Collections.Generic; using DevHome.SetupFlow.Models; @@ -18,7 +17,7 @@ internal interface IWinGetPackageCache /// Package URIs to find /// Output package URIs not found /// List of packages found - public IList GetPackages(IEnumerable packageUris, out IEnumerable packageUrisNotFound); + public IList GetPackages(IEnumerable packageUris, out IEnumerable packageUrisNotFound); /// /// Try to get a package in the cache. @@ -26,7 +25,7 @@ internal interface IWinGetPackageCache /// Package URI to find /// Output package /// True if the package was found, false otherwise. - public bool TryGetPackage(Uri packageUri, out IWinGetPackage package); + public bool TryGetPackage(WinGetPackageUri packageUri, out IWinGetPackage package); /// /// Try to add a package to the cache. @@ -34,7 +33,7 @@ internal interface IWinGetPackageCache /// Package URI to add /// Package to add /// True if the package was added, false otherwise. - public bool TryAddPackage(Uri packageUri, IWinGetPackage package); + public bool TryAddPackage(WinGetPackageUri packageUri, IWinGetPackage package); /// /// Clear the cache. diff --git a/tools/SetupFlow/DevHome.SetupFlow/Services/WinGet/IWinGetPackageInstaller.cs b/tools/SetupFlow/DevHome.SetupFlow/Services/WinGet/IWinGetPackageInstaller.cs index 6aa290e03f..f4c8cc6a56 100644 --- a/tools/SetupFlow/DevHome.SetupFlow/Services/WinGet/IWinGetPackageInstaller.cs +++ b/tools/SetupFlow/DevHome.SetupFlow/Services/WinGet/IWinGetPackageInstaller.cs @@ -13,6 +13,7 @@ internal interface IWinGetPackageInstaller /// /// Catalog from which to install the package /// Package id to install + /// Version of the package to install /// Result of the installation - public Task InstallPackageAsync(WinGetCatalog catalog, string packageId); + public Task InstallPackageAsync(WinGetCatalog catalog, string packageId, string version = null); } diff --git a/tools/SetupFlow/DevHome.SetupFlow/Services/WinGet/IWinGetProtocolParser.cs b/tools/SetupFlow/DevHome.SetupFlow/Services/WinGet/IWinGetProtocolParser.cs index d479ae1406..c26026c934 100644 --- a/tools/SetupFlow/DevHome.SetupFlow/Services/WinGet/IWinGetProtocolParser.cs +++ b/tools/SetupFlow/DevHome.SetupFlow/Services/WinGet/IWinGetProtocolParser.cs @@ -1,7 +1,6 @@ // Copyright (c) Microsoft Corporation. // Licensed under the MIT License. -using System; using System.Threading.Tasks; using DevHome.SetupFlow.Models; @@ -14,21 +13,21 @@ internal interface IWinGetProtocolParser /// /// Package /// Package uri - public Uri CreatePackageUri(IWinGetPackage package); + public WinGetPackageUri CreatePackageUri(IWinGetPackage package); /// /// Create a winget catalog package uri from a package id /// /// Package id /// Package uri - public Uri CreateWinGetCatalogPackageUri(string packageId); + public WinGetPackageUri CreateWinGetCatalogPackageUri(string packageId); /// /// Create a Microsoft store catalog package uri from a package id /// /// Package id /// Package uri - public Uri CreateMsStoreCatalogPackageUri(string packageId); + public WinGetPackageUri CreateMsStoreCatalogPackageUri(string packageId); /// /// Create a custom catalog package uri from a package id and catalog name @@ -36,21 +35,14 @@ internal interface IWinGetProtocolParser /// Package id /// Catalog name /// Package uri - public Uri CreateCustomCatalogPackageUri(string packageId, string catalogName); - - /// - /// Get the package id and catalog from a package uri - /// - /// Input package uri - /// Package id and catalog, or null if the URI protocol is inaccurate - public WinGetProtocolParserResult ParsePackageUri(Uri packageUri); + public WinGetPackageUri CreateCustomCatalogPackageUri(string packageId, string catalogName); /// /// Resolve a catalog from a parser result /// - /// Parser result + /// Package uri /// Catalog - public Task ResolveCatalogAsync(WinGetProtocolParserResult result); + public Task ResolveCatalogAsync(WinGetPackageUri packageUri); /// /// Create a package uri from a package id and catalog @@ -58,5 +50,5 @@ internal interface IWinGetProtocolParser /// Package id /// Catalog /// Package uri - public Uri CreatePackageUri(string packageId, WinGetCatalog catalog); + public WinGetPackageUri CreatePackageUri(string packageId, WinGetCatalog catalog); } diff --git a/tools/SetupFlow/DevHome.SetupFlow/Services/WinGet/Operations/IWinGetGetPackageOperation.cs b/tools/SetupFlow/DevHome.SetupFlow/Services/WinGet/Operations/IWinGetGetPackageOperation.cs index 4f313441cd..9ce3b7eaa9 100644 --- a/tools/SetupFlow/DevHome.SetupFlow/Services/WinGet/Operations/IWinGetGetPackageOperation.cs +++ b/tools/SetupFlow/DevHome.SetupFlow/Services/WinGet/Operations/IWinGetGetPackageOperation.cs @@ -1,7 +1,6 @@ // Copyright (c) Microsoft Corporation. // Licensed under the MIT License. -using System; using System.Collections.Generic; using System.Threading.Tasks; using DevHome.SetupFlow.Models; @@ -15,5 +14,5 @@ internal interface IWinGetGetPackageOperation /// /// List of package uri /// List of winget package matches - public Task> GetPackagesAsync(IList packageUris); + public Task> GetPackagesAsync(IList packageUris); } diff --git a/tools/SetupFlow/DevHome.SetupFlow/Services/WinGet/Operations/IWinGetInstallOperation.cs b/tools/SetupFlow/DevHome.SetupFlow/Services/WinGet/Operations/IWinGetInstallOperation.cs index e041be8dd0..5c0c51fa1a 100644 --- a/tools/SetupFlow/DevHome.SetupFlow/Services/WinGet/Operations/IWinGetInstallOperation.cs +++ b/tools/SetupFlow/DevHome.SetupFlow/Services/WinGet/Operations/IWinGetInstallOperation.cs @@ -1,7 +1,6 @@ // Copyright (c) Microsoft Corporation. // Licensed under the MIT License. -using System; using System.Threading.Tasks; using DevHome.SetupFlow.Models; @@ -9,17 +8,10 @@ namespace DevHome.SetupFlow.Services.WinGet.Operations; internal interface IWinGetInstallOperation { - /// - /// Install a package on the user's machine. - /// - /// Package to install - /// Install package result - public Task InstallPackageAsync(IWinGetPackage package); - /// /// Installs a package from a URI. /// /// Uri of the package to install. /// Result of the installation. - public Task InstallPackageAsync(Uri packageUri); + public Task InstallPackageAsync(WinGetPackageUri packageUri); } diff --git a/tools/SetupFlow/DevHome.SetupFlow/Services/WinGet/Operations/WinGetGetPackageOperation.cs b/tools/SetupFlow/DevHome.SetupFlow/Services/WinGet/Operations/WinGetGetPackageOperation.cs index 811464ee78..681918dfbf 100644 --- a/tools/SetupFlow/DevHome.SetupFlow/Services/WinGet/Operations/WinGetGetPackageOperation.cs +++ b/tools/SetupFlow/DevHome.SetupFlow/Services/WinGet/Operations/WinGetGetPackageOperation.cs @@ -33,7 +33,7 @@ public WinGetGetPackageOperation( } /// - public async Task> GetPackagesAsync(IList packageUris) + public async Task> GetPackagesAsync(IList packageUris) { // Remove duplicates (optimization to prevent querying the same package multiple times) var distinctPackageUris = packageUris.Distinct(); @@ -45,7 +45,8 @@ public async Task> GetPackagesAsync(IList packageUris // Get packages grouped by catalog var getPackagesTasks = new List>>(); - foreach (var parsedUrisGroup in GroupParsedUrisByCatalog(packageUrisToQuery)) + var groupedParsedUris = packageUrisToQuery.GroupBy(p => p.CatalogName).Select(p => p.ToList()).ToList(); + foreach (var parsedUrisGroup in groupedParsedUris) { if (parsedUrisGroup.Count != 0) { @@ -55,7 +56,7 @@ public async Task> GetPackagesAsync(IList packageUris // All parsed URIs in the group have the same catalog, resolve catalog from the first entry var firstParsedUri = parsedUrisGroup.First(); var packageIds = parsedUrisGroup.Select(p => p.PackageId).ToHashSet(); - Log.Logger?.ReportInfo(Log.Component.AppManagement, $"Getting packages [{string.Join(", ", packageIds)}] from parsed uri catalog name: {firstParsedUri.CatalogUriName}"); + Log.Logger?.ReportInfo(Log.Component.AppManagement, $"Getting packages [{string.Join(", ", packageIds)}] from parsed uri catalog name: {firstParsedUri.CatalogName}"); // Get packages from the catalog var catalog = await _protocolParser.ResolveCatalogAsync(firstParsedUri); @@ -75,13 +76,13 @@ public async Task> GetPackagesAsync(IList packageUris var unorderedPackagesMap = getPackagesTasks .SelectMany(p => p.Result) .Concat(cachedPackages) - .ToDictionary(p => _protocolParser.CreatePackageUri(p), p => p); + .ToDictionary(p => _protocolParser.CreatePackageUri(p).ToString(WinGetPackageUriParameters.None), p => p); // Order packages by the order of the input URIs using a dictionary var orderedPackages = new List(); foreach (var packageUri in packageUris) { - if (unorderedPackagesMap.TryGetValue(packageUri, out var package)) + if (unorderedPackagesMap.TryGetValue(packageUri.ToString(WinGetPackageUriParameters.None), out var package)) { orderedPackages.Add(package); } @@ -93,31 +94,4 @@ public async Task> GetPackagesAsync(IList packageUris return orderedPackages; } - - /// - /// Group packages by their catalogs - /// - /// Package URIs - /// Dictionary of package ids by catalog - private List> GroupParsedUrisByCatalog(IEnumerable packageUriSet) - { - var parsedUris = new List(); - - // 1. Parse all package URIs and log invalid ones - foreach (var packageUri in packageUriSet) - { - var uriInfo = _protocolParser.ParsePackageUri(packageUri); - if (uriInfo != null) - { - parsedUris.Add(uriInfo); - } - else - { - Log.Logger?.ReportWarn(Log.Component.AppManagement, $"Failed to get URI details from '{packageUri}'"); - } - } - - // 2. Group package ids by catalog - return parsedUris.GroupBy(p => p.CatalogUriName).Select(p => p.ToList()).ToList(); - } } diff --git a/tools/SetupFlow/DevHome.SetupFlow/Services/WinGet/Operations/WinGetInstallOperation.cs b/tools/SetupFlow/DevHome.SetupFlow/Services/WinGet/Operations/WinGetInstallOperation.cs index a004159b61..7d42fc27f3 100644 --- a/tools/SetupFlow/DevHome.SetupFlow/Services/WinGet/Operations/WinGetInstallOperation.cs +++ b/tools/SetupFlow/DevHome.SetupFlow/Services/WinGet/Operations/WinGetInstallOperation.cs @@ -1,7 +1,6 @@ // Copyright (c) Microsoft Corporation. // Licensed under the MIT License. -using System; using System.Threading.Tasks; using DevHome.SetupFlow.Models; @@ -30,28 +29,12 @@ public WinGetInstallOperation( } /// - public async Task InstallPackageAsync(IWinGetPackage package) + public async Task InstallPackageAsync(WinGetPackageUri packageUri) { return await _recovery.DoWithRecoveryAsync(async () => { - var catalog = await _catalogConnector.GetPackageCatalogAsync(package); - return await _packageInstaller.InstallPackageAsync(catalog, package.Id); - }); - } - - /// - public async Task InstallPackageAsync(Uri packageUri) - { - var parsedPackageUri = _protocolParser.ParsePackageUri(packageUri); - if (parsedPackageUri == null) - { - throw new ArgumentException($"Invalid package URI ${packageUri}"); - } - - return await _recovery.DoWithRecoveryAsync(async () => - { - var catalog = await _protocolParser.ResolveCatalogAsync(parsedPackageUri); - return await _packageInstaller.InstallPackageAsync(catalog, parsedPackageUri.PackageId); + var catalog = await _protocolParser.ResolveCatalogAsync(packageUri); + return await _packageInstaller.InstallPackageAsync(catalog, packageUri.PackageId, packageUri.Options.Version); }); } } diff --git a/tools/SetupFlow/DevHome.SetupFlow/Services/WinGet/WinGetOperations.cs b/tools/SetupFlow/DevHome.SetupFlow/Services/WinGet/WinGetOperations.cs index 4145b4fae3..8ddc086943 100644 --- a/tools/SetupFlow/DevHome.SetupFlow/Services/WinGet/WinGetOperations.cs +++ b/tools/SetupFlow/DevHome.SetupFlow/Services/WinGet/WinGetOperations.cs @@ -1,7 +1,6 @@ // Copyright (c) Microsoft Corporation. // Licensed under the MIT License. -using System; using System.Collections.Generic; using System.Threading.Tasks; using DevHome.SetupFlow.Models; @@ -25,10 +24,10 @@ public WinGetOperations( } /// - public async Task InstallPackageAsync(IWinGetPackage package) => await _installOperation.InstallPackageAsync(package); + public async Task InstallPackageAsync(WinGetPackageUri packageUri) => await _installOperation.InstallPackageAsync(packageUri); /// - public async Task> GetPackagesAsync(IList packageUris) => await _getPackageOperation.GetPackagesAsync(packageUris); + public async Task> GetPackagesAsync(IList packageUris) => await _getPackageOperation.GetPackagesAsync(packageUris); /// public async Task> SearchAsync(string query, uint limit) => await _searchOperation.SearchAsync(query, limit); diff --git a/tools/SetupFlow/DevHome.SetupFlow/Services/WinGet/WinGetPackageCache.cs b/tools/SetupFlow/DevHome.SetupFlow/Services/WinGet/WinGetPackageCache.cs index 94b327b3af..7e21237f27 100644 --- a/tools/SetupFlow/DevHome.SetupFlow/Services/WinGet/WinGetPackageCache.cs +++ b/tools/SetupFlow/DevHome.SetupFlow/Services/WinGet/WinGetPackageCache.cs @@ -1,7 +1,6 @@ // Copyright (c) Microsoft Corporation. // Licensed under the MIT License. -using System; using System.Collections.Generic; using DevHome.SetupFlow.Models; @@ -12,17 +11,17 @@ namespace DevHome.SetupFlow.Services.WinGet; /// internal sealed class WinGetPackageCache : IWinGetPackageCache { - private readonly Dictionary _cache = new(); + private readonly Dictionary _cache = new(); private readonly object _lock = new(); /// - public IList GetPackages(IEnumerable packageUris, out IEnumerable packageUrisNotFound) + public IList GetPackages(IEnumerable packageUris, out IEnumerable packageUrisNotFound) { // Lock to ensure all packages fetched are from the same cache state lock (_lock) { var foundPackages = new List(); - var notFoundPackageUris = new List(); + var notFoundPackageUris = new List(); foreach (var packageUri in packageUris) { @@ -42,11 +41,12 @@ public IList GetPackages(IEnumerable packageUris, out IEnum } /// - public bool TryGetPackage(Uri packageUri, out IWinGetPackage package) + public bool TryGetPackage(WinGetPackageUri packageUri, out IWinGetPackage package) { lock (_lock) { - if (_cache.TryGetValue(packageUri, out package)) + var key = CreateKey(packageUri); + if (_cache.TryGetValue(key, out package)) { return true; } @@ -57,11 +57,12 @@ public bool TryGetPackage(Uri packageUri, out IWinGetPackage package) } /// - public bool TryAddPackage(Uri packageUri, IWinGetPackage package) + public bool TryAddPackage(WinGetPackageUri packageUri, IWinGetPackage package) { lock (_lock) { - return _cache.TryAdd(packageUri, package); + var key = CreateKey(packageUri); + return _cache.TryAdd(key, package); } } @@ -73,4 +74,14 @@ public void Clear() _cache.Clear(); } } + + /// + /// Create a key from a package URI + /// + /// Package URI + /// Unique key from a package URI + private string CreateKey(WinGetPackageUri packageUri) + { + return packageUri.ToString(WinGetPackageUriParameters.None); + } } diff --git a/tools/SetupFlow/DevHome.SetupFlow/Services/WinGet/WinGetPackageInstaller.cs b/tools/SetupFlow/DevHome.SetupFlow/Services/WinGet/WinGetPackageInstaller.cs index a2b8908228..6852bbea74 100644 --- a/tools/SetupFlow/DevHome.SetupFlow/Services/WinGet/WinGetPackageInstaller.cs +++ b/tools/SetupFlow/DevHome.SetupFlow/Services/WinGet/WinGetPackageInstaller.cs @@ -28,7 +28,7 @@ public WinGetPackageInstaller(WindowsPackageManagerFactory wingetFactory, IWinGe } /// - public async Task InstallPackageAsync(WinGetCatalog catalog, string packageId) + public async Task InstallPackageAsync(WinGetCatalog catalog, string packageId, string version = null) { if (catalog == null) { @@ -45,7 +45,7 @@ public async Task InstallPackageAsync(WinGetCatalog catalo // 2. Install package Log.Logger?.ReportInfo(Log.Component.AppManagement, $"Starting package installation for {packageId} from catalog {catalog.GetDescriptiveName()}"); - var installResult = await InstallPackageInternalAsync(package); + var installResult = await InstallPackageInternalAsync(package, version); var extendedErrorCode = installResult.ExtendedErrorCode?.HResult ?? HRESULT.S_OK; var installErrorCode = installResult.GetValueOrDefault(res => res.InstallerErrorCode, HRESULT.S_OK); // WPM API V4 @@ -69,11 +69,42 @@ public async Task InstallPackageAsync(WinGetCatalog catalo /// /// Package to install /// Install result - private async Task InstallPackageInternalAsync(CatalogPackage package) + private async Task InstallPackageInternalAsync(CatalogPackage package, string version = null) { var installOptions = _wingetFactory.CreateInstallOptions(); installOptions.PackageInstallMode = PackageInstallMode.Silent; + if (!string.IsNullOrWhiteSpace(version)) + { + installOptions.PackageVersionId = FindVersionOrThrow(package, version); + } + else + { + Log.Logger?.ReportInfo(Log.Component.AppManagement, $"Install version not specified. Falling back to default install version {package.DefaultInstallVersion.Version}"); + } + var packageManager = _wingetFactory.CreatePackageManager(); return await packageManager.InstallPackageAsync(package, installOptions).AsTask(); } + + /// + /// Find a specific version in the list of available versions for a package. + /// + /// Target package + /// Version to find + /// Specified version + /// Exception thrown if the specified version was not found + private PackageVersionId FindVersionOrThrow(CatalogPackage package, string version) + { + // Find the version in the list of available versions + for (var i = 0; i < package.AvailableVersions.Count; i++) + { + if (package.AvailableVersions[i].Version == version) + { + return package.AvailableVersions[i]; + } + } + + Log.Logger?.ReportError(Log.Component.AppManagement, $"Specified install version was not found {version}."); + throw new InstallPackageException(InstallResultStatus.InvalidOptions, InstallPackageException.InstallErrorInvalidParameter, HRESULT.S_OK); + } } diff --git a/tools/SetupFlow/DevHome.SetupFlow/Services/WinGet/WinGetProtocolParser.cs b/tools/SetupFlow/DevHome.SetupFlow/Services/WinGet/WinGetProtocolParser.cs index bba9c9bbd0..f8502743dc 100644 --- a/tools/SetupFlow/DevHome.SetupFlow/Services/WinGet/WinGetProtocolParser.cs +++ b/tools/SetupFlow/DevHome.SetupFlow/Services/WinGet/WinGetProtocolParser.cs @@ -20,11 +20,6 @@ public WinGetProtocolParser(IWinGetCatalogConnector catalogConnector) _catalogConnector = catalogConnector; } - /// - /// Windows package manager custom protocol scheme - /// - private const string Scheme = "x-ms-winget"; - /// /// Reserved URI name for the WinGet catalog /// @@ -36,22 +31,9 @@ public WinGetProtocolParser(IWinGetCatalogConnector catalogConnector) private const string ReservedMsStoreCatalogURIName = "msstore"; /// - public WinGetProtocolParserResult ParsePackageUri(Uri packageUri) + public async Task ResolveCatalogAsync(WinGetPackageUri packageUri) { - if (packageUri.Scheme == Scheme && packageUri.Segments.Length == 2) - { - var packageId = packageUri.Segments[1]; - var catalogUriName = packageUri.Host; - return new(packageId, catalogUriName); - } - - return null; - } - - /// - public async Task ResolveCatalogAsync(WinGetProtocolParserResult result) - { - var catalogName = result.CatalogUriName; + var catalogName = packageUri.CatalogName; // 'winget' catalog if (catalogName == ReservedWingetCatalogURIName) @@ -70,16 +52,16 @@ public async Task ResolveCatalogAsync(WinGetProtocolParserResult } /// - public Uri CreateWinGetCatalogPackageUri(string packageId) => new($"{Scheme}://{ReservedWingetCatalogURIName}/{packageId}"); + public WinGetPackageUri CreateWinGetCatalogPackageUri(string packageId) => new(ReservedWingetCatalogURIName, packageId); /// - public Uri CreateMsStoreCatalogPackageUri(string packageId) => new($"{Scheme}://{ReservedMsStoreCatalogURIName}/{packageId}"); + public WinGetPackageUri CreateMsStoreCatalogPackageUri(string packageId) => new(ReservedMsStoreCatalogURIName, packageId); /// - public Uri CreateCustomCatalogPackageUri(string packageId, string catalogName) => new($"{Scheme}://{catalogName}/{packageId}"); + public WinGetPackageUri CreateCustomCatalogPackageUri(string packageId, string catalogName) => new(catalogName, packageId); /// - public Uri CreatePackageUri(string packageId, WinGetCatalog catalog) + public WinGetPackageUri CreatePackageUri(string packageId, WinGetCatalog catalog) { return catalog.Type switch { @@ -91,10 +73,5 @@ public Uri CreatePackageUri(string packageId, WinGetCatalog catalog) } /// - public Uri CreatePackageUri(IWinGetPackage package) - { - return CreateCustomCatalogPackageUri(package.Id, package.CatalogName); - } + public WinGetPackageUri CreatePackageUri(IWinGetPackage package) => CreateCustomCatalogPackageUri(package.Id, package.CatalogName); } - -public record class WinGetProtocolParserResult(string PackageId, string CatalogUriName); diff --git a/tools/SetupFlow/DevHome.SetupFlow/Services/WinGetFeaturedApplicationsDataSource.cs b/tools/SetupFlow/DevHome.SetupFlow/Services/WinGetFeaturedApplicationsDataSource.cs index 81979549d3..abc9939f8f 100644 --- a/tools/SetupFlow/DevHome.SetupFlow/Services/WinGetFeaturedApplicationsDataSource.cs +++ b/tools/SetupFlow/DevHome.SetupFlow/Services/WinGetFeaturedApplicationsDataSource.cs @@ -142,18 +142,18 @@ private async Task LoadCatalogAsync(IFeaturedApplicationsGroup g /// /// List of package URI strings /// List of package URIs - private List ParseURIs(IReadOnlyList uriStrings) + private List ParseURIs(IReadOnlyList uriStrings) { - var result = new List(); - foreach (var app in uriStrings) + var result = new List(); + foreach (var uriString in uriStrings) { - if (Uri.TryCreate(app, UriKind.Absolute, out var uri)) + if (WinGetPackageUri.TryCreate(uriString, out var packageUri)) { - result.Add(uri); + result.Add(packageUri); } else { - Log.Logger?.ReportWarn(Log.Component.AppManagement, $"Invalid package uri: {app}"); + Log.Logger?.ReportWarn(Log.Component.AppManagement, $"Invalid package uri: {uriString}"); } } diff --git a/tools/SetupFlow/DevHome.SetupFlow/Services/WinGetPackageDataSource.cs b/tools/SetupFlow/DevHome.SetupFlow/Services/WinGetPackageDataSource.cs index 955695bee6..0905c49155 100644 --- a/tools/SetupFlow/DevHome.SetupFlow/Services/WinGetPackageDataSource.cs +++ b/tools/SetupFlow/DevHome.SetupFlow/Services/WinGetPackageDataSource.cs @@ -44,7 +44,7 @@ public WinGetPackageDataSource(IWindowsPackageManager wpm) /// Input type /// List of package URIs /// List of packages - protected async Task> GetPackagesAsync(IList packageUris) + protected async Task> GetPackagesAsync(IList packageUris) { List result = new(); diff --git a/tools/SetupFlow/DevHome.SetupFlow/Services/WinGetPackageJsonDataSource.cs b/tools/SetupFlow/DevHome.SetupFlow/Services/WinGetPackageJsonDataSource.cs index b45274b324..2712229692 100644 --- a/tools/SetupFlow/DevHome.SetupFlow/Services/WinGetPackageJsonDataSource.cs +++ b/tools/SetupFlow/DevHome.SetupFlow/Services/WinGetPackageJsonDataSource.cs @@ -1,108 +1,138 @@ -// Copyright (c) Microsoft Corporation. -// Licensed under the MIT License. - -using System; -using System.Collections.Generic; -using System.IO; -using System.Linq; -using System.Text.Json; -using System.Threading.Tasks; -using DevHome.Common.Extensions; -using DevHome.SetupFlow.Common.Helpers; -using DevHome.SetupFlow.Models; -using Windows.Storage; -using Windows.Storage.Streams; - -namespace DevHome.SetupFlow.Services; - -/// -/// Class for loading package catalogs from a JSON data source -/// -public class WinGetPackageJsonDataSource : WinGetPackageDataSource -{ - /// - /// Class for deserializing a JSON winget package - /// - private sealed class JsonWinGetPackage - { - public Uri Uri { get; set; } - - public string Icon { get; set; } - } - - /// - /// Class for deserializing a JSON package catalog with package ids from - /// winget - /// - private sealed class JsonWinGetPackageCatalog - { - public string NameResourceKey { get; set; } - - public string DescriptionResourceKey { get; set; } - - public IList WinGetPackages { get; set; } - } - - private readonly ISetupFlowStringResource _stringResource; - private readonly string _fileName; - private readonly JsonSerializerOptions jsonSerializerOptions = new() { ReadCommentHandling = JsonCommentHandling.Skip }; - private IList _jsonCatalogs = new List(); - - public override int CatalogCount => _jsonCatalogs.Count; - - public WinGetPackageJsonDataSource( - ISetupFlowStringResource stringResource, - IWindowsPackageManager wpm, - string fileName) - : base(wpm) - { - _stringResource = stringResource; - _fileName = fileName; - } - - public async override Task InitializeAsync() - { - // Open and deserialize JSON file - Log.Logger?.ReportInfo(Log.Component.AppManagement, $"Reading package list from JSON file {_fileName}"); - using var fileStream = File.OpenRead(_fileName); - - _jsonCatalogs = await JsonSerializer.DeserializeAsync>(fileStream, jsonSerializerOptions); - } - - public async override Task> LoadCatalogsAsync() - { - var result = new List(); - foreach (var jsonCatalog in _jsonCatalogs) - { - var packageCatalog = await LoadCatalogAsync(jsonCatalog); - if (packageCatalog != null) - { - result.Add(packageCatalog); - } - } - - return result; - } - - /// - /// Load a package catalog with the list of winget packages sorted based on - /// the input JSON catalog - /// - /// JSON catalog - /// Package catalog - private async Task LoadCatalogAsync(JsonWinGetPackageCatalog jsonCatalog) - { - var catalogName = _stringResource.GetLocalized(jsonCatalog.NameResourceKey); - Log.Logger?.ReportInfo(Log.Component.AppManagement, $"Attempting to read JSON package catalog {catalogName}"); - - try - { - var packages = await GetPackagesAsync(jsonCatalog.WinGetPackages.Select(p => p.Uri).ToList()); +// Copyright (c) Microsoft Corporation. +// Licensed under the MIT License. + +using System; +using System.Collections.Generic; +using System.IO; +using System.Linq; +using System.Text.Json; +using System.Threading.Tasks; +using DevHome.Common.Extensions; +using DevHome.SetupFlow.Common.Helpers; +using DevHome.SetupFlow.Models; +using Windows.Storage; +using Windows.Storage.Streams; + +namespace DevHome.SetupFlow.Services; + +/// +/// Class for loading package catalogs from a JSON data source +/// +public class WinGetPackageJsonDataSource : WinGetPackageDataSource +{ + /// + /// Class for deserializing a JSON winget package + /// + private sealed class JsonWinGetPackage + { + public Uri Uri { get; set; } + + public string Icon { get; set; } + + public WinGetPackageUri GetPackageUri() + { + if (WinGetPackageUri.TryCreate(Uri, out var packageUri)) + { + return packageUri; + } + + return null; + } + } + + /// + /// Class for deserializing a JSON package catalog with package ids from + /// winget + /// + private sealed class JsonWinGetPackageCatalog + { + public string NameResourceKey { get; set; } + + public string DescriptionResourceKey { get; set; } + + public IList WinGetPackages { get; set; } + } + + private readonly ISetupFlowStringResource _stringResource; + private readonly string _fileName; + private readonly JsonSerializerOptions jsonSerializerOptions = new() { ReadCommentHandling = JsonCommentHandling.Skip }; + private IList _jsonCatalogs = new List(); + + public override int CatalogCount => _jsonCatalogs.Count; + + public WinGetPackageJsonDataSource( + ISetupFlowStringResource stringResource, + IWindowsPackageManager wpm, + string fileName) + : base(wpm) + { + _stringResource = stringResource; + _fileName = fileName; + } + + public async override Task InitializeAsync() + { + // Open and deserialize JSON file + Log.Logger?.ReportInfo(Log.Component.AppManagement, $"Reading package list from JSON file {_fileName}"); + using var fileStream = File.OpenRead(_fileName); + + _jsonCatalogs = await JsonSerializer.DeserializeAsync>(fileStream, jsonSerializerOptions); + } + + public async override Task> LoadCatalogsAsync() + { + var result = new List(); + foreach (var jsonCatalog in _jsonCatalogs) + { + var packageCatalog = await LoadCatalogAsync(jsonCatalog); + if (packageCatalog != null) + { + result.Add(packageCatalog); + } + } + + return result; + } + + private List GetPackageUris(IList jsonPackages) + { + var result = new List(); + foreach (var jsonPackage in jsonPackages) + { + var packageUri = jsonPackage.GetPackageUri(); + if (packageUri != null) + { + result.Add(packageUri); + } + else + { + Log.Logger?.ReportWarn(Log.Component.AppManagement, $"Skipping {jsonPackage.Uri} because it is not a valid winget package uri"); + } + } + + return result; + } + + /// + /// Load a package catalog with the list of winget packages sorted based on + /// the input JSON catalog + /// + /// JSON catalog + /// Package catalog + private async Task LoadCatalogAsync(JsonWinGetPackageCatalog jsonCatalog) + { + var catalogName = _stringResource.GetLocalized(jsonCatalog.NameResourceKey); + Log.Logger?.ReportInfo(Log.Component.AppManagement, $"Attempting to read JSON package catalog {catalogName}"); + + try + { + var packageUris = GetPackageUris(jsonCatalog.WinGetPackages); + var packages = await GetPackagesAsync(packageUris); Log.Logger?.ReportInfo(Log.Component.AppManagement, $"Obtaining icon information for JSON packages: [{string.Join(", ", packages.Select(p => $"({p.Name}, {p.CatalogName})"))}]"); foreach (var package in packages) { var packageUri = WindowsPackageManager.CreatePackageUri(package); - var jsonPackage = jsonCatalog.WinGetPackages.FirstOrDefault(p => packageUri == p.Uri); + var jsonPackage = jsonCatalog.WinGetPackages.FirstOrDefault(p => packageUri.Equals(p.GetPackageUri(), WinGetPackageUriParameters.None)); if (jsonPackage != null) { var icon = await GetJsonApplicationIconAsync(jsonPackage); @@ -111,51 +141,51 @@ private async Task LoadCatalogAsync(JsonWinGetPackageCatalog jso } } - if (packages.Any()) - { - return new PackageCatalog() - { - Name = catalogName, - Description = _stringResource.GetLocalized(jsonCatalog.DescriptionResourceKey), - Packages = packages.ToReadOnlyCollection(), - }; - } - else - { - Log.Logger?.ReportWarn(Log.Component.AppManagement, $"JSON package catalog [{catalogName}] is empty"); - } - } - catch (Exception e) - { - Log.Logger?.ReportError(Log.Component.AppManagement, $"Error loading packages from winget catalog.", e); - } - - return null; - } - - private async Task GetJsonApplicationIconAsync(JsonWinGetPackage package) - { - try - { - if (!string.IsNullOrEmpty(package.Icon)) - { - // Load icon from application assets - var iconFile = await StorageFile.GetFileFromApplicationUriAsync(new Uri(package.Icon)); - var icon = await iconFile.OpenAsync(FileAccessMode.Read); - - // Ensure stream is not empty to prevent rendering an empty image - if (icon.Size > 0) - { - return icon; - } - } - } - catch (Exception e) - { - Log.Logger?.ReportError(Log.Component.AppManagement, $"Failed to get icon for JSON package {package.Uri}.", e); - } - - Log.Logger?.ReportWarn(Log.Component.AppManagement, $"No icon found for JSON package {package.Uri}. A default one will be provided."); - return null; - } -} + if (packages.Any()) + { + return new PackageCatalog() + { + Name = catalogName, + Description = _stringResource.GetLocalized(jsonCatalog.DescriptionResourceKey), + Packages = packages.ToReadOnlyCollection(), + }; + } + else + { + Log.Logger?.ReportWarn(Log.Component.AppManagement, $"JSON package catalog [{catalogName}] is empty"); + } + } + catch (Exception e) + { + Log.Logger?.ReportError(Log.Component.AppManagement, $"Error loading packages from winget catalog.", e); + } + + return null; + } + + private async Task GetJsonApplicationIconAsync(JsonWinGetPackage package) + { + try + { + if (!string.IsNullOrEmpty(package.Icon)) + { + // Load icon from application assets + var iconFile = await StorageFile.GetFileFromApplicationUriAsync(new Uri(package.Icon)); + var icon = await iconFile.OpenAsync(FileAccessMode.Read); + + // Ensure stream is not empty to prevent rendering an empty image + if (icon.Size > 0) + { + return icon; + } + } + } + catch (Exception e) + { + Log.Logger?.ReportError(Log.Component.AppManagement, $"Failed to get icon for JSON package {package.Uri}.", e); + } + + Log.Logger?.ReportWarn(Log.Component.AppManagement, $"No icon found for JSON package {package.Uri}. A default one will be provided."); + return null; + } +} diff --git a/tools/SetupFlow/DevHome.SetupFlow/Services/WinGetPackageRestoreDataSource.cs b/tools/SetupFlow/DevHome.SetupFlow/Services/WinGetPackageRestoreDataSource.cs index 3330ad8e85..de0518f02e 100644 --- a/tools/SetupFlow/DevHome.SetupFlow/Services/WinGetPackageRestoreDataSource.cs +++ b/tools/SetupFlow/DevHome.SetupFlow/Services/WinGetPackageRestoreDataSource.cs @@ -157,7 +157,7 @@ private string GetDescription() /// Application information /// Package URI /// All restored applications are from winget catalog - private Uri GetPackageUri(IRestoreApplicationInfo appInfo) + private WinGetPackageUri GetPackageUri(IRestoreApplicationInfo appInfo) { return WindowsPackageManager.CreateWinGetCatalogPackageUri(appInfo.Id); } diff --git a/tools/SetupFlow/DevHome.SetupFlow/Services/WindowsPackageManager.cs b/tools/SetupFlow/DevHome.SetupFlow/Services/WindowsPackageManager.cs index 9073f29ee1..3945c48371 100644 --- a/tools/SetupFlow/DevHome.SetupFlow/Services/WindowsPackageManager.cs +++ b/tools/SetupFlow/DevHome.SetupFlow/Services/WindowsPackageManager.cs @@ -1,7 +1,6 @@ // Copyright (c) Microsoft Corporation. // Licensed under the MIT License. -using System; using System.Collections.Generic; using System.Threading.Tasks; using DevHome.SetupFlow.Models; @@ -46,10 +45,10 @@ public async Task InitializeAsync() } /// - public async Task InstallPackageAsync(IWinGetPackage package) => await _operations.InstallPackageAsync(package); + public async Task InstallPackageAsync(WinGetPackageUri packageUri) => await _operations.InstallPackageAsync(packageUri); /// - public async Task> GetPackagesAsync(IList packageUris) => await _operations.GetPackagesAsync(packageUris); + public async Task> GetPackagesAsync(IList packageUris) => await _operations.GetPackagesAsync(packageUris); /// public async Task> SearchAsync(string query, uint limit) => await _operations.SearchAsync(query, limit); @@ -70,14 +69,14 @@ public async Task InitializeAsync() public bool IsWinGetPackage(IWinGetPackage package) => _catalogConnector.IsWinGetPackage(package); /// - public Uri CreatePackageUri(IWinGetPackage package) => _protocolParser.CreatePackageUri(package); + public WinGetPackageUri CreatePackageUri(IWinGetPackage package) => _protocolParser.CreatePackageUri(package); /// - public Uri CreateWinGetCatalogPackageUri(string packageId) => _protocolParser.CreateWinGetCatalogPackageUri(packageId); + public WinGetPackageUri CreateWinGetCatalogPackageUri(string packageId) => _protocolParser.CreateWinGetCatalogPackageUri(packageId); /// - public Uri CreateMsStoreCatalogPackageUri(string packageId) => _protocolParser.CreateMsStoreCatalogPackageUri(packageId); + public WinGetPackageUri CreateMsStoreCatalogPackageUri(string packageId) => _protocolParser.CreateMsStoreCatalogPackageUri(packageId); /// - public Uri CreateCustomCatalogPackageUri(string packageId, string catalogName) => _protocolParser.CreateCustomCatalogPackageUri(packageId, catalogName); + public WinGetPackageUri CreateCustomCatalogPackageUri(string packageId, string catalogName) => _protocolParser.CreateCustomCatalogPackageUri(packageId, catalogName); } diff --git a/tools/SetupFlow/DevHome.SetupFlow/Strings/en-us/Resources.resw b/tools/SetupFlow/DevHome.SetupFlow/Strings/en-us/Resources.resw index 0215395b2c..4fb2956fcf 100644 --- a/tools/SetupFlow/DevHome.SetupFlow/Strings/en-us/Resources.resw +++ b/tools/SetupFlow/DevHome.SetupFlow/Strings/en-us/Resources.resw @@ -518,8 +518,16 @@ Body text description for a card than when clicked takes the user to a multi-step flow for setting up their machine - End-to-end setup - Header for a card than when clicked takes the user to a multi-step flow for setting up their machine + Set up local machine + Header for a card that when clicked takes the user to a multi-step flow for setting up their machine + + + Set up a target + Header for a card that when clicked takes the user to a multi-step flow for setting up for a remote machine + + + Set up a select target + Body text description for a card than when clicked takes the user to a multi-step flow for setting up their machine No apps to install @@ -669,10 +677,18 @@ Search for applications to install or select them below. Description for the application management page - + Add private or public repositories to clone to your machine. Description for the repo config page + + Add public repositories to clone to your target machine. + Description for the repo config page + + + Select a target machine to set up. + Description for the setup target page + Review the terms and setup details below before applying these changes to your computer. Description for the review page @@ -1348,4 +1364,240 @@ This configuration unit was not run because an assert failed or was false. + + Setup target + Title of the 'Setup Target' page where users can select a remote machine to setup using Dev Home. + + + All providers + value in combo box that will allow the user to view all the Dev Home extensions that are for interacting with remote machines or virtual machines + + + Filter + placeholder text for the textbox where users will be able to filter results from a list displayed in the UI. + + + Provider: + Textblock that appears on the side of the combo box that will contain the names of Dev Home extensions that are for interacting with remote machines or virtual machines. + + + Sort: + Textblock that appears on the side of the combo box that will contain a list of options to sort items on a page. + + + Name: A-Z + Textblock that appears inside of a combo box that is used to sort a list of remote computer names in Ascending order. + + + Name: Z-A + Textblock that appears inside of a combo box that is used to sort a list of remote computer names in descending order + + + Provider + Label for combo box that will contain the names of Dev Home extensions that are for interacting with remote machines or virtual machines. + + + Sort your environments + Label for combo box that will sort the users remote machines in ascending and descending order. + + + Sync your compute systems + Label for the sync button that allows users to refresh the list of remote machines and virtual machines. Dev Home calls these Compute systems. + + + No compute systems were found that support configuration + Label for when there are no compute systems available for a Dev Home extension. A compute system can be a remote machine or virtual machine. + + + Loading compute systems + Label for when we're loading compute systems from a Dev Home extension. A compute system can be a remote machine or virtual machine. + + + Only public Git repositories are supported at this time for the setup target flow + Message text advising the use that Dev Home currently only supports cloning public Git repositories in the setup target flow. + + + Public Git repositories only + Title text for an info bar that tells the user that they can only public git repositories in the setup target flow + + + The setup target's configuration step is still under construction. Until its complete we've disabled the setup button. + Message in info bar to show that the setup target's configuration page is still being worked on. + + + Warning + Title of info bar to show that the setup target's configuration page is still being worked on. + + + Version: + The operating system version of the compute system. + + + Proceeding with the next step may result in the target machine being rebooted and logging in may be required. + Message that explicitly tells the user that if they proceed we may reboot their machine and they may need to log back in. + + + Warning + Title for info bar that will warn users that proceeding with the setup may result in the restart and re-login of the targetted remote machine or virtual machine. + + + Proceeding with the next step may result in the target machine being rebooted and logging in may be required. Dev Home's Dev Setup Agent service will be installed as it is required to setup a Hyper-V virtual machine + Message that explicitly tells the user that if they proceed we may reboot their machine and they may need to log back in. + + + Unknown + Text that appears in an expander next to the remote computers name, letting the user know that we couldn't find its operating system. + + + Sync + Text that appears in a button to refresh the page that has a list of the users remote machine + + + Provider name: + The name of the technology platform that the compute system belongs to. + + + The entire configuration is now completed + Text for the completed status of the configuration operation that happened on a remote machine. This is for the entire configuration file we're applying + + + This part of the configuration is now complete + Text for the completed status of the configuration operation that happened on a remote machine. This is for a single part of the configuration we're applying + + + The entire configuration is still in progress + Text for the 'In progress' status of the configuration operation that happened on a remote machine. This is for the entire configuration file we're applying + + + This part of the configuration is in progress + Text for the 'In progress' status of the configuration operation that happened on a remote machine. This is for a single part of the configuration we're applying + + + The entire configuration is still pending + Text for the pending status of the configuration operation that happened on a remote machine. This is for the entire configuration file we're applying + + + This part of the configuration is still pending + Text for the pending status of the configuration operation that happened on a remote machine. This is for a single part of the configuration we're applying + + + We're currently provisioning device + Text for the 'provisioning device' status of the configuration operation that happened on a remote machine + + + We're currently restarting device + Text for the 'Restarting device' status of the configuration operation that happened on a remote machine + + + We're currently shutting down device + Text for the 'shut down' status of the configuration operation that happened on a remote machine + + + This part of the configuration has been skipped + Text for the 'shut down' status of the configuration operation that happened on a remote machine + + + We're currently starting the device + Text for when the device is starting up as part of the configuration operation that happened on a remote machine + + + Configuration progress received! + Text for when we display the progress of applying the entire configuration up to the point of displaying this message + + + State of this configuration unit is unknown + Generic text for when a part of the configuration we're attempting to apply on a remote computer is unknown. + + + State of this configuration unit is unknown + Generic text for when all of the configurations we're attempting to apply on a remote computer is unknown. + + + We're currently waiting for an admin to logon + Text for when the configuration operation on a remote machine is on going but the extension is waiting for a user with administrator priviledges to logon to the machine + + + We're currently waiting for the user to logon + Text for when the configuration operation on a remote machine is on going but the extension is waiting for the current user to logon to the machine + + + Winget skipped this unit + Text for when the a part of the configuration on the remove machine was completed but its completed status was set to skipped. + + + The extension failed to open the configuration file. You may need to check the extensions log file for more information + Text for when an extension recieved the configuration file string from Dev Home but was unable to open it + + + Configuration set status: {0} + Text for the ongoing progress of applying a set of configuration units in the configuration file. A configurations file are made up of configuration units. + + + There was an issue applying part of the configuration using DSC resource: '{0}'. Error: {1} + Locked={"0", "{1}"} Text for the ongoing progress of applying a configuration unit in the configuration file on to the remote machine. {0} is the name of the DSC resource we're using for this part of the configuration file, while {1} is the message from the other app. + + + There was an issue applying part of the configuration using DSC resource: '{0}'. Check the extension's logs + Locked={"0"} Text for the ongoing progress of applying a configuration unit in the configuration file on to the remote machine. {0} is the name of the DSC resource we're using for this part of the configuration file + + + Configuration unit status: {0} + Locked={"0"} Text for the error that occurred while of applying a configuration unit in the configuration file on to the remote machine. {0} is the status string, e.g "pending", "completed" but it comes from a different app + + + Action required. Please review the action center. ({0}/{1}) + Locked={"0", "{1}"} Text to tell the user that their action is needed in order to complete the configuration. {0} is the amount of attempts the user has done so far while {1} is the max amount attempts the user has. + + + Apply configuration stopped with status: {0}. You may need to check the extensions log file for more information + Locked={"0"} Info message that tells the user applying the configuration operation from the extension has stopped. {0} is the message from the extension who attempted to apply the configuration file. + + + The extension has stopped applying the configuration. You may need to check the logs if there were any failures. + Info message that tells the user applying the configuration operation from the extension has stopped. + + + Attempting to apply configuration on {0} + Locked={"0"} Text for the ongoing progress of applying the whole configuration file on to the remote machine. {0} is the name of the remote machine or virtual machine. + + + We couldn't apply the configuration file onto {0} + Locked={"0"} Label displayed when the settings listed in a configuration file have not been applied successfully on the remote machine. {0} is the name of the remote machine or virtual machine. + + + {0} successfully applied the configuration + Locked={"0"} Label displayed when a configuration file has been applied successfully on a remote machine. {0} is the name of the remote machine or virtual machine. + + + {0} successfully applied the configuration but a reboot is required + Locked={"0"} Label displayed when a configuration file has been applied successfully and a system restart is required for the remove machine. {0} is the name of the remote machine or virtual machine. + + + target machine + placeholder name that is used to describe a remote machine when we do not know its name + + + Corrective action was sent to the extension successfully + Text to tell the user that Dev Home was able to successfully send their corrective action back to the extension who was waiting for a response + + + Corrective action failed. Please try again + Text to tell the user that the corrective action that they took to fix an issue failed. The user is allowed to attempt to remediate the issue again. + + + Corrective action failed. No more attempts permitted + Text to tell the user that the corrective action that they took to fix an issue failed. The user is no longer allowed to remediate the issue. + + + To set up the Hyper-V virtual machine an installation of Dev Home's Dev Setup Agent service is required + message text to tell the user which app is needed to be installed inorder for Dev Home to complete the remote machines configuration. + + + App install required + title text for a caution infobar to alert the user that we need to install an app on their remote machine in order to complete the configuration + + + Loading your environments + Test that tells the user that we're still loading their environments. These environments can be virtual or remote machines + \ No newline at end of file diff --git a/tools/SetupFlow/DevHome.SetupFlow/Styles/AppManagement_ThemeResources.xaml b/tools/SetupFlow/DevHome.SetupFlow/Styles/AppManagement_ThemeResources.xaml index 734204943e..274dd5cce7 100644 --- a/tools/SetupFlow/DevHome.SetupFlow/Styles/AppManagement_ThemeResources.xaml +++ b/tools/SetupFlow/DevHome.SetupFlow/Styles/AppManagement_ThemeResources.xaml @@ -50,13 +50,14 @@ - + + @@ -71,15 +72,28 @@ - + + + | + + - +