diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml
index ad4ccee..fa11dd7 100644
--- a/.github/workflows/ci.yml
+++ b/.github/workflows/ci.yml
@@ -14,14 +14,12 @@ jobs:
steps:
- name: Checkout
- uses: actions/checkout@v4
+ uses: actions/checkout@v7
- name: Setup .NET
- uses: actions/setup-dotnet@v4
+ uses: actions/setup-dotnet@v6
with:
- dotnet-version: |
- 8.0.x
- 9.0.x
+ dotnet-version: 8.0.x
- name: Restore
run: dotnet restore HyperVConsoleKit.sln
@@ -34,3 +32,20 @@ jobs:
- name: Pack
run: dotnet pack src\HyperVConsoleKit\HyperVConsoleKit.csproj --configuration Release --no-build --output artifacts\packages
+
+ - name: Publish self-contained compatibility probe
+ run: dotnet publish samples\CompatibilityProbe\CompatibilityProbe.csproj --configuration Release --runtime win-x64 --self-contained true --output artifacts\compatibility-probe
+
+ - name: Verify package and publish artifacts
+ shell: pwsh
+ run: .\build\Verify-Artifacts.ps1 -PackageDirectory artifacts\packages -PublishDirectory artifacts\compatibility-probe
+
+ - name: Upload compatibility validation bundle
+ uses: actions/upload-artifact@v7
+ with:
+ name: HyperVConsoleKit-0.6.0-preview.1-validation
+ path: |
+ artifacts/compatibility-probe/**
+ artifacts/packages/**
+ if-no-files-found: error
+ retention-days: 14
diff --git a/.github/workflows/publish-nuget.yml b/.github/workflows/publish-nuget.yml
index f7f2078..dca2ba7 100644
--- a/.github/workflows/publish-nuget.yml
+++ b/.github/workflows/publish-nuget.yml
@@ -16,10 +16,10 @@ jobs:
steps:
- name: Checkout
- uses: actions/checkout@v4
+ uses: actions/checkout@v7
- name: Setup .NET
- uses: actions/setup-dotnet@v4
+ uses: actions/setup-dotnet@v6
with:
dotnet-version: 9.0.x
@@ -42,9 +42,20 @@ jobs:
throw "Release tag '${{ github.event.release.tag_name }}' does not match package version 'v$version'."
}
+ - name: Verify physical compatibility matrix
+ shell: pwsh
+ run: .\build\Verify-CompatibilityMatrix.ps1
+
- name: Pack
run: dotnet pack src\HyperVConsoleKit\HyperVConsoleKit.csproj --configuration Release --no-build --output artifacts\packages
+ - name: Publish self-contained compatibility probe
+ run: dotnet publish samples\CompatibilityProbe\CompatibilityProbe.csproj --configuration Release --runtime win-x64 --self-contained true --output artifacts\compatibility-probe
+
+ - name: Verify package and publish artifacts
+ shell: pwsh
+ run: .\build\Verify-Artifacts.ps1 -PackageDirectory artifacts\packages -PublishDirectory artifacts\compatibility-probe
+
- name: NuGet login
uses: NuGet/login@v1
id: login
diff --git a/HyperVConsoleKit.sln b/HyperVConsoleKit.sln
index 547c14c..d1098ad 100644
--- a/HyperVConsoleKit.sln
+++ b/HyperVConsoleKit.sln
@@ -23,6 +23,8 @@ Project("{2150E333-8FDC-42A3-9474-1A3956D46DE8}") = "tests", "tests", "{0AB3BF05
EndProject
Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "HyperVConsoleKit.Tests", "tests\HyperVConsoleKit.Tests\HyperVConsoleKit.Tests.csproj", "{82276E05-EF92-4499-89FC-1CC227702324}"
EndProject
+Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "CompatibilityProbe", "samples\CompatibilityProbe\CompatibilityProbe.csproj", "{9B4C74C8-9E41-4681-B8EA-9EF538057F63}"
+EndProject
Global
GlobalSection(SolutionConfigurationPlatforms) = preSolution
Debug|Any CPU = Debug|Any CPU
@@ -117,6 +119,18 @@ Global
{82276E05-EF92-4499-89FC-1CC227702324}.Release|x64.Build.0 = Release|Any CPU
{82276E05-EF92-4499-89FC-1CC227702324}.Release|x86.ActiveCfg = Release|Any CPU
{82276E05-EF92-4499-89FC-1CC227702324}.Release|x86.Build.0 = Release|Any CPU
+ {9B4C74C8-9E41-4681-B8EA-9EF538057F63}.Debug|Any CPU.ActiveCfg = Debug|Any CPU
+ {9B4C74C8-9E41-4681-B8EA-9EF538057F63}.Debug|Any CPU.Build.0 = Debug|Any CPU
+ {9B4C74C8-9E41-4681-B8EA-9EF538057F63}.Debug|x64.ActiveCfg = Debug|Any CPU
+ {9B4C74C8-9E41-4681-B8EA-9EF538057F63}.Debug|x64.Build.0 = Debug|Any CPU
+ {9B4C74C8-9E41-4681-B8EA-9EF538057F63}.Debug|x86.ActiveCfg = Debug|Any CPU
+ {9B4C74C8-9E41-4681-B8EA-9EF538057F63}.Debug|x86.Build.0 = Debug|Any CPU
+ {9B4C74C8-9E41-4681-B8EA-9EF538057F63}.Release|Any CPU.ActiveCfg = Release|Any CPU
+ {9B4C74C8-9E41-4681-B8EA-9EF538057F63}.Release|Any CPU.Build.0 = Release|Any CPU
+ {9B4C74C8-9E41-4681-B8EA-9EF538057F63}.Release|x64.ActiveCfg = Release|Any CPU
+ {9B4C74C8-9E41-4681-B8EA-9EF538057F63}.Release|x64.Build.0 = Release|Any CPU
+ {9B4C74C8-9E41-4681-B8EA-9EF538057F63}.Release|x86.ActiveCfg = Release|Any CPU
+ {9B4C74C8-9E41-4681-B8EA-9EF538057F63}.Release|x86.Build.0 = Release|Any CPU
EndGlobalSection
GlobalSection(SolutionProperties) = preSolution
HideSolutionNode = FALSE
@@ -129,5 +143,6 @@ Global
{40CCF4A0-91E2-4B01-B45C-0DAC2F0DB07C} = {827E0CD3-B72D-47B6-A68D-7590B98EB39B}
{DEB5D1AA-CA37-4BBA-BF7C-43A72B026720} = {5D20AA90-6969-D8BD-9DCD-8634F4692FDA}
{82276E05-EF92-4499-89FC-1CC227702324} = {0AB3BF05-4346-4AA6-1389-037BE0695223}
+ {9B4C74C8-9E41-4681-B8EA-9EF538057F63} = {5D20AA90-6969-D8BD-9DCD-8634F4692FDA}
EndGlobalSection
EndGlobal
diff --git a/README.md b/README.md
index c0084e0..42e71dc 100644
--- a/README.md
+++ b/README.md
@@ -26,22 +26,25 @@ It is not trying to replace RDP, VMConnect, ScreenConnect, TeamViewer, or a prop
The core library targets:
```xml
-net46;netstandard2.0;net8.0-windows
+net8.0-windows
```
-That means it is compatible with modern .NET on Windows, including .NET Core/.NET services that can consume `netstandard2.0`, plus .NET 8 Windows apps and services. It is not cross-platform .NET because Hyper-V console capture and input are Windows-only WMI APIs.
+The supported deployment shape is a self-contained .NET 8 Windows x64 application or service. It is not
+cross-platform because Hyper-V console capture and input are Windows-only management APIs.
The NuGet package includes XML documentation for IntelliSense and generated API documentation.
## How It Works
-The raw emergency console path uses the Hyper-V WMI provider:
+The raw emergency console path uses `Microsoft.Management.Infrastructure` with a local CIM session connected to
+the Hyper-V provider:
```text
root\virtualization\v2
```
Screen capture uses Hyper-V thumbnail capture. Input uses Hyper-V virtual keyboard and mouse WMI devices where available.
+The package does not depend on `System.Management` and does not load `wminet_utils.dll`.
Single captures return raw `Rgb565` bytes sized exactly:
@@ -81,7 +84,7 @@ Your process must run on the Hyper-V host with administrator rights or as LocalS
```csharp
using HyperVConsoleKit;
-var client = new HyperVConsoleClient();
+using var client = new HyperVConsoleClient();
foreach (var vm in client.GetVirtualMachines())
{
@@ -89,6 +92,9 @@ foreach (var vm in client.GetVirtualMachines())
}
```
+`HyperVConsoleClient` owns its local CIM session and is disposable. Dispose any console sessions before disposing
+their client.
+
## Agent Policy
If you are embedding this in an MSP agent, set policy at the client or session boundary. This keeps your service from accidentally opening an unlimited, full-resolution, full-input remote console.
@@ -110,7 +116,7 @@ var policy = new HyperVConsolePolicy
AllowPowerControl = false
};
-var client = new HyperVConsoleClient(policy);
+using var client = new HyperVConsoleClient(policy);
```
Power control defaults to disabled. Policy and stream options are copied when retained, so changing the original
@@ -177,7 +183,7 @@ using var session = client.OpenConsole(
```csharp
using HyperVConsoleKit;
-var client = new HyperVConsoleClient();
+using var client = new HyperVConsoleClient();
var vms = client.GetVirtualMachines();
foreach (var vm in vms)
@@ -202,7 +208,7 @@ Use this before deciding what UI to show a technician.
```csharp
using HyperVConsoleKit;
-var client = new HyperVConsoleClient();
+using var client = new HyperVConsoleClient();
var vm = client.GetVirtualMachines().First(v => v.Name == "Recovery VM");
var caps = client.GetConsoleCapabilities(vm.Id);
@@ -241,7 +247,7 @@ recommends `RawHostConsole`; Enhanced Session remains an optional local VMConnec
```csharp
using HyperVConsoleKit;
-var client = new HyperVConsoleClient();
+using var client = new HyperVConsoleClient();
var vm = client.GetVirtualMachines().First(v => v.IsRunning);
using var session = client.OpenConsole(vm.Id, new HyperVConsoleOpenOptions
@@ -321,7 +327,7 @@ The streaming API gives you frames and lets you choose the transport.
```csharp
using HyperVConsoleKit;
-var client = new HyperVConsoleClient();
+using var client = new HyperVConsoleClient();
var vm = client.GetVirtualMachines().First(v => v.IsRunning);
using var session = client.OpenConsole(vm.Id, new HyperVConsoleOpenOptions
@@ -603,7 +609,7 @@ shortcuts may still need the sample's dedicated command buttons.
## Power Controls
```csharp
-var powerClient = new HyperVConsoleClient(new HyperVConsolePolicy
+using var powerClient = new HyperVConsoleClient(new HyperVConsolePolicy
{
AllowPowerControl = true
});
@@ -758,8 +764,14 @@ dotnet test HyperVConsoleKit.sln
```
Current tests cover pixel conversion, tile diffing, policy validation/cloning, input contracts, and frame-hub
-lifecycle/keyframe behavior. GitHub CI builds, tests, and packs on pushes and pull requests; publishing repeats
-the tests and verifies that a release tag matches the package version.
+lifecycle/keyframe behavior. They also cover older-provider shapes, optional properties, immediate and asynchronous
+Hyper-V method completion, and dependency rejection. GitHub CI builds, tests, creates a self-contained x64 probe,
+and verifies package/publish artifacts; publishing repeats the checks and verifies that a release tag matches the
+package version.
+
+For physical-host compatibility validation, build and run the unchanged probe described in
+[`docs/COMPATIBILITY_VALIDATION.md`](docs/COMPATIBILITY_VALIDATION.md). Physical Hyper-V validation is required
+for every supported Windows release; CI alone is insufficient.
## WebSocket Frame Shape Used By The Sample
@@ -813,6 +825,7 @@ Guest is broken -> use RawHostConsole fallback
## Requirements
- Windows.
+- .NET 8, or a self-contained .NET 8 `win-x64` publish.
- Hyper-V installed.
- Administrator privileges or LocalSystem.
- Hyper-V WMI provider at `root\virtualization\v2`.
@@ -824,6 +837,8 @@ Guest is broken -> use RawHostConsole fallback
- Enhanced Session is detected and launchable, but not headlessly streamed.
- Mouse input depends on Hyper-V mouse device availability and guest behavior.
- International keyboard layouts and IME scenarios may need extra mapping work.
+- The compatibility matrix and current physical-host evidence are tracked in
+ [`docs/COMPATIBILITY_VALIDATION.md`](docs/COMPATIBILITY_VALIDATION.md).
- The sample web console has baseline controls, but still needs product-grade identity, authorization, TLS,
distributed rate limiting, durable audit storage, and operational monitoring before internet exposure.
diff --git a/THIRD-PARTY-NOTICES.md b/THIRD-PARTY-NOTICES.md
new file mode 100644
index 0000000..02e00e1
--- /dev/null
+++ b/THIRD-PARTY-NOTICES.md
@@ -0,0 +1,15 @@
+# Third-party notices
+
+HyperVConsoleKit uses the following third-party package:
+
+## Microsoft.Management.Infrastructure 3.0.0
+
+Copyright (c) Microsoft Corporation.
+
+Licensed under the MIT License. The package license and source are available from:
+
+- https://www.nuget.org/packages/Microsoft.Management.Infrastructure/3.0.0
+- https://github.com/PowerShell/MMI
+
+Microsoft.Management.Infrastructure is used for local Windows CIM access. HyperVConsoleKit does not use
+`System.Management`.
diff --git a/build/Verify-Artifacts.ps1 b/build/Verify-Artifacts.ps1
new file mode 100644
index 0000000..d357485
--- /dev/null
+++ b/build/Verify-Artifacts.ps1
@@ -0,0 +1,68 @@
+param(
+ [Parameter(Mandatory = $true)]
+ [string]$PackageDirectory,
+
+ [Parameter(Mandatory = $true)]
+ [string]$PublishDirectory
+)
+
+$ErrorActionPreference = 'Stop'
+
+$packageDirectoryPath = (Resolve-Path -LiteralPath $PackageDirectory).Path
+$publishDirectoryPath = (Resolve-Path -LiteralPath $PublishDirectory).Path
+
+$forbiddenPublishFiles = Get-ChildItem -LiteralPath $publishDirectoryPath -Recurse -File |
+ Where-Object { $_.Name -ieq 'System.Management.dll' }
+if ($forbiddenPublishFiles) {
+ throw "System.Management.dll was found in publish output: $($forbiddenPublishFiles.FullName -join ', ')"
+}
+
+$requiredNativeAsset = Get-ChildItem -LiteralPath $publishDirectoryPath -Recurse -File |
+ Where-Object { $_.Name -ieq 'Microsoft.Management.Infrastructure.Native.Unmanaged.dll' } |
+ Select-Object -First 1
+if (-not $requiredNativeAsset) {
+ throw 'The x64 Microsoft.Management.Infrastructure native runtime asset is missing from publish output.'
+}
+
+Add-Type -AssemblyName System.IO.Compression
+$projectPath = Join-Path $PSScriptRoot '..\src\HyperVConsoleKit\HyperVConsoleKit.csproj'
+[xml]$project = Get-Content -LiteralPath $projectPath
+$packageVersion = $project.Project.PropertyGroup.Version | Select-Object -First 1
+$expectedPackageName = "HyperVConsoleKit.$packageVersion.nupkg"
+$packages = Get-ChildItem -LiteralPath $packageDirectoryPath -Filter $expectedPackageName -File
+if (-not $packages) {
+ throw "Expected NuGet package $expectedPackageName was not found in $packageDirectoryPath."
+}
+
+foreach ($package in $packages) {
+ $archive = [System.IO.Compression.ZipFile]::OpenRead($package.FullName)
+ try {
+ $forbiddenEntries = $archive.Entries |
+ Where-Object { $_.FullName -match '(^|/)System\.Management(\.dll)?$' }
+ if ($forbiddenEntries) {
+ throw "System.Management was found in package $($package.Name)."
+ }
+
+ $nuspec = $archive.Entries | Where-Object { $_.FullName -like '*.nuspec' } | Select-Object -First 1
+ if (-not $nuspec) {
+ throw "Package $($package.Name) has no nuspec."
+ }
+
+ $reader = [System.IO.StreamReader]::new($nuspec.Open())
+ try {
+ $nuspecText = $reader.ReadToEnd()
+ }
+ finally {
+ $reader.Dispose()
+ }
+
+ if ($nuspecText -match 'System\.Management') {
+ throw "System.Management was found in the generated nuspec for $($package.Name)."
+ }
+ }
+ finally {
+ $archive.Dispose()
+ }
+}
+
+Write-Host 'Artifact verification passed: no System.Management package/assembly and the MMI x64 runtime asset is present.'
diff --git a/build/Verify-CompatibilityMatrix.ps1 b/build/Verify-CompatibilityMatrix.ps1
new file mode 100644
index 0000000..2083fcf
--- /dev/null
+++ b/build/Verify-CompatibilityMatrix.ps1
@@ -0,0 +1,14 @@
+param(
+ [string]$MatrixPath = 'validation\compatibility-matrix.json'
+)
+
+$ErrorActionPreference = 'Stop'
+
+$matrix = Get-Content -LiteralPath $MatrixPath -Raw | ConvertFrom-Json
+$pending = @($matrix.required | Where-Object { $_.status -ne 'Pass' })
+if ($pending.Count -gt 0) {
+ $names = $pending | ForEach-Object { "$($_.operatingSystem) $($_.architecture): $($_.status)" }
+ throw "Physical compatibility validation is incomplete: $($names -join '; ')"
+}
+
+Write-Host "Physical compatibility matrix passed for package $($matrix.packageVersion)."
diff --git a/docs/COMPATIBILITY_VALIDATION.md b/docs/COMPATIBILITY_VALIDATION.md
new file mode 100644
index 0000000..6685b62
--- /dev/null
+++ b/docs/COMPATIBILITY_VALIDATION.md
@@ -0,0 +1,51 @@
+# HyperVConsoleKit 0.6 compatibility validation
+
+The same self-contained `win-x64` probe must pass on every supported operating system before the prerelease is
+published.
+
+## Required matrix
+
+| Operating system | Status | Evidence |
+| --- | --- | --- |
+| Windows Server 2012 R2 x64 with ESU | Pending | Required physical Hyper-V host |
+| Windows Server 2016 x64 | Pending | Must include the host exhibiting the original `wminet_utils.dll` failure |
+| Windows Server 2019 x64 | Pending | Required physical Hyper-V host |
+| Windows Server 2022 x64 | Pending | Required physical Hyper-V host |
+| Windows Server 2025 x64 | Passed | Build 26100; local Hyper-V probe passed |
+| Windows 10 x64 | Pending | Required physical Hyper-V host |
+| Windows 11 x64 | Pending | Required physical Hyper-V host |
+
+The Server 2025 run passed VM enumeration and lookup, capability discovery, first and sustained capture,
+recommended-size capture, mouse movement and click, Enter, Shift+Tab, Ctrl+Alt+Del, repeated open/close,
+client disposal, asynchronous-job VM start, force-stop with restoration to the original offline state, and
+absence of both `System.Management.dll` and `wminet_utils.dll`. The unchanged self-contained probe passed once as
+an elevated administrator and once as `NT AUTHORITY\SYSTEM`.
+
+## Build the unchanged probe
+
+```powershell
+dotnet publish samples\CompatibilityProbe\CompatibilityProbe.csproj `
+ --configuration Release `
+ --runtime win-x64 `
+ --self-contained true `
+ --output artifacts\compatibility-probe
+```
+
+Copy the complete `artifacts\compatibility-probe` directory to each host. From an elevated prompt on the Hyper-V
+host, with a designated running test VM:
+
+```powershell
+.\CompatibilityProbe.exe `
+ --vm "Test VM" `
+ --send-input `
+ --output .\compatibility-result.json
+```
+
+`--send-input` sends a mouse click, Enter, Shift+Tab, and Ctrl+Alt+Del to the selected VM. Use only a designated
+test VM. When starting from an offline test VM, add `--power-cycle` to exercise the package's start and force-stop
+paths; the probe returns the VM to its original offline state.
+
+Repeat the probe under `LocalSystem`, using the same files and VM. Retain the JSON report with the OS build,
+process architecture, package version, per-check results, loaded managed assemblies, and loaded native modules.
+
+CI success is not a substitute for these physical-host results.
diff --git a/docs/MANAGEMENT_MIGRATION.md b/docs/MANAGEMENT_MIGRATION.md
new file mode 100644
index 0000000..e8a0531
--- /dev/null
+++ b/docs/MANAGEMENT_MIGRATION.md
@@ -0,0 +1,56 @@
+# Hyper-V management migration inventory
+
+HyperVConsoleKit 0.6 replaces `System.Management` with an internal adapter over
+`Microsoft.Management.Infrastructure`. The adapter opens only a local `CimSession`; it does not configure
+WS-Man, credentials, listeners, or remote management.
+
+## Current provider surface
+
+Namespace: `root\virtualization\v2`
+
+### Queries
+
+| Purpose | WQL |
+| --- | --- |
+| Enumerate VMs | `SELECT * FROM Msvm_ComputerSystem WHERE Caption = 'Virtual Machine'` |
+| Find VM | Same query with `Name = ''` |
+| Find management service | `SELECT * FROM Msvm_VirtualSystemManagementService` |
+
+### Properties
+
+| Class | Properties |
+| --- | --- |
+| `Msvm_ComputerSystem` | `Name`, `ElementName`, `EnabledState` |
+| `Msvm_VirtualSystemSettingData` | optional `EnhancedSessionTransportType` |
+| `Msvm_VideoHead` | optional `CurrentHorizontalResolution`, `CurrentVerticalResolution`, `CurrentBitsPerPixel`, `EnabledState` |
+| `Msvm_ConcreteJob` | `JobState`, optional `ErrorCode` |
+
+### Associations
+
+| Source | Association | Result |
+| --- | --- | --- |
+| `Msvm_ComputerSystem` | `Msvm_SettingsDefineState` | `Msvm_VirtualSystemSettingData` |
+| `Msvm_ComputerSystem` | `Msvm_SystemDevice` | `Msvm_VideoHead` |
+| `Msvm_ComputerSystem` | `Msvm_SystemDevice` | `Msvm_Keyboard` |
+| `Msvm_ComputerSystem` | `Msvm_SystemDevice` | `Msvm_SyntheticMouse` |
+
+### Methods
+
+| Class | Methods and inputs |
+| --- | --- |
+| `Msvm_ComputerSystem` | `RequestStateChange(RequestedState)` |
+| `Msvm_VirtualSystemManagementService` | `GetVirtualSystemThumbnailImage(TargetSystem, WidthPixels, HeightPixels)` |
+| `Msvm_Keyboard` | `TypeText(asciiText)`, `TypeKey(keyCode)`, `PressKey(keyCode)`, `ReleaseKey(keyCode)`, `TypeScancodes(scanCodes)`, `TypeCtrlAltDel()` |
+| `Msvm_SyntheticMouse` | `SetAbsolutePosition(HorizontalPosition, VerticalPosition)`, `ClickButton(ButtonIndex)` |
+
+Every method processes immediate return code `0`. Return code `4096` resolves and polls the returned
+`Msvm_ConcreteJob`; other provider return codes retain the existing `HyperVWmiException` contract.
+
+## Compatibility behavior
+
+- Missing optional settings or video properties produce unknown/default capabilities instead of failing VM enumeration.
+- Provider availability, access denial, invalid class, timeout, and operational failures are classified separately.
+- Management implementation types remain internal.
+- `HyperVConsoleClient` now owns the local CIM session and implements `IDisposable`. Console sessions must be
+ disposed before their client.
+- Package and CI checks reject `System.Management` in references, package metadata, and publish output.
diff --git a/docs/PACKAGE_DEPENDENCIES.md b/docs/PACKAGE_DEPENDENCIES.md
new file mode 100644
index 0000000..7dfca24
--- /dev/null
+++ b/docs/PACKAGE_DEPENDENCIES.md
@@ -0,0 +1,28 @@
+# Package dependency report
+
+Package: `HyperVConsoleKit 0.6.0-preview.1`
+
+Target: `net8.0-windows7.0`
+
+## Direct dependency
+
+| Package | Version |
+| --- | --- |
+| `Microsoft.Management.Infrastructure` | `3.0.0` |
+
+## Transitive dependencies
+
+| Package | Version |
+| --- | --- |
+| `Microsoft.Management.Infrastructure.Runtime.Win` | `3.0.0` |
+| `Microsoft.Management.Infrastructure.Runtime.Unix` | `3.0.0` |
+
+NuGet resolves the Windows runtime assets for the supported `win-x64` publish. The published self-contained probe
+contains:
+
+- `Microsoft.Management.Infrastructure.dll`
+- `Microsoft.Management.Infrastructure.Native.dll`
+- `Microsoft.Management.Infrastructure.Native.Unmanaged.dll`
+
+The project reference graph, generated package nuspec, `.nupkg`, and self-contained publish output contain no
+`System.Management` dependency or assembly.
diff --git a/docs/PUBLIC_API_COMPATIBILITY.md b/docs/PUBLIC_API_COMPATIBILITY.md
new file mode 100644
index 0000000..44d06a0
--- /dev/null
+++ b/docs/PUBLIC_API_COMPATIBILITY.md
@@ -0,0 +1,26 @@
+# Public API compatibility report
+
+Comparison baseline: published HyperVConsoleKit 0.5.0 `net8.0-windows7.0` assembly.
+
+## Result
+
+The existing .NET 8 public type and member surface is retained. Hyper-V management implementation types remain
+internal.
+
+Additions in 0.6:
+
+- `HyperVConsoleClient` implements `IDisposable` and adds `Dispose()`.
+- `HyperVProviderUnavailableException`
+- `HyperVProviderClassUnavailableException`
+- `HyperVManagementTimeoutException`
+
+The new exceptions derive from the existing `HyperVConsoleException`. Existing access-denied, VM-not-found,
+capture, keyboard, mouse, and WMI return-code exception contracts remain available.
+
+## Intentional compatibility change
+
+The package now contains only `net8.0-windows7.0`. The former `net46` and `netstandard2.0` assets were intentionally
+removed because ScreenGymp's self-contained .NET 8 Windows x64 agent is the sole consumer.
+
+Console sessions must be disposed before their owning client. Existing code that did not dispose the client
+continues to compile, but new code should dispose it so the local CIM session is released deterministically.
diff --git a/samples/CompatibilityProbe/CompatibilityProbe.csproj b/samples/CompatibilityProbe/CompatibilityProbe.csproj
new file mode 100644
index 0000000..6d8f255
--- /dev/null
+++ b/samples/CompatibilityProbe/CompatibilityProbe.csproj
@@ -0,0 +1,15 @@
+
+
+
+ Exe
+ net8.0-windows
+ enable
+ enable
+ win-x64
+
+
+
+
+
+
+
diff --git a/samples/CompatibilityProbe/Program.cs b/samples/CompatibilityProbe/Program.cs
new file mode 100644
index 0000000..7d5291f
--- /dev/null
+++ b/samples/CompatibilityProbe/Program.cs
@@ -0,0 +1,428 @@
+using System.Diagnostics;
+using System.Reflection;
+using System.Runtime.InteropServices;
+using System.Text.Json;
+using HyperVConsoleKit;
+
+var vmSelector = GetOption("--vm");
+var outputPath = GetOption("--output");
+var sendInput = args.Contains("--send-input", StringComparer.OrdinalIgnoreCase);
+var powerCycle = args.Contains("--power-cycle", StringComparer.OrdinalIgnoreCase);
+var checks = new List();
+var startedUtc = DateTime.UtcNow;
+var startedByProbe = false;
+
+using var client = new HyperVConsoleClient(new HyperVConsolePolicy { AllowPowerControl = powerCycle });
+var virtualMachines = Run(
+ "VM enumeration",
+ () => client.GetVirtualMachines(),
+ checks,
+ Array.Empty());
+
+var vm = ResolveVm(vmSelector, virtualMachines);
+if (vm == null)
+{
+ checks.Add(ProbeCheck.Fail("VM selection", "No matching running virtual machine was found."));
+}
+else
+{
+ if (powerCycle && !vm.IsRunning)
+ {
+ var started = Run(
+ "Power start",
+ () =>
+ {
+ client.StartVirtualMachine(vm.Id);
+ return WaitForState(client, vm.Id, expectedRunning: true, TimeSpan.FromMinutes(1));
+ },
+ checks,
+ null);
+ if (started != null)
+ {
+ vm = started;
+ startedByProbe = true;
+ Run(
+ "Console readiness",
+ () => WaitForConsoleReady(client, vm.Id, TimeSpan.FromMinutes(1)),
+ checks,
+ false);
+ }
+ }
+
+ Run("VM lookup", () => client.GetVirtualMachine(vm.Id), checks, null);
+ var capabilities = Run("Console capabilities", () => client.GetConsoleCapabilities(vm.Id), checks, null);
+
+ if (capabilities != null && capabilities.CanCaptureNow)
+ {
+ using var session = Run(
+ "Console opening",
+ () => client.OpenConsole(vm.Id),
+ checks,
+ null);
+
+ if (session != null)
+ {
+ var smallFrame = Run(
+ "First frame",
+ () => session.CaptureFrame(new ConsoleFrameOptions { Width = 64, Height = 64 }),
+ checks,
+ null);
+ if (smallFrame != null)
+ {
+ Check(
+ "First frame shape",
+ smallFrame.RawBytes.Length == smallFrame.Width * smallFrame.Height * 2,
+ $"{smallFrame.Width}x{smallFrame.Height}, {smallFrame.RawBytes.Length} bytes",
+ checks);
+ }
+
+ var recommendedFrame = Run(
+ "Recommended frame",
+ () => session.CaptureFrame(new ConsoleFrameOptions()),
+ checks,
+ null);
+
+ Run(
+ "Sustained capture",
+ () =>
+ {
+ using var cancellation = new CancellationTokenSource(TimeSpan.FromSeconds(5));
+ var frames = 0;
+ try
+ {
+ session.StreamFramesAsync(
+ new ConsoleFrameStreamOptions
+ {
+ Width = 64,
+ Height = 64,
+ PixelFormat = ConsoleFramePixelFormat.Rgb332,
+ ActiveFramesPerSecond = 3,
+ IdleFramesPerSecond = 1
+ },
+ (frame, token) =>
+ {
+ frames++;
+ return Task.CompletedTask;
+ },
+ cancellation.Token).GetAwaiter().GetResult();
+ }
+ catch (OperationCanceledException)
+ {
+ }
+
+ if (frames < 2)
+ {
+ throw new InvalidOperationException($"Expected at least two frames, received {frames}.");
+ }
+
+ return frames;
+ },
+ checks,
+ 0);
+
+ if (capabilities.CanSendMouseInputNow && recommendedFrame != null)
+ {
+ var x = recommendedFrame.Width / 2;
+ var y = recommendedFrame.Height / 2;
+ Check("Mouse movement", session.TrySendMouseMove(x, y), $"{x},{y}", checks);
+ if (sendInput)
+ {
+ Check("Mouse click", session.TrySendMouseClick(x, y, MouseButton.Left), "Left", checks);
+ }
+ else
+ {
+ checks.Add(ProbeCheck.Skip("Mouse click", "Pass --send-input to exercise guest input."));
+ }
+ }
+
+ if (capabilities.CanSendKeyboardInputNow && sendInput)
+ {
+ RunAction("Enter input", () => session.SendKey(ConsoleKeyCode.Enter), checks);
+ RunAction(
+ "Shift+Tab input",
+ () => session.SendChord(ConsoleKeyCode.Shift, ConsoleKeyCode.Tab),
+ checks);
+ RunAction("Ctrl+Alt+Del input", session.SendCtrlAltDel, checks);
+ }
+ else
+ {
+ checks.Add(ProbeCheck.Skip("Keyboard input", "Unavailable or --send-input was not supplied."));
+ }
+ }
+
+ RunAction(
+ "Repeated open and close",
+ () =>
+ {
+ for (var index = 0; index < 3; index++)
+ {
+ using var repeatedSession = client.OpenConsole(vm.Id);
+ repeatedSession.CaptureFrame(new ConsoleFrameOptions { Width = 64, Height = 64 });
+ }
+ },
+ checks);
+ }
+ else
+ {
+ checks.Add(ProbeCheck.Fail("Live console checks", "The selected VM cannot capture in its current state."));
+ }
+}
+
+if (startedByProbe && vm != null)
+{
+ Run(
+ "Power force stop",
+ () =>
+ {
+ client.StopVirtualMachine(vm.Id, force: true);
+ return WaitForState(client, vm.Id, expectedRunning: false, TimeSpan.FromMinutes(1));
+ },
+ checks,
+ null);
+}
+
+var loadedAssemblies = AppDomain.CurrentDomain.GetAssemblies()
+ .Select(assembly => assembly.GetName().Name ?? string.Empty)
+ .Where(name => name.Length > 0)
+ .OrderBy(name => name, StringComparer.OrdinalIgnoreCase)
+ .ToArray();
+var loadedModules = GetLoadedModules();
+
+Check(
+ "System.Management assembly absence",
+ !loadedAssemblies.Contains("System.Management", StringComparer.OrdinalIgnoreCase),
+ "System.Management.dll is not loaded.",
+ checks);
+Check(
+ "wminet_utils module absence",
+ !loadedModules.Any(path => string.Equals(Path.GetFileName(path), "wminet_utils.dll", StringComparison.OrdinalIgnoreCase)),
+ "wminet_utils.dll is not loaded.",
+ checks);
+
+client.Dispose();
+RunAction(
+ "Disposed client rejection",
+ () =>
+ {
+ try
+ {
+ client.GetVirtualMachines();
+ }
+ catch (ObjectDisposedException)
+ {
+ return;
+ }
+
+ throw new InvalidOperationException("Disposed client accepted an operation.");
+ },
+ checks);
+
+var report = new ProbeReport
+{
+ StartedUtc = startedUtc,
+ CompletedUtc = DateTime.UtcNow,
+ OsDescription = RuntimeInformation.OSDescription,
+ OsVersion = Environment.OSVersion.VersionString,
+ OsArchitecture = RuntimeInformation.OSArchitecture.ToString(),
+ ProcessArchitecture = RuntimeInformation.ProcessArchitecture.ToString(),
+ FrameworkDescription = RuntimeInformation.FrameworkDescription,
+ Is64BitProcess = Environment.Is64BitProcess,
+ Is64BitOperatingSystem = Environment.Is64BitOperatingSystem,
+ UserName = Environment.UserName,
+ ProcessIdentity = WindowsIdentityName(),
+ PackageAssemblyVersion = typeof(HyperVConsoleClient).Assembly
+ .GetCustomAttribute()?.InformationalVersion,
+ SelectedVm = vm == null ? null : new ProbeVm
+ {
+ Id = vm.Id,
+ Name = vm.Name,
+ State = vm.State.ToString()
+ },
+ LoadedAssemblies = loadedAssemblies,
+ LoadedModules = loadedModules,
+ Checks = checks,
+ Passed = checks.All(check => check.Outcome != "Fail")
+};
+
+var json = JsonSerializer.Serialize(report, new JsonSerializerOptions { WriteIndented = true });
+Console.WriteLine(json);
+if (!string.IsNullOrWhiteSpace(outputPath))
+{
+ File.WriteAllText(outputPath, json);
+}
+
+return report.Passed ? 0 : 1;
+
+string? GetOption(string name)
+{
+ var index = Array.FindIndex(args, arg => string.Equals(arg, name, StringComparison.OrdinalIgnoreCase));
+ return index >= 0 && index + 1 < args.Length ? args[index + 1] : null;
+}
+
+static HyperVVirtualMachine? ResolveVm(string? selector, IReadOnlyList virtualMachines)
+{
+ if (string.IsNullOrWhiteSpace(selector))
+ {
+ return virtualMachines.FirstOrDefault(candidate => candidate.IsRunning);
+ }
+
+ if (Guid.TryParse(selector, out var id))
+ {
+ return virtualMachines.FirstOrDefault(candidate => candidate.Id == id);
+ }
+
+ return virtualMachines.FirstOrDefault(
+ candidate => string.Equals(candidate.Name, selector, StringComparison.OrdinalIgnoreCase));
+}
+
+static HyperVVirtualMachine WaitForState(
+ HyperVConsoleClient client,
+ Guid vmId,
+ bool expectedRunning,
+ TimeSpan timeout)
+{
+ var deadline = DateTime.UtcNow.Add(timeout);
+ while (DateTime.UtcNow < deadline)
+ {
+ var vm = client.GetVirtualMachine(vmId);
+ if (vm.IsRunning == expectedRunning)
+ {
+ return vm;
+ }
+
+ Thread.Sleep(250);
+ }
+
+ throw new TimeoutException(
+ $"VM {vmId} did not reach expected running state {expectedRunning} within {timeout}.");
+}
+
+static bool WaitForConsoleReady(HyperVConsoleClient client, Guid vmId, TimeSpan timeout)
+{
+ var deadline = DateTime.UtcNow.Add(timeout);
+ HyperVWmiException? lastInvalidState = null;
+ while (DateTime.UtcNow < deadline)
+ {
+ try
+ {
+ using var session = client.OpenConsole(vmId);
+ session.CaptureFrame(new ConsoleFrameOptions());
+ return true;
+ }
+ catch (HyperVWmiException ex) when (ex.ReturnCode == 32775)
+ {
+ lastInvalidState = ex;
+ Thread.Sleep(500);
+ }
+ }
+
+ throw new TimeoutException(
+ $"VM {vmId} console did not become ready within {timeout}.",
+ lastInvalidState);
+}
+
+static T Run(string name, Func operation, ICollection checks, T fallback)
+{
+ try
+ {
+ var result = operation();
+ checks.Add(ProbeCheck.Pass(name));
+ return result;
+ }
+ catch (Exception ex)
+ {
+ checks.Add(ProbeCheck.Fail(name, ex.GetType().Name + ": " + ex.Message));
+ return fallback;
+ }
+}
+
+static void RunAction(string name, Action operation, ICollection checks)
+{
+ Run(
+ name,
+ () =>
+ {
+ operation();
+ return true;
+ },
+ checks,
+ false);
+}
+
+static void Check(string name, bool passed, string detail, ICollection checks)
+{
+ checks.Add(passed ? ProbeCheck.Pass(name, detail) : ProbeCheck.Fail(name, detail));
+}
+
+static string[] GetLoadedModules()
+{
+ try
+ {
+ return Process.GetCurrentProcess().Modules
+ .Cast()
+ .Select(module => module.FileName)
+ .Where(path => !string.IsNullOrWhiteSpace(path))
+ .OrderBy(path => path, StringComparer.OrdinalIgnoreCase)
+ .ToArray();
+ }
+ catch (Exception ex)
+ {
+ return new[] { "MODULE_ENUMERATION_FAILED: " + ex.GetType().Name + ": " + ex.Message };
+ }
+}
+
+static string WindowsIdentityName()
+{
+ try
+ {
+ return System.Security.Principal.WindowsIdentity.GetCurrent().Name;
+ }
+ catch
+ {
+ return Environment.UserName;
+ }
+}
+
+internal sealed class ProbeReport
+{
+ public DateTime StartedUtc { get; set; }
+ public DateTime CompletedUtc { get; set; }
+ public string? OsDescription { get; set; }
+ public string? OsVersion { get; set; }
+ public string? OsArchitecture { get; set; }
+ public string? ProcessArchitecture { get; set; }
+ public string? FrameworkDescription { get; set; }
+ public bool Is64BitProcess { get; set; }
+ public bool Is64BitOperatingSystem { get; set; }
+ public string? UserName { get; set; }
+ public string? ProcessIdentity { get; set; }
+ public string? PackageAssemblyVersion { get; set; }
+ public ProbeVm? SelectedVm { get; set; }
+ public string[] LoadedAssemblies { get; set; } = Array.Empty();
+ public string[] LoadedModules { get; set; } = Array.Empty();
+ public IReadOnlyCollection Checks { get; set; } = Array.Empty();
+ public bool Passed { get; set; }
+}
+
+internal sealed class ProbeVm
+{
+ public Guid Id { get; set; }
+ public string? Name { get; set; }
+ public string? State { get; set; }
+}
+
+internal sealed class ProbeCheck
+{
+ public string Name { get; set; } = string.Empty;
+ public string Outcome { get; set; } = string.Empty;
+ public string? Detail { get; set; }
+
+ public static ProbeCheck Pass(string name, string? detail = null) =>
+ new() { Name = name, Outcome = "Pass", Detail = detail };
+
+ public static ProbeCheck Fail(string name, string detail) =>
+ new() { Name = name, Outcome = "Fail", Detail = detail };
+
+ public static ProbeCheck Skip(string name, string detail) =>
+ new() { Name = name, Outcome = "Skip", Detail = detail };
+}
diff --git a/samples/ConsoleDiagnostics/Program.cs b/samples/ConsoleDiagnostics/Program.cs
index 6ca170c..6516cb0 100644
--- a/samples/ConsoleDiagnostics/Program.cs
+++ b/samples/ConsoleDiagnostics/Program.cs
@@ -2,7 +2,7 @@
var vmSelector = args.FirstOrDefault(arg => !arg.StartsWith("--", StringComparison.OrdinalIgnoreCase));
var sendInput = args.Any(arg => string.Equals(arg, "--send-input", StringComparison.OrdinalIgnoreCase));
-var client = new HyperVConsoleClient();
+using var client = new HyperVConsoleClient();
var vms = client.GetVirtualMachines();
if (vms.Count == 0)
diff --git a/samples/ConsoleKeyboardInput/Program.cs b/samples/ConsoleKeyboardInput/Program.cs
index 708390a..5289659 100644
--- a/samples/ConsoleKeyboardInput/Program.cs
+++ b/samples/ConsoleKeyboardInput/Program.cs
@@ -6,7 +6,7 @@
return 1;
}
-var client = new HyperVConsoleClient();
+using var client = new HyperVConsoleClient();
var vm = ResolveVm(args[0], client.GetVirtualMachines());
using (var session = client.OpenConsole(vm.Id))
diff --git a/samples/ConsoleListVms/Program.cs b/samples/ConsoleListVms/Program.cs
index 207f29f..3870c5c 100644
--- a/samples/ConsoleListVms/Program.cs
+++ b/samples/ConsoleListVms/Program.cs
@@ -1,6 +1,6 @@
using HyperVConsoleKit;
-var client = new HyperVConsoleClient();
+using var client = new HyperVConsoleClient();
var vms = client.GetVirtualMachines();
Console.WriteLine("Name\tId\tState\tCanCapture\tCanKeyboard\tCanMouse\tEnhanced\tRecommended");
diff --git a/samples/ConsoleScreenshot/Program.cs b/samples/ConsoleScreenshot/Program.cs
index d850ff4..1c497ce 100644
--- a/samples/ConsoleScreenshot/Program.cs
+++ b/samples/ConsoleScreenshot/Program.cs
@@ -1,6 +1,6 @@
using HyperVConsoleKit;
-var client = new HyperVConsoleClient();
+using var client = new HyperVConsoleClient();
var vms = client.GetVirtualMachines();
if (vms.Count == 0)
{
diff --git a/src/HyperVConsoleKit/CimManagement.cs b/src/HyperVConsoleKit/CimManagement.cs
new file mode 100644
index 0000000..40052c2
--- /dev/null
+++ b/src/HyperVConsoleKit/CimManagement.cs
@@ -0,0 +1,483 @@
+using System;
+using System.Collections;
+using System.Collections.Concurrent;
+using System.Collections.Generic;
+using System.Linq;
+using Microsoft.Management.Infrastructure;
+
+namespace HyperVConsoleKit
+{
+ ///
+ /// Small internal adapter over Microsoft.Management.Infrastructure. It intentionally
+ /// models only the WMI operations used by HyperVConsoleKit.
+ ///
+ internal class ManagementScope : IDisposable
+ {
+ private readonly ConcurrentDictionary _references =
+ new ConcurrentDictionary(StringComparer.OrdinalIgnoreCase);
+ private CimSession _session;
+
+ public ManagementScope(string namespacePath)
+ {
+ if (string.IsNullOrWhiteSpace(namespacePath))
+ {
+ throw new ArgumentException("A CIM namespace is required.", nameof(namespacePath));
+ }
+
+ NamespacePath = namespacePath;
+ }
+
+ internal string NamespacePath { get; }
+
+ internal CimSession Session
+ {
+ get
+ {
+ if (_session == null)
+ {
+ throw new ObjectDisposedException(nameof(ManagementScope));
+ }
+
+ return _session;
+ }
+ }
+
+ public virtual void Connect()
+ {
+ try
+ {
+ // A null computer name creates an in-process local CIM session. It does
+ // not opt into WS-Man, credentials, or remote management.
+ _session = CimSession.Create(null);
+ }
+ catch (CimException ex)
+ {
+ throw Translate(ex, "Unable to create the local CIM session.");
+ }
+ }
+
+ internal virtual IEnumerable Query(string query)
+ {
+ try
+ {
+ return Session.QueryInstances(NamespacePath, "WQL", query).ToArray();
+ }
+ catch (CimException ex)
+ {
+ throw Translate(ex, "The local CIM query failed.");
+ }
+ }
+
+ internal virtual IEnumerable GetRelated(
+ CimInstance source,
+ string relatedClass,
+ string associationClass,
+ string resultRole,
+ string thisRole)
+ {
+ try
+ {
+ return Session.EnumerateAssociatedInstances(
+ NamespacePath,
+ source,
+ associationClass,
+ relatedClass,
+ thisRole,
+ resultRole).ToArray();
+ }
+ catch (CimException ex)
+ {
+ throw Translate(ex, "The local CIM association query failed.");
+ }
+ }
+
+ internal virtual bool HasMethod(CimInstance instance, string methodName)
+ {
+ try
+ {
+ using (var cimClass = Session.GetClass(NamespacePath, instance.CimSystemProperties.ClassName))
+ {
+ return cimClass.CimClassMethods[methodName] != null;
+ }
+ }
+ catch (CimException ex)
+ {
+ throw Translate(ex, "Unable to read CIM method metadata for " + methodName + ".");
+ }
+ }
+
+ internal virtual ManagementBaseObject Invoke(
+ CimInstance instance,
+ string methodName,
+ ManagementBaseObject inParameters)
+ {
+ try
+ {
+ using (var cimClass = Session.GetClass(NamespacePath, instance.CimSystemProperties.ClassName))
+ {
+ var declaration = cimClass.CimClassMethods[methodName];
+ if (declaration == null)
+ {
+ throw new ManagementException(
+ "CIM class " + instance.CimSystemProperties.ClassName +
+ " does not expose method " + methodName + ".");
+ }
+
+ using (var parameters = new CimMethodParametersCollection())
+ {
+ if (inParameters != null)
+ {
+ foreach (var pair in inParameters.Values)
+ {
+ var parameterDeclaration = declaration.Parameters[pair.Key];
+ if (parameterDeclaration == null)
+ {
+ throw new ManagementException(
+ "CIM method " + methodName +
+ " has no input parameter named " + pair.Key + ".");
+ }
+
+ var value = pair.Value is ManagementObject managementObject
+ ? (object)managementObject.Instance
+ : pair.Value;
+ parameters.Add(CimMethodParameter.Create(
+ pair.Key,
+ value,
+ parameterDeclaration.CimType,
+ CimFlags.In));
+ }
+ }
+
+ return new ManagementBaseObject(
+ this,
+ Session.InvokeMethod(NamespacePath, instance, methodName, parameters));
+ }
+ }
+ }
+ catch (CimException ex)
+ {
+ throw Translate(ex, "The local CIM method call failed.");
+ }
+ }
+
+ internal virtual CimInstance Refresh(CimInstance instance)
+ {
+ try
+ {
+ return Session.GetInstance(NamespacePath, instance);
+ }
+ catch (CimException ex)
+ {
+ throw Translate(ex, "Unable to refresh the local CIM instance.");
+ }
+ }
+
+ internal void RegisterReference(CimInstance instance)
+ {
+ var path = instance?.CimSystemProperties?.Path;
+ if (!string.IsNullOrEmpty(path))
+ {
+ _references[path] = new CimInstance(instance);
+ }
+ }
+
+ internal virtual CimInstance ResolveReference(string path)
+ {
+ if (string.IsNullOrWhiteSpace(path))
+ {
+ throw new ArgumentException("A CIM instance path is required.", nameof(path));
+ }
+
+ if (!_references.TryGetValue(path, out var reference))
+ {
+ throw new ManagementException("The CIM reference was not returned by this management session: " + path);
+ }
+
+ try
+ {
+ return Session.GetInstance(NamespacePath, reference);
+ }
+ catch (CimException ex)
+ {
+ throw Translate(ex, "Unable to refresh CIM instance " + path + ".");
+ }
+ }
+
+ internal object ExportValue(object value)
+ {
+ if (value is CimInstance reference)
+ {
+ RegisterReference(reference);
+ return new ManagementObject(this, new CimInstance(reference));
+ }
+
+ if (value is CimInstance[] references)
+ {
+ return references
+ .Select(item => (object)new ManagementObject(this, new CimInstance(item)))
+ .ToArray();
+ }
+
+ return value;
+ }
+
+ internal static Exception Translate(CimException exception, string message)
+ {
+ switch (exception.NativeErrorCode)
+ {
+ case NativeErrorCode.AccessDenied:
+ return new UnauthorizedAccessException(message, exception);
+ case NativeErrorCode.InvalidNamespace:
+ case NativeErrorCode.ServerIsShuttingDown:
+ return new HyperVProviderUnavailableException(message, exception);
+ case NativeErrorCode.InvalidClass:
+ return new HyperVProviderClassUnavailableException(message, exception);
+ case NativeErrorCode.InvalidOperationTimeout:
+ return new HyperVManagementTimeoutException(message, exception);
+ default:
+ return new ManagementException(message, exception);
+ }
+ }
+
+ public virtual void Dispose()
+ {
+ var session = _session;
+ _session = null;
+ session?.Dispose();
+
+ foreach (var reference in _references.Values)
+ {
+ reference.Dispose();
+ }
+
+ _references.Clear();
+ }
+ }
+
+ internal sealed class ObjectQuery
+ {
+ public ObjectQuery(string queryString)
+ {
+ QueryString = queryString ?? throw new ArgumentNullException(nameof(queryString));
+ }
+
+ internal string QueryString { get; }
+ }
+
+ internal sealed class ManagementObjectSearcher : IDisposable
+ {
+ private readonly ManagementScope _scope;
+ private readonly ObjectQuery _query;
+
+ public ManagementObjectSearcher(ManagementScope scope, ObjectQuery query)
+ {
+ _scope = scope ?? throw new ArgumentNullException(nameof(scope));
+ _query = query ?? throw new ArgumentNullException(nameof(query));
+ }
+
+ public ManagementObjectCollection Get()
+ {
+ return new ManagementObjectCollection(
+ _scope.Query(_query.QueryString).Select(instance => new ManagementObject(_scope, instance)));
+ }
+
+ public void Dispose()
+ {
+ }
+ }
+
+ internal sealed class ManagementObjectCollection : IEnumerable, IDisposable
+ {
+ private readonly IReadOnlyList _items;
+ private readonly HashSet _ownedItems;
+
+ public ManagementObjectCollection(IEnumerable items)
+ {
+ _items = items.ToArray();
+ _ownedItems = new HashSet(_items);
+ }
+
+ public IEnumerator GetEnumerator()
+ {
+ return _items.GetEnumerator();
+ }
+
+ IEnumerator IEnumerable.GetEnumerator()
+ {
+ return GetEnumerator();
+ }
+
+ public void Detach(ManagementObject item)
+ {
+ _ownedItems.Remove(item);
+ }
+
+ public void Dispose()
+ {
+ foreach (var item in _ownedItems)
+ {
+ item.Dispose();
+ }
+
+ _ownedItems.Clear();
+ }
+ }
+
+ internal sealed class ManagementPath
+ {
+ public ManagementPath(string path)
+ {
+ Path = path;
+ }
+
+ public string Path { get; }
+ }
+
+ internal class ManagementBaseObject : IDisposable
+ {
+ private readonly Dictionary _values =
+ new Dictionary(StringComparer.OrdinalIgnoreCase);
+ private readonly CimMethodResult _methodResult;
+
+ internal ManagementBaseObject()
+ {
+ }
+
+ internal ManagementBaseObject(ManagementScope scope, CimMethodResult result)
+ {
+ _methodResult = result;
+ _values["ReturnValue"] = result.ReturnValue?.Value;
+ foreach (var parameter in result.OutParameters)
+ {
+ _values[parameter.Name] = scope.ExportValue(parameter.Value);
+ }
+ }
+
+ public object this[string propertyName]
+ {
+ get
+ {
+ _values.TryGetValue(propertyName, out var value);
+ return value;
+ }
+ set
+ {
+ _values[propertyName] = value;
+ }
+ }
+
+ internal IEnumerable> Values => _values;
+
+ public virtual void Dispose()
+ {
+ foreach (var value in _values.Values.OfType())
+ {
+ value.Dispose();
+ }
+
+ _methodResult?.Dispose();
+ }
+ }
+
+ internal sealed class ManagementObject : ManagementBaseObject
+ {
+ private readonly ManagementScope _scope;
+ private CimInstance _instance;
+
+ internal ManagementObject(ManagementScope scope, CimInstance instance)
+ {
+ _scope = scope ?? throw new ArgumentNullException(nameof(scope));
+ _instance = instance ?? throw new ArgumentNullException(nameof(instance));
+ _scope.RegisterReference(instance);
+ }
+
+ public ManagementObject(ManagementScope scope, ManagementPath path, object options)
+ : this(scope, scope.ResolveReference(path?.Path))
+ {
+ }
+
+ internal CimInstance Instance
+ {
+ get
+ {
+ if (_instance == null)
+ {
+ throw new ObjectDisposedException(nameof(ManagementObject));
+ }
+
+ return _instance;
+ }
+ }
+
+ public new object this[string propertyName]
+ {
+ get
+ {
+ var property = Instance.CimInstanceProperties[propertyName];
+ return property == null ? null : _scope.ExportValue(property.Value);
+ }
+ }
+
+ public ManagementPath Path => new ManagementPath(Instance.CimSystemProperties.Path);
+
+ public ManagementObjectCollection GetRelated(
+ string relatedClass,
+ string associationClass,
+ string requiredQualifier,
+ string requiredAssociationQualifier,
+ string resultRole,
+ string thisRole,
+ bool classDefinitionsOnly,
+ object options)
+ {
+ return new ManagementObjectCollection(
+ _scope.GetRelated(Instance, relatedClass, associationClass, resultRole, thisRole)
+ .Select(instance => new ManagementObject(_scope, instance)));
+ }
+
+ public ManagementBaseObject GetMethodParameters(string methodName)
+ {
+ if (!_scope.HasMethod(Instance, methodName))
+ {
+ throw new ManagementException(
+ "CIM class " + Instance.CimSystemProperties.ClassName +
+ " does not expose method " + methodName + ".");
+ }
+
+ return new ManagementBaseObject();
+ }
+
+ public ManagementBaseObject InvokeMethod(string methodName, ManagementBaseObject inParameters, object options)
+ {
+ return _scope.Invoke(Instance, methodName, inParameters);
+ }
+
+ public void Get()
+ {
+ var refreshed = _scope.Refresh(Instance);
+ var previous = _instance;
+ _instance = refreshed;
+ previous.Dispose();
+ _scope.RegisterReference(refreshed);
+ }
+
+ public override void Dispose()
+ {
+ var instance = _instance;
+ _instance = null;
+ instance?.Dispose();
+ base.Dispose();
+ }
+ }
+
+ internal sealed class ManagementException : Exception
+ {
+ public ManagementException(string message) : base(message)
+ {
+ }
+
+ public ManagementException(string message, Exception innerException) : base(message, innerException)
+ {
+ }
+ }
+}
diff --git a/src/HyperVConsoleKit/Exceptions.cs b/src/HyperVConsoleKit/Exceptions.cs
index b44dd5d..8f50522 100644
--- a/src/HyperVConsoleKit/Exceptions.cs
+++ b/src/HyperVConsoleKit/Exceptions.cs
@@ -19,6 +19,30 @@ public sealed class HyperVAccessDeniedException : HyperVConsoleException
public HyperVAccessDeniedException(string message, Exception innerException = null) : base(message, innerException) { }
}
+ ///
+ /// Thrown when the local Hyper-V management provider is unavailable.
+ ///
+ public sealed class HyperVProviderUnavailableException : HyperVConsoleException
+ {
+ public HyperVProviderUnavailableException(string message, Exception innerException = null) : base(message, innerException) { }
+ }
+
+ ///
+ /// Thrown when a required Hyper-V provider class is unavailable.
+ ///
+ public sealed class HyperVProviderClassUnavailableException : HyperVConsoleException
+ {
+ public HyperVProviderClassUnavailableException(string message, Exception innerException = null) : base(message, innerException) { }
+ }
+
+ ///
+ /// Thrown when a Hyper-V management operation exceeds its timeout.
+ ///
+ public sealed class HyperVManagementTimeoutException : HyperVConsoleException
+ {
+ public HyperVManagementTimeoutException(string message, Exception innerException = null) : base(message, innerException) { }
+ }
+
///
/// Thrown when the requested VM id cannot be found on the local Hyper-V host.
///
diff --git a/src/HyperVConsoleKit/HyperVConsoleClient.cs b/src/HyperVConsoleKit/HyperVConsoleClient.cs
index bd35b6f..1d90263 100644
--- a/src/HyperVConsoleKit/HyperVConsoleClient.cs
+++ b/src/HyperVConsoleKit/HyperVConsoleClient.cs
@@ -4,7 +4,6 @@
using System.Diagnostics;
using System.IO;
using System.Linq;
-using System.Management;
using System.Runtime.ExceptionServices;
using System.Threading;
using System.Threading.Tasks;
@@ -18,13 +17,14 @@ namespace HyperVConsoleKit
/// The client uses the local Hyper-V WMI provider at root\virtualization\v2 and must run on the Hyper-V host
/// with sufficient privileges, typically elevated or as LocalSystem.
///
- public sealed class HyperVConsoleClient
+ public sealed class HyperVConsoleClient : IDisposable
{
internal const string NamespacePath = @"root\virtualization\v2";
private readonly ManagementScope _scope;
private readonly object _wmiLock = new object();
private readonly ConcurrentDictionary _virtualMachineLocks = new ConcurrentDictionary();
private readonly HyperVConsolePolicy _policy;
+ private int _disposeState;
private static readonly TimeSpan DefaultJobTimeout = TimeSpan.FromMinutes(5);
///
/// Raised for auditable actions performed by the client or sessions opened by this client.
@@ -58,10 +58,15 @@ public HyperVConsoleClient(HyperVConsolePolicy policy)
/// Creates a client connected to a specific Hyper-V WMI namespace with a default console policy.
///
public HyperVConsoleClient(string namespacePath, HyperVConsolePolicy policy)
+ : this(new ManagementScope(namespacePath), policy)
+ {
+ }
+
+ internal HyperVConsoleClient(ManagementScope scope, HyperVConsolePolicy policy)
{
_policy = (policy ?? new HyperVConsolePolicy()).Clone();
_policy.Validate();
- _scope = new ManagementScope(namespacePath);
+ _scope = scope ?? throw new ArgumentNullException(nameof(scope));
_scope.Connect();
}
@@ -70,6 +75,7 @@ public HyperVConsoleClient(string namespacePath, HyperVConsolePolicy policy)
///
public IReadOnlyList GetVirtualMachines()
{
+ ThrowIfDisposed();
try
{
lock (_wmiLock)
@@ -77,7 +83,16 @@ public IReadOnlyList GetVirtualMachines()
using (var searcher = new ManagementObjectSearcher(_scope, new ObjectQuery("SELECT * FROM Msvm_ComputerSystem WHERE Caption = 'Virtual Machine'")))
using (var results = searcher.Get())
{
- return results.Cast().Select(ToVirtualMachine).ToList();
+ var virtualMachines = new List();
+ foreach (var vm in results)
+ {
+ using (vm)
+ {
+ virtualMachines.Add(ToVirtualMachine(vm));
+ }
+ }
+
+ return virtualMachines;
}
}
}
@@ -138,6 +153,7 @@ public IHyperVConsoleSession OpenConsole(Guid virtualMachineId)
///
public IHyperVConsoleSession OpenConsole(Guid virtualMachineId, HyperVConsoleOpenOptions options)
{
+ ThrowIfDisposed();
if (options == null)
{
options = new HyperVConsoleOpenOptions();
@@ -159,6 +175,7 @@ public IHyperVConsoleSession OpenConsole(Guid virtualMachineId, HyperVConsoleOpe
internal HyperVConsolePolicy GetPolicySnapshot()
{
+ ThrowIfDisposed();
return _policy.Clone();
}
@@ -167,6 +184,7 @@ internal HyperVConsolePolicy GetPolicySnapshot()
///
public HyperVConsoleCapabilities GetConsoleCapabilities(Guid virtualMachineId)
{
+ ThrowIfDisposed();
lock (GetVirtualMachineLock(virtualMachineId))
{
using (var vm = GetVirtualMachineObject(virtualMachineId))
@@ -181,6 +199,7 @@ public HyperVConsoleCapabilities GetConsoleCapabilities(Guid virtualMachineId)
///
public HyperVEnhancedSessionLaunchInfo GetEnhancedSessionLaunchInfo(Guid virtualMachineId)
{
+ ThrowIfDisposed();
var vm = GetVirtualMachine(virtualMachineId);
var vmConnectPath = FindVmConnectPath();
return new HyperVEnhancedSessionLaunchInfo
@@ -200,6 +219,7 @@ public HyperVEnhancedSessionLaunchInfo GetEnhancedSessionLaunchInfo(Guid virtual
///
public bool TryLaunchEnhancedSession(Guid virtualMachineId)
{
+ ThrowIfDisposed();
var launchInfo = GetEnhancedSessionLaunchInfo(virtualMachineId);
if (!launchInfo.CanLaunchFromCurrentProcess)
{
@@ -305,6 +325,7 @@ internal ManagementObject GetVirtualMachineObject(Guid id)
{
foreach (ManagementObject vm in results)
{
+ results.Detach(vm);
return vm;
}
}
@@ -313,6 +334,16 @@ internal ManagementObject GetVirtualMachineObject(Guid id)
}
internal static void EnsureCompleted(string wmiClass, string methodName, ManagementBaseObject outParams, ManagementScope scope)
+ {
+ EnsureCompleted(wmiClass, methodName, outParams, scope, DefaultJobTimeout);
+ }
+
+ internal static void EnsureCompleted(
+ string wmiClass,
+ string methodName,
+ ManagementBaseObject outParams,
+ ManagementScope scope,
+ TimeSpan timeout)
{
var returnCode = Convert.ToUInt32(outParams["ReturnValue"]);
if (returnCode == WmiReturnCode.Completed)
@@ -322,7 +353,7 @@ internal static void EnsureCompleted(string wmiClass, string methodName, Managem
if (returnCode == WmiReturnCode.Started)
{
- WaitForJob(wmiClass, methodName, outParams, scope, DefaultJobTimeout);
+ WaitForJob(wmiClass, methodName, outParams, scope, timeout);
return;
}
@@ -336,20 +367,21 @@ internal static void EnsureCompleted(string wmiClass, string methodName, Managem
private static void WaitForJob(string wmiClass, string methodName, ManagementBaseObject outParams, ManagementScope scope, TimeSpan timeout)
{
+ var returnedJob = outParams["Job"] as ManagementObject;
var jobPath = outParams["Job"] as string;
- if (string.IsNullOrEmpty(jobPath))
+ if (returnedJob == null && string.IsNullOrEmpty(jobPath))
{
throw new HyperVWmiException(wmiClass, methodName, WmiReturnCode.Started);
}
- using (var job = new ManagementObject(scope, new ManagementPath(jobPath), null))
+ using (var job = returnedJob ?? new ManagementObject(scope, new ManagementPath(jobPath), null))
{
var deadline = DateTime.UtcNow.Add(timeout);
while (true)
{
if (DateTime.UtcNow > deadline)
{
- throw new HyperVConsoleException(string.Format("Timed out waiting for Hyper-V WMI job. Class={0}, Method={1}, Timeout={2}.", wmiClass, methodName, timeout));
+ throw new HyperVManagementTimeoutException(string.Format("Timed out waiting for Hyper-V WMI job. Class={0}, Method={1}, Timeout={2}.", wmiClass, methodName, timeout));
}
job.Get();
@@ -561,6 +593,7 @@ private static ManagementObject GetFirstRelatedObject(ManagementObject source, s
{
foreach (ManagementObject item in related)
{
+ related.Detach(item);
return item;
}
}
@@ -751,6 +784,25 @@ internal static HyperVConsoleAuditEvent CreateAuditEvent(Guid virtualMachineId,
};
}
+ ///
+ /// Releases the local CIM session owned by this client. Dispose console
+ /// sessions opened by the client before disposing the client.
+ ///
+ public void Dispose()
+ {
+ if (Interlocked.Exchange(ref _disposeState, 1) == 0)
+ {
+ _scope.Dispose();
+ }
+ }
+
+ private void ThrowIfDisposed()
+ {
+ if (Volatile.Read(ref _disposeState) != 0)
+ {
+ throw new ObjectDisposedException(nameof(HyperVConsoleClient));
+ }
+ }
}
internal sealed class ConsoleFrameSize
@@ -825,7 +877,7 @@ public ConsoleFrame CaptureFrame(ConsoleFrameOptions options)
throw new HyperVConsoleCaptureNotSupportedException("No Hyper-V virtual system settings object was found for this virtual machine.");
}
- inParams["TargetSystem"] = settings.Path.Path;
+ inParams["TargetSystem"] = settings;
inParams["WidthPixels"] = (ushort)options.Width;
inParams["HeightPixels"] = (ushort)options.Height;
@@ -1552,6 +1604,7 @@ private ManagementObject GetVirtualMachineObject()
{
foreach (ManagementObject vm in results)
{
+ results.Detach(vm);
return vm;
}
}
@@ -1566,6 +1619,7 @@ private ManagementObject GetManagementService()
{
foreach (ManagementObject service in results)
{
+ results.Detach(service);
return service;
}
}
@@ -1579,6 +1633,7 @@ private static ManagementObject GetFirstRelatedObject(ManagementObject source, s
{
foreach (ManagementObject item in related)
{
+ related.Detach(item);
return item;
}
}
diff --git a/src/HyperVConsoleKit/HyperVConsoleKit.csproj b/src/HyperVConsoleKit/HyperVConsoleKit.csproj
index 8765283..835df20 100644
--- a/src/HyperVConsoleKit/HyperVConsoleKit.csproj
+++ b/src/HyperVConsoleKit/HyperVConsoleKit.csproj
@@ -1,11 +1,11 @@
- net46;netstandard2.0;net8.0-windows
+ net8.0-windows
HyperVConsoleKit
HyperVConsoleKit
HyperVConsoleKit
- 0.5.0
+ 0.6.0-preview.1
HyperVConsoleKit
Host-side emergency Hyper-V VM console access toolkit.
Hyper-V;WMI;console;virtualization
@@ -25,17 +25,14 @@
latest
-
-
-
-
-
-
+
+
+
@@ -44,8 +41,13 @@
-
-
-
+
+
+ <_SystemManagementReference Include="@(ReferencePath)"
+ Condition="'%(ReferencePath.Filename)' == 'System.Management'" />
+
+
+
diff --git a/tests/HyperVConsoleKit.Tests/CimProviderShapeTests.cs b/tests/HyperVConsoleKit.Tests/CimProviderShapeTests.cs
new file mode 100644
index 0000000..4857174
--- /dev/null
+++ b/tests/HyperVConsoleKit.Tests/CimProviderShapeTests.cs
@@ -0,0 +1,290 @@
+using Microsoft.Management.Infrastructure;
+
+namespace HyperVConsoleKit.Tests;
+
+public sealed class CimProviderShapeTests
+{
+ [Fact]
+ public void Server2012R2ShapedProviderAllowsMissingOptionalProperties()
+ {
+ var vmId = Guid.NewGuid();
+ using var scope = new FakeManagementScope(
+ vmId,
+ enabledState: 2,
+ includeEnhancedSessionTransport: false,
+ includeVideoDimensions: false);
+ using var client = new HyperVConsoleClient(scope, new HyperVConsolePolicy());
+
+ var vm = Assert.Single(client.GetVirtualMachines());
+
+ Assert.Equal(vmId, vm.Id);
+ Assert.True(vm.IsRunning);
+ Assert.True(vm.SupportsKeyboardInput);
+ Assert.False(vm.SupportsMouseInput);
+ Assert.Null(vm.RecommendedFrameWidth);
+ Assert.Null(vm.RecommendedFrameHeight);
+ Assert.Equal(HyperVEnhancedSessionTransportType.Unknown, client.GetConsoleCapabilities(vmId).EnhancedSessionTransportType);
+ }
+
+ [Theory]
+ [InlineData(2, HyperVVirtualMachineState.Running)]
+ [InlineData(3, HyperVVirtualMachineState.Off)]
+ [InlineData(6, HyperVVirtualMachineState.Saved)]
+ [InlineData(9, HyperVVirtualMachineState.Paused)]
+ [InlineData(32776, HyperVVirtualMachineState.Pausing)]
+ public void ProviderStatesMapWithoutOsVersionChecks(ushort enabledState, HyperVVirtualMachineState expected)
+ {
+ using var scope = new FakeManagementScope(Guid.NewGuid(), enabledState, true, true);
+ using var client = new HyperVConsoleClient(scope, new HyperVConsolePolicy());
+
+ Assert.Equal(expected, Assert.Single(client.GetVirtualMachines()).State);
+ }
+
+ [Fact]
+ public void EmptyProviderInventoryReturnsEmptyCollection()
+ {
+ using var scope = new FakeManagementScope(null, 3, false, false);
+ using var client = new HyperVConsoleClient(scope, new HyperVConsolePolicy());
+
+ Assert.Empty(client.GetVirtualMachines());
+ }
+
+ [Fact]
+ public void ImmediatePowerMethodCompletionUsesInternalManagementAdapter()
+ {
+ var vmId = Guid.NewGuid();
+ using var scope = new FakeManagementScope(vmId, 3, false, false);
+ using var client = new HyperVConsoleClient(scope, new HyperVConsolePolicy { AllowPowerControl = true });
+
+ client.StartVirtualMachine(vmId);
+
+ Assert.Equal("RequestStateChange", scope.LastInvokedMethod);
+ Assert.Equal((ushort)2, scope.LastInputParameters!["RequestedState"]);
+ }
+
+ [Fact]
+ public void AsynchronousPowerMethodCompletionRefreshesConcreteJob()
+ {
+ var vmId = Guid.NewGuid();
+ using var scope = new FakeManagementScope(vmId, 3, false, false)
+ {
+ ReturnAsynchronousJob = true
+ };
+ using var client = new HyperVConsoleClient(scope, new HyperVConsolePolicy { AllowPowerControl = true });
+
+ client.StartVirtualMachine(vmId);
+
+ Assert.True(scope.JobWasRefreshed);
+ }
+
+ [Fact]
+ public void FailedAsynchronousJobPreservesWmiFailureContract()
+ {
+ var vmId = Guid.NewGuid();
+ using var scope = new FakeManagementScope(vmId, 3, false, false)
+ {
+ ReturnAsynchronousJob = true,
+ JobFinalState = 10,
+ JobErrorCode = 32775
+ };
+ using var client = new HyperVConsoleClient(scope, new HyperVConsolePolicy { AllowPowerControl = true });
+
+ var exception = Assert.Throws(() => client.StartVirtualMachine(vmId));
+
+ Assert.Equal((uint)32775, exception.ReturnCode);
+ }
+
+ [Fact]
+ public void AsynchronousJobTimeoutHasSpecificException()
+ {
+ using var scope = new FakeManagementScope(Guid.NewGuid(), 3, false, false);
+ using var result = new ManagementBaseObject();
+ result["ReturnValue"] = 4096u;
+ result["Job"] = "fake-job";
+
+ Assert.Throws(
+ () => HyperVConsoleClient.EnsureCompleted(
+ "Msvm_ComputerSystem",
+ "RequestStateChange",
+ result,
+ scope,
+ TimeSpan.Zero));
+ }
+
+ [Fact]
+ public void ManagedAssemblyHasNoSystemManagementReference()
+ {
+ Assert.DoesNotContain(
+ typeof(HyperVConsoleClient).Assembly.GetReferencedAssemblies(),
+ assembly => string.Equals(assembly.Name, "System.Management", StringComparison.OrdinalIgnoreCase));
+ }
+
+ [Fact]
+ public void MissingProviderNamespaceHasSpecificException()
+ {
+ using var client = new HyperVConsoleClient(@"root\HyperVConsoleKit_DefinitelyMissing");
+
+ Assert.Throws(() => client.GetVirtualMachines());
+ }
+
+ private sealed class FakeManagementScope : ManagementScope
+ {
+ private const string Namespace = @"root\virtualization\v2";
+ private readonly Guid? _vmId;
+ private readonly ushort _enabledState;
+ private readonly bool _includeEnhancedSessionTransport;
+ private readonly bool _includeVideoDimensions;
+
+ public FakeManagementScope(
+ Guid? vmId,
+ ushort enabledState,
+ bool includeEnhancedSessionTransport,
+ bool includeVideoDimensions)
+ : base(Namespace)
+ {
+ _vmId = vmId;
+ _enabledState = enabledState;
+ _includeEnhancedSessionTransport = includeEnhancedSessionTransport;
+ _includeVideoDimensions = includeVideoDimensions;
+ }
+
+ public string? LastInvokedMethod { get; private set; }
+ public Dictionary? LastInputParameters { get; private set; }
+ public bool ReturnAsynchronousJob { get; set; }
+ public bool JobWasRefreshed { get; private set; }
+ public ushort JobFinalState { get; set; } = 7;
+ public uint JobErrorCode { get; set; }
+
+ public override void Connect()
+ {
+ }
+
+ internal override IEnumerable Query(string query)
+ {
+ if (query.Contains("Msvm_VirtualSystemManagementService", StringComparison.Ordinal))
+ {
+ return new[] { Instance("Msvm_VirtualSystemManagementService") };
+ }
+
+ if (!_vmId.HasValue || !query.Contains("Msvm_ComputerSystem", StringComparison.Ordinal))
+ {
+ return Array.Empty();
+ }
+
+ return new[]
+ {
+ Instance(
+ "Msvm_ComputerSystem",
+ ("Name", _vmId.Value.ToString("D").ToUpperInvariant(), CimType.String),
+ ("ElementName", "Compatibility VM", CimType.String),
+ ("EnabledState", _enabledState, CimType.UInt16))
+ };
+ }
+
+ internal override IEnumerable GetRelated(
+ CimInstance source,
+ string relatedClass,
+ string associationClass,
+ string resultRole,
+ string thisRole)
+ {
+ switch (relatedClass)
+ {
+ case "Msvm_VirtualSystemSettingData":
+ var settings = Instance("Msvm_VirtualSystemSettingData");
+ if (_includeEnhancedSessionTransport)
+ {
+ settings.CimInstanceProperties.Add(
+ CimProperty.Create("EnhancedSessionTransportType", (ushort)0, CimType.UInt16, CimFlags.Property));
+ }
+
+ return new[] { settings };
+
+ case "Msvm_VideoHead":
+ if (!_includeVideoDimensions)
+ {
+ return Array.Empty();
+ }
+
+ return new[]
+ {
+ Instance(
+ "Msvm_VideoHead",
+ ("CurrentHorizontalResolution", (uint)1024, CimType.UInt32),
+ ("CurrentVerticalResolution", (uint)768, CimType.UInt32),
+ ("CurrentBitsPerPixel", (uint)32, CimType.UInt32),
+ ("EnabledState", (ushort)2, CimType.UInt16))
+ };
+
+ case "Msvm_Keyboard":
+ return new[] { Instance("Msvm_Keyboard") };
+
+ case "Msvm_SyntheticMouse":
+ return Array.Empty();
+
+ default:
+ return Array.Empty();
+ }
+ }
+
+ internal override bool HasMethod(CimInstance instance, string methodName)
+ {
+ return instance.CimSystemProperties.ClassName == "Msvm_ComputerSystem"
+ ? methodName == "RequestStateChange"
+ : instance.CimSystemProperties.ClassName == "Msvm_Keyboard"
+ && (methodName == "TypeKey" || methodName == "PressKey" || methodName == "ReleaseKey");
+ }
+
+ internal override ManagementBaseObject Invoke(
+ CimInstance instance,
+ string methodName,
+ ManagementBaseObject inParameters)
+ {
+ LastInvokedMethod = methodName;
+ LastInputParameters = inParameters.Values.ToDictionary(pair => pair.Key, pair => pair.Value);
+
+ var result = new ManagementBaseObject();
+ result["ReturnValue"] = ReturnAsynchronousJob ? (uint)4096 : 0u;
+ if (ReturnAsynchronousJob)
+ {
+ result["Job"] = "fake-job";
+ }
+
+ return result;
+ }
+
+ internal override CimInstance ResolveReference(string path)
+ {
+ Assert.Equal("fake-job", path);
+ return JobInstance(4);
+ }
+
+ internal override CimInstance Refresh(CimInstance instance)
+ {
+ JobWasRefreshed = true;
+ return JobInstance(JobFinalState, JobErrorCode);
+ }
+
+ private static CimInstance JobInstance(ushort state, uint errorCode = 0)
+ {
+ return Instance(
+ "Msvm_ConcreteJob",
+ ("JobState", state, CimType.UInt16),
+ ("ErrorCode", errorCode, CimType.UInt32));
+ }
+
+ private static CimInstance Instance(
+ string className,
+ params (string Name, object Value, CimType Type)[] properties)
+ {
+ var instance = new CimInstance(className, Namespace);
+ foreach (var property in properties)
+ {
+ instance.CimInstanceProperties.Add(
+ CimProperty.Create(property.Name, property.Value, property.Type, CimFlags.Property));
+ }
+
+ return instance;
+ }
+ }
+}
diff --git a/tests/HyperVConsoleKit.Tests/HyperVConsoleKit.Tests.csproj b/tests/HyperVConsoleKit.Tests/HyperVConsoleKit.Tests.csproj
index e5492cb..da75324 100644
--- a/tests/HyperVConsoleKit.Tests/HyperVConsoleKit.Tests.csproj
+++ b/tests/HyperVConsoleKit.Tests/HyperVConsoleKit.Tests.csproj
@@ -1,7 +1,7 @@
- net9.0
+ net8.0-windows
enable
enable
false
diff --git a/validation/compatibility-matrix.json b/validation/compatibility-matrix.json
new file mode 100644
index 0000000..7db12a2
--- /dev/null
+++ b/validation/compatibility-matrix.json
@@ -0,0 +1,46 @@
+{
+ "packageVersion": "0.6.0-preview.1",
+ "required": [
+ {
+ "operatingSystem": "Windows Server 2012 R2",
+ "architecture": "x64",
+ "status": "Pending"
+ },
+ {
+ "operatingSystem": "Windows Server 2016",
+ "architecture": "x64",
+ "status": "Pending"
+ },
+ {
+ "operatingSystem": "Windows Server 2019",
+ "architecture": "x64",
+ "status": "Pending"
+ },
+ {
+ "operatingSystem": "Windows Server 2022",
+ "architecture": "x64",
+ "status": "Pending"
+ },
+ {
+ "operatingSystem": "Windows Server 2025",
+ "architecture": "x64",
+ "status": "Pass",
+ "build": "26100",
+ "validatedUtc": "2026-07-27",
+ "identities": [
+ "Elevated administrator",
+ "LocalSystem"
+ ]
+ },
+ {
+ "operatingSystem": "Windows 10",
+ "architecture": "x64",
+ "status": "Pending"
+ },
+ {
+ "operatingSystem": "Windows 11",
+ "architecture": "x64",
+ "status": "Pending"
+ }
+ ]
+}